diff --git a/tools/RioSerialMonitor/E0Test.cs b/tools/RioSerialMonitor/E0Test.cs
new file mode 100644
index 0000000..a228fe1
--- /dev/null
+++ b/tools/RioSerialMonitor/E0Test.cs
@@ -0,0 +1,273 @@
+using System.Diagnostics;
+using RioJoy.Core.Serial;
+
+namespace RioSerialMonitor;
+
+///
+/// E0-threshold firmware verification (rio-firmware `--e0thresh` images).
+///
+/// Two phases against a DTR-reset board (counters zeroed, display F0000000):
+/// - Phase A (gate hold): analog requests whose reply we ACK only after the
+/// first retransmit — each event increments the board's retransmit
+/// counter ($3184) by a small sub-threshold amount, calling the display
+/// renderer every time. The _e0t5 gate must keep the display F0000000;
+/// stock firmware paints E0... at the first retry.
+/// - Phase B (flip): analog requests we never ACK — the board runs the full
+/// retry cycle (observed: 5 retransmits) and gives up ($3185++, RESTART
+/// $FE). The counters cross the threshold and the display must flip to
+/// the E0 readout, which the tool predicts from observed traffic.
+///
+/// dotnet run --project tools/RioSerialMonitor -- --e0test [port]
+/// [--baud rate] [--hold n] [--flip n]
+///
+/// No --baud probes 9600, 31250, 62500 (DTR-resetting each try). HANDS OFF
+/// buttons/keypads during the run — an unACKed button packet shifts the
+/// counters (the tool folds observed traffic into its predictions either
+/// way). Exit: 0 = ran, 2 = no board reply on any baud.
+///
+internal static class E0Test
+{
+ private const int Threshold = 5;
+
+ private enum AckMode { Never, Immediate, NakThenAck }
+
+ public static async Task RunAsync(string[] args)
+ {
+ string port = "COM1";
+ int baud = 0, holdEvents = 4, flipEvents = 1;
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--e0test": break;
+ case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b): baud = b; i++; break;
+ case "--hold" when i + 1 < args.Length && int.TryParse(args[i + 1], out int h) && h >= 0: holdEvents = h; i++; break;
+ case "--flip" when i + 1 < args.Length && int.TryParse(args[i + 1], out int f) && f > 0: flipEvents = f; i++; break;
+ default:
+ if (!args[i].StartsWith("--")) port = args[i];
+ break;
+ }
+ }
+
+ var sw = Stopwatch.StartNew();
+ object gate = new();
+ var buffer = new List();
+ int cursor = 0;
+
+ // Reader-thread ACK automation (latency matters: the board's ACK wait
+ // at 9600 is ~4ms/retry, so ACKs must not wait for a polling loop).
+ AckMode ackMode = AckMode.Never;
+ int replyByte = 0x87; // frame command byte the current phase expects
+ int frameLen = 12; // full frame: cmd + payload + checksum
+ int repliesSeen = 0; // completed frames of replyByte since phase reset
+ int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
+ bool ackSent = false;
+
+ SerialPortTransport? transport = null;
+ Task? reader = null;
+
+ void PhaseReset(AckMode mode, int expectReply, int expectLen)
+ {
+ lock (gate)
+ {
+ ackMode = mode;
+ replyByte = expectReply;
+ frameLen = expectLen;
+ repliesSeen = 0;
+ frameCountdown = 0;
+ ackSent = false;
+ cursor = buffer.Count;
+ }
+ }
+
+ int[] bauds = baud != 0 ? new[] { baud } : new[] { 9600, 31250, 62500 };
+ (int Major, int Minor)? version = null;
+
+ foreach (int tryBaud in bauds)
+ {
+ Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] probing {port} @ {tryBaud} (DTR reset, ~2s boot wait)...");
+ try { transport = new SerialPortTransport(port, tryBaud); }
+ catch (Exception ex)
+ {
+ Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
+ return 2;
+ }
+
+ lock (gate) { buffer.Clear(); cursor = 0; }
+ SerialPortTransport t = transport;
+ reader = Task.Run(async () =>
+ {
+ var tmp = new byte[256];
+ try
+ {
+ while (true)
+ {
+ int n = await t.ReadAsync(tmp, CancellationToken.None);
+ if (n <= 0) break;
+ byte? respond = null;
+ lock (gate)
+ {
+ for (int i = 0; i < n; i++)
+ {
+ buffer.Add(tmp[i]);
+ // Frame tracking: respond only once the FULL reply
+ // frame is on the wire — the board arms its
+ // reply-await when the frame finishes sending; a
+ // response mid-frame is ignored.
+ if (frameCountdown > 0)
+ {
+ if (--frameCountdown > 0) continue;
+ repliesSeen++;
+ if (ackMode == AckMode.Immediate && !ackSent)
+ {
+ ackSent = true;
+ respond = 0xFC;
+ }
+ else if (ackMode == AckMode.NakThenAck && repliesSeen <= 2)
+ {
+ // frame 1 -> NAK (board resends, $3184++),
+ // frame 2 -> ACK (cycle ends cleanly).
+ respond = repliesSeen == 1 ? (byte)0xFD : (byte)0xFC;
+ ackSent = repliesSeen == 2;
+ }
+ }
+ else if (tmp[i] == replyByte)
+ {
+ frameCountdown = frameLen - 1;
+ }
+ }
+ }
+ if (respond is byte r)
+ await t.WriteAsync(new byte[] { r }, CancellationToken.None);
+ }
+ }
+ catch { /* port closed */ }
+ });
+
+ await Task.Delay(2000); // boot: counters cleared, display F0000000
+
+ PhaseReset(AckMode.Immediate, 0x86, 4); // version reply: cmd+2+ck
+ await t.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
+ byte[] got = await Collect(1200, b => IndexOf(b, 0x86) >= 0);
+ int vi = IndexOf(got, 0x86);
+ if (vi >= 0)
+ {
+ if (vi + 2 < got.Length) version = (got[vi + 1], got[vi + 2]);
+ baud = tryBaud;
+ break;
+ }
+
+ Console.WriteLine($" no version reply at {tryBaud}");
+ transport.Dispose();
+ transport = null;
+ if (reader is not null) await reader;
+ }
+
+ if (transport is null)
+ {
+ Console.WriteLine(" no board reply on any baud — is the board powered / the right chip in?");
+ return 2;
+ }
+
+ // Handshake bookkeeping: fold any retries/give-up into the prediction.
+ await Task.Delay(300);
+ byte[] hs = Drain();
+ int retx = Math.Max(0, CountAll(0x86) - 1); // $3184 prediction
+ int giveup = SeenAll(0xFE) ? 1 : 0; // $3185 prediction
+
+ Console.WriteLine();
+ Console.WriteLine($"== E0-threshold test :: {transport.Description}, firmware {version?.Major}.{version?.Minor} ==");
+ Console.WriteLine($" threshold {Threshold}; phase A: {holdEvents} sub-threshold event(s), phase B: {flipEvents} give-up event(s)");
+ if (retx > 0 || giveup > 0)
+ Console.WriteLine($" note: handshake cost {retx} retransmit(s){(giveup > 0 ? " + a give-up" : "")}; predictions include them.");
+ Console.WriteLine(" HANDS OFF buttons/keypads for the whole run.");
+ Console.WriteLine();
+ string expected = Expected(retx, giveup);
+ Console.WriteLine($">>> WATCH THE 8-DIGIT DISPLAY. It should read {expected} right now. <<<");
+ await Task.Delay(4000);
+
+ int eventNo = 0;
+ bool ok = true;
+
+ for (int k = 0; k < holdEvents && ok; k++)
+ ok = await RunEvent(AckMode.NakThenAck, "hold");
+ for (int k = 0; k < flipEvents && ok; k++)
+ ok = await RunEvent(AckMode.Never, "flip");
+
+ Console.WriteLine();
+ Console.WriteLine("== done ==");
+ Console.WriteLine($" final predicted counters: retx=${retx:X2} giveup=${giveup:X2} -> display {Expected(retx, giveup)}");
+ Console.WriteLine(" PASS = display held F0000000 through every sub-threshold line above and");
+ Console.WriteLine(" matched the E0 predictions after the threshold crossing.");
+ Console.WriteLine(" (Stock firmware flips at the very first retry.) DTR reset restores F0.");
+
+ transport.Dispose();
+ if (reader is not null) await reader;
+ return 0;
+
+ async Task RunEvent(AckMode mode, string phase)
+ {
+ eventNo++;
+ PhaseReset(mode, 0x87, 12); // analog reply: cmd+10+ck
+ await transport!.WriteAsync(new byte[] { 0x82, 0x02 }, CancellationToken.None);
+
+ byte[] bytes = mode == AckMode.Never
+ ? await Collect(2500, b => IndexOf(b, 0xFE) >= 0)
+ : await Collect(800, _ => false);
+ await Task.Delay(250); // let the cycle finish either way
+ bytes = Concat(bytes, Drain());
+
+ int transmissions = Count(bytes, 0x87);
+ bool sawRestart = IndexOf(bytes, 0xFE) >= 0;
+ if (transmissions == 0)
+ {
+ Console.WriteLine($"[event {eventNo}] NO analog reply ({bytes.Length} bytes) — aborting.");
+ return false;
+ }
+
+ retx += Math.Max(0, transmissions - 1);
+ if (sawRestart) giveup++;
+
+ Console.WriteLine($"[event {eventNo}] {phase}: reply x{transmissions} " +
+ $"({transmissions - 1} retries), give-up={sawRestart} " +
+ $"-> retx=${retx:X2} giveup=${giveup:X2}");
+ Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(retx, giveup)} <<<");
+ await Task.Delay(4000);
+ return true;
+ }
+
+ string Expected(int r, int g) =>
+ r >= Threshold || g >= Threshold ? $"E000{r:X2}{g:X2}" : "F0000000";
+
+ byte[] Drain()
+ {
+ lock (gate)
+ {
+ byte[] r = buffer.Skip(cursor).ToArray();
+ cursor = buffer.Count;
+ return r;
+ }
+ }
+
+ int CountAll(byte v) { lock (gate) return buffer.Count(x => x == v); }
+ bool SeenAll(byte v) { lock (gate) return buffer.Contains(v); }
+
+ async Task Collect(int ms, Func done)
+ {
+ var end = sw.Elapsed + TimeSpan.FromMilliseconds(ms);
+ var all = new List();
+ while (sw.Elapsed < end)
+ {
+ all.AddRange(Drain());
+ if (done(all.ToArray())) break;
+ await Task.Delay(15);
+ }
+ all.AddRange(Drain());
+ return all.ToArray();
+ }
+
+ static int IndexOf(byte[] a, byte v) => Array.IndexOf(a, v);
+ static int Count(byte[] a, byte v) => a.Count(x => x == v);
+ static byte[] Concat(byte[] a, byte[] b) => a.Concat(b).ToArray();
+ }
+}
diff --git a/tools/RioSerialMonitor/Program.cs b/tools/RioSerialMonitor/Program.cs
index b72acd7..e2e5ebb 100644
--- a/tools/RioSerialMonitor/Program.cs
+++ b/tools/RioSerialMonitor/Program.cs
@@ -15,6 +15,11 @@ using RioJoy.Core.Serial;
// --baud: 31250 for the retuned firmware variant (rio-firmware --baud31250).
// Exit: 0 = ran, 2 = could not open the port (mash: 1 = wedge detected).
+// E0-threshold firmware verification (rio-firmware --e0thresh images, see E0Test.cs):
+// dotnet run --project tools/RioSerialMonitor -- --e0test [port] [--baud rate] [--events n]
+if (args.Contains("--e0test"))
+ return await RioSerialMonitor.E0Test.RunAsync(args);
+
if (args.Contains("--mash"))
return await RioSerialMonitor.MashTest.RunAsync(args);