From 1b3627373cc9b08c2a7c963d4de7b92bc5afe7cb Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 16 Oct 2026 22:45:42 +0100 Subject: [PATCH 1/5] engine: a frame the receiver locked onto and lost reaches the chip A collision now says where it landed. More than one destroyed symbol inside the eight-symbol header block, coded at CR 4/8, is a header the receiver could not read; anything else lost is a payload that failed its CRC. The firmware's chip is told the same through a new bridge kind, 0x0A, and raises HeaderErr, or RxDone beside CrcErr, as the part does. Waveform mode takes the answer from the demodulator. Before, a lost frame reached the firmware as nothing at all, so every error path in a radio driver went unexercised. A node that does not know the kind skips it, as it skips any kind, and behaves as it always has. --- docs/firmware-integration.md | 2 +- docs/shortcomings.md | 29 +++ internal/firmware/bridge.go | 37 +++- internal/firmware/bridge_test.go | 33 ++++ internal/sim/engine/collision.go | 53 +++++- internal/sim/engine/headercollision_test.go | 165 ++++++++++++++++++ internal/sim/engine/transmissions.go | 29 ++- internal/sim/engine/waveform.go | 4 + .../renode/peripherals/VirtualSX1262Engine.cs | 22 ++- tools/renode/peripherals/VirtualSX1262Lib.cs | 9 + 10 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 internal/sim/engine/headercollision_test.go diff --git a/docs/firmware-integration.md b/docs/firmware-integration.md index d6697c43..cb1148a2 100644 --- a/docs/firmware-integration.md +++ b/docs/firmware-integration.md @@ -72,7 +72,7 @@ abstraction hides, ADR-0002 needs revisiting rather than patching. |---|---| | `startSendRaw` | Enqueue frame to the RF engine with node id and sim time. Return false if already transmitting. | | `isSendComplete` | True once sim time ≥ start + airtime. | -| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here** — everything else is recorded but withheld, exactly like real hardware. | +| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here.** A frame the receiver locked onto and lost reaches the chip as a header error or a CRC error, as the part reports it; one it never locked onto is recorded and withheld, exactly like real hardware. | | `getEstAirtimeFor` | Must equal our own airtime formula. Test this first. | | `getNoiseFloor` | Real value from the channel, including external emitters. | | `setCADEnabled` / CAD result | Answer from actual channel energy, not a guess. This is where firmware CSMA becomes observable. | diff --git a/docs/shortcomings.md b/docs/shortcomings.md index ece31b3d..b24cbaf1 100644 --- a/docs/shortcomings.md +++ b/docs/shortcomings.md @@ -483,6 +483,35 @@ the same pair and puts it in the status line, the sweep panel prints it beside the cost estimate before the run starts, and the results panel repeats it above the table. Use native for anything being compared. +### 3.2b A failed frame reaches the chip from a collision, and from nothing else + +A frame a receiver locked onto and lost reaches the firmware's chip as the part +reports it: a header it could not read raises `HeaderErr` and nothing more, and +a header that read with a payload that did not raises `RxDone` beside `CrcErr`, +with the corrupt bytes in the buffer. In calculated mode the engine decides +which from where a collision landed: more than one destroyed symbol inside +the eight-symbol header block, which is coded at CR 4/8, is a header error, and +anything else that is lost is a CRC error. In waveform mode the demodulator +decides. + +Three things are still kinder than the part: + +- **Only collisions fail a frame.** A signal near the floor that the chip would + lock onto and then lose is, in calculated mode, simply not heard. Real radios + raise header and CRC errors on weak signals all the time. +- **The failure arrives when the packet ends.** The calculated path judges a + reception once the whole packet is on the air, so `HeaderErr` is raised at the + end of the frame rather than the end of its header. +- **The detection flags clear with the carrier.** The datasheet holds + `PreambleDetected` and `HeaderValid` until the firmware clears them; the chip + model drops them when the engine says the channel has gone quiet. It does so + because, until failed frames reached the chip, a frame the engine withheld + left `HeaderValid` up with nothing to end it. + +**Consequence.** A firmware's error paths now run, so a fault in how it handles +a stale `HeaderErr` can be seen. A fault that depends on a detection flag +outliving its carrier cannot, yet. + ### 3.3 Native nodes cannot catch anything architecture-dependent A native node is x86-64 or arm64, 64-bit, with a different `int` width in diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index 13be433b..ef564d1f 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -56,6 +56,28 @@ const ( // it - only the engine has every waveform in flight and how strongly each // one arrives at a given antenna. kindChannelBusy = 0x08 + + // kindFrameFailed is host → node: a frame this receiver locked onto and + // could not trust. The first payload byte says where it failed, the rest is + // the frame as it arrived. A real SX1262 says so - HeaderErr for a header + // it could not read, RxDone beside CrcErr for a payload it could not - and + // a firmware that never hears either has error paths nothing exercises. + // A node built before this kind existed skips it, as it skips any kind it + // does not know, and sees what it always saw: nothing. + kindFrameFailed = 0x0A +) + +// FrameFailure is where a frame the receiver locked onto went wrong, numbered +// as virtual-sx1262's vsx_deliver_frame_failed numbers it. +type FrameFailure byte + +const ( + // FailedCRC is a header that decoded and a payload that did not: the chip + // raises RxDone beside CrcErr and the corrupt bytes are in its buffer. + FailedCRC FrameFailure = 1 + // FailedHeader is a header the receiver could not read: the chip raises + // HeaderErr, and no RxDone follows. + FailedHeader FrameFailure = 2 ) // ErrClosed is returned once a bridge has been shut down. @@ -274,8 +296,9 @@ func (b *Bridge) read(c net.Conn) { } // Deliver hands a frame to the emulated firmware — a frame the channel decided -// this node received. Only CRC-passing frames should be delivered; everything -// else belongs in the ledger and is withheld, exactly as on real hardware. +// this node received. Only CRC-passing frames go this way; a frame the receiver +// locked onto and could not trust goes through DeliverFailed, and one it never +// locked onto reaches the firmware not at all, exactly as on real hardware. func (b *Bridge) Deliver(frame []byte) error { if err := b.send(kindFrame, frame); err != nil { return fmt.Errorf("firmware: deliver: %w", err) @@ -283,6 +306,16 @@ func (b *Bridge) Deliver(frame []byte) error { return nil } +// DeliverFailed tells the firmware's chip that a frame it locked onto failed, +// and where. Nothing is decoded from it; the frame rides along because a CRC +// failure leaves the corrupt bytes in the chip's buffer, as the part does. +func (b *Bridge) DeliverFailed(frame []byte, why FrameFailure) error { + if err := b.send(kindFrameFailed, append([]byte{byte(why)}, frame...)); err != nil { + return fmt.Errorf("firmware: deliver failed frame: %w", err) + } + return nil +} + func (b *Bridge) send(kind byte, payload []byte) error { b.mu.Lock() c, closed := b.conn, b.closed diff --git a/internal/firmware/bridge_test.go b/internal/firmware/bridge_test.go index 91444ab7..a9f0ed64 100644 --- a/internal/firmware/bridge_test.go +++ b/internal/firmware/bridge_test.go @@ -82,6 +82,39 @@ func TestDeliveryReachesTheEmulator(t *testing.T) { } } +// A frame the receiver locked onto and lost reaches the chip as its own kind, +// with where it failed in front of the bytes, so the chip can raise HeaderErr +// or CrcErr rather than nothing. +func TestAFailedFrameReachesTheEmulatorWithItsCause(t *testing.T) { + b, _ := Listen("127.0.0.1:0", "GB7XYZ") + defer func() { _ = b.Close() }() + c := fakeRadio(t, b.Addr()) + defer func() { _ = c.Close() }() + + for i := 0; i < 50 && !b.Attached(); i++ { + time.Sleep(10 * time.Millisecond) + } + if err := b.DeliverFailed([]byte{0x22, 0xBB}, FailedHeader); err != nil { + t.Fatal(err) + } + + var hdr [3]byte + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadFull(c, hdr[:]); err != nil { + t.Fatal(err) + } + if hdr[0] != 0x0A { + t.Errorf("wrong frame kind 0x%02x, want 0x0a", hdr[0]) + } + buf := make([]byte, binary.BigEndian.Uint16(hdr[1:])) + if _, err := io.ReadFull(c, buf); err != nil { + t.Fatal(err) + } + if string(buf) != string([]byte{2, 0x22, 0xBB}) { + t.Errorf("emulator received % x, want 02 22 bb", buf) + } +} + // The point of the bridge: an emulated node's frame goes through the SAME // channel as a native one. If it took a different path the two backends could // not be compared, which is the whole reason ADR-0010 keeps both. diff --git a/internal/sim/engine/collision.go b/internal/sim/engine/collision.go index 168442c6..77c9227f 100644 --- a/internal/sim/engine/collision.go +++ b/internal/sim/engine/collision.go @@ -21,18 +21,27 @@ package engine import ( "math" + + "github.com/MeshBench/meshbench/internal/firmware" + "github.com/MeshBench/meshbench/internal/rf/dsp" ) // corruptedSymbols counts how many of a transmission's symbols were destroyed -// by co-channel traffic the receiver could not capture over. +// by co-channel traffic the receiver could not capture over, in the whole +// packet and in its header block alone. // // Overlaps are unioned, not summed: two interferers stamping on the same // stretch of a packet destroy it once. Anything the wanted signal leads by the // capture threshold contributes nothing, however long it overlaps for. A hit // shorter than one symbol still costs a whole symbol, because a chirp // interrupted anywhere lands in the wrong FFT bin. +// +// The header is counted apart because the chip answers differently for it: a +// header it cannot read raises HeaderErr and nothing more, where a payload it +// cannot read raises RxDone beside CrcErr. Which of the two a firmware is told +// decides which of its error paths runs. func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, - concurrent []transmission, nodes []*Node, txPHY phy) float64 { + concurrent []transmission, nodes []*Node, txPHY phy) (total, header float64) { type span struct{ from, to uint32 } var hits []span @@ -59,7 +68,7 @@ func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, hits = append(hits, span{from, to}) } if len(hits) == 0 { - return 0 + return 0, 0 } // Union by sweep, so two interferers over the same symbols cost one packet // rather than two. Insertion sort: the list is one entry per concurrent @@ -85,9 +94,43 @@ func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, symbolMs := 1000 * math.Pow(2, float64(txPHY.sf)) / txPHY.bandwidthHz if symbolMs <= 0 { - return 0 + return 0, 0 + } + hdrFrom, hdrTo := headerBlockMs(t.startMs, txPHY.sf, symbolMs) + var headerMs float64 + for _, h := range hits { + from, to := math.Max(float64(h.from), hdrFrom), math.Min(float64(h.to), hdrTo) + if to > from { + headerMs += to - from + } + } + return math.Ceil(float64(damagedMs) / symbolMs), math.Ceil(math.Min(headerMs, hdrTo-hdrFrom) / symbolMs) +} + +// headerBlockMs is where a transmission's explicit header sits on the air: the +// first interleaver block after the preamble, the two sync symbols and the +// 2.25-symbol start of frame, eight symbols long and always coded at CR 4/8. +func headerBlockMs(startMs uint32, sf int, symbolMs float64) (from, to float64) { + from = float64(startMs) + (float64(dsp.PreambleSymbols(sf))+4.25)*symbolMs + return from, from + 8*symbolMs +} + +// headerSurvives reports whether the header block can be put back together. +// It is coded at CR 4/8 whatever the payload uses, so one destroyed symbol is +// repaired and a second is fatal, by the same interleaver argument as below. +func headerSurvives(damagedSymbols float64) bool { + return damagedSymbols <= 1 +} + +// failureOf is what the chip raises for a frame it locked onto and lost: a +// header it could not read is HeaderErr and nothing more; a header that read +// and a payload that did not is RxDone beside CrcErr, with the bytes the +// receive chain produced left in the buffer. +func failureOf(headerOK bool) firmware.FrameFailure { + if headerOK { + return firmware.FailedCRC } - return math.Ceil(float64(damagedMs) / symbolMs) + return firmware.FailedHeader } // survivesCorruption reports whether forward error correction can put a packet diff --git a/internal/sim/engine/headercollision_test.go b/internal/sim/engine/headercollision_test.go new file mode 100644 index 00000000..15aa368f --- /dev/null +++ b/internal/sim/engine/headercollision_test.go @@ -0,0 +1,165 @@ +package engine_test + +import ( + "context" + "encoding/binary" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/MeshBench/meshbench/internal/firmware" + "github.com/MeshBench/meshbench/internal/sim/engine" +) + +// Where a collision lands decides what the chip reports. A hit on the header +// block is a header the receiver could not read - HeaderErr, and no RxDone - +// while a hit after it is a payload that failed its CRC. Firmware takes a +// different path for each, so the ledger has to tell them apart. +// +// At SF8 and 125 kHz a symbol is 2.048 ms, the 32-symbol preamble with its +// sync and start of frame ends 74 ms in, and the header block runs to 91 ms. +func TestACollisionOnTheHeaderIsAHeaderError(t *testing.T) { + detail := func(interfererStartMs uint32) string { + t.Helper() + e := engine.New(flat{100}, engine.Config{StepMs: 10, Seed: 4417}) + e.Add(wfNode("a", 0, 22), nil) + e.Add(wfNode("b", 0.010, 22), nil) + e.Add(wfNode("c", 0.020, 22), nil) + + frame := make([]byte, 40) + for i := range frame { + frame[i] = byte(37 + i*11) + } + if err := e.Run(context.Background(), 10); err != nil { + t.Fatal(err) + } + e.InjectFrame(0, frame) + if err := e.Run(context.Background(), interfererStartMs); err != nil { + t.Fatal(err) + } + e.InjectFrame(2, frame[:20]) + if err := e.Run(context.Background(), 8000); err != nil { + t.Fatal(err) + } + for _, ev := range e.Events() { + if ev.From == "a" && ev.To == "b" { + if ev.Kind != "miss" { + t.Fatalf("an equal-power collision it could not capture over decoded anyway (%s: %s)", + ev.Kind, ev.Detail) + } + return ev.Detail + } + } + t.Fatal("b recorded nothing for a's packet") + return "" + } + + if d := detail(60); !strings.Contains(d, "header symbols were destroyed") { + t.Fatalf("a collision across the header block was not reported as a header error: %q", d) + } + if d := detail(200); !strings.Contains(d, "decoded its header") { + t.Fatalf("a collision after the header block was reported as something else: %q", d) + } +} + +// And the firmware is told. A receiver running firmware is handed the failure +// as the chip would raise it - header or CRC, the first byte of the message - +// rather than nothing, which is all it was ever handed before. +func TestTheFirmwareIsToldWhereAFrameFailed(t *testing.T) { + told := func(interfererStartMs uint32) []byte { + t.Helper() + e := engine.New(flat{100}, engine.Config{StepMs: 10, Seed: 4417}) + defer func() { _ = e.Close() }() + br, err := firmware.Listen("127.0.0.1:0", "b") + if err != nil { + t.Fatalf("listen: %v", err) + } + e.Add(wfNode("a", 0, 22), nil) + e.Add(wfNode("b", 0.010, 22), &firmware.Node{Bridge: br, Backend: noopBackend{}}) + e.Add(wfNode("c", 0.020, 22), nil) + peer := dialRecorder(t, br.Addr()) + defer func() { _ = peer.conn.Close() }() + for i := 0; i < 200 && !br.Attached(); i++ { + time.Sleep(5 * time.Millisecond) + } + + frame := make([]byte, 40) + for i := range frame { + frame[i] = byte(37 + i*11) + } + if err := e.Run(context.Background(), 10); err != nil { + t.Fatal(err) + } + e.InjectFrame(0, frame) + if err := e.Run(context.Background(), interfererStartMs); err != nil { + t.Fatal(err) + } + e.InjectFrame(2, frame[:20]) + if err := e.Run(context.Background(), 8000); err != nil { + t.Fatal(err) + } + return peer.failed() + } + + if got := told(60); len(got) != 1+40 || got[0] != byte(firmware.FailedHeader) { + t.Fatalf("a header destroyed by a collision reached the firmware as % x, want a header failure", got) + } + if got := told(200); len(got) != 1+40 || got[0] != byte(firmware.FailedCRC) { + t.Fatalf("a payload destroyed by a collision reached the firmware as % x, want a CRC failure", got) + } +} + +// recorder acks every tick, as firmware does, and keeps the first failed +// frame it is handed. +type recorder struct { + conn net.Conn + mu sync.Mutex + got []byte +} + +func dialRecorder(t *testing.T, addr string) *recorder { + t.Helper() + c, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial bridge: %v", err) + } + r := &recorder{conn: c} + go r.serve() + return r +} + +func (r *recorder) serve() { + var hdr [3]byte + for { + if _, err := readFull(r.conn, hdr[:]); err != nil { + return + } + buf := make([]byte, binary.BigEndian.Uint16(hdr[1:])) + if len(buf) > 0 { + if _, err := readFull(r.conn, buf); err != nil { + return + } + } + switch { + case hdr[0] == fakeKindTick && len(buf) >= 4: + reply := []byte{fakeKindAck, 0, 4, buf[0], buf[1], buf[2], buf[3]} + if _, err := r.conn.Write(reply); err != nil { + return + } + case hdr[0] == 0x0A: + r.mu.Lock() + if r.got == nil { + r.got = buf + } + r.mu.Unlock() + } + } +} + +func (r *recorder) failed() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return r.got +} diff --git a/internal/sim/engine/transmissions.go b/internal/sim/engine/transmissions.go index 36cbf3f2..c436fbc6 100644 --- a/internal/sim/engine/transmissions.go +++ b/internal/sim/engine/transmissions.go @@ -257,9 +257,9 @@ func (e *Engine) deliver(t transmission, concurrent []transmission, cache modCac // What a collision destroyed on the way through, for a packet that // won the demodulator in the first place. - damaged, repaired, survives := 0.0, 0, true + damaged, headerDamaged, repaired, survives := 0.0, 0.0, 0, true if !deaf && held == "" { - damaged = e.corruptedSymbols(i, t, rxDBm, concurrent, nodes, txPHY) + damaged, headerDamaged = e.corruptedSymbols(i, t, rxDBm, concurrent, nodes, txPHY) repaired, survives = survivesCorruption(damaged, txPHY.codingRate) } @@ -315,14 +315,27 @@ func (e *Engine) deliver(t transmission, concurrent []transmission, cache modCac // through the demodulator; here it is the interleaver's own // guarantee applied to the overlap the timing actually produced. rec.Outcome = capture.NotDemodulated - rec.Demod = true + rec.Demod = headerSurvives(headerDamaged) + why := fmt.Sprintf( + "decoded its header, then %.0f symbol(s) were destroyed by a collision "+ + "it could not capture over; beyond what CR 4/%d can repair", + damaged, txPHY.codingRate+4) + if !rec.Demod { + why = fmt.Sprintf( + "locked on, then %.0f of its 8 header symbols were destroyed by a collision "+ + "it could not capture over; beyond what the header's CR 4/8 can repair", + headerDamaged) + } e.record(Event{AtMs: t.endMs, Kind: "miss", From: src.specRef().Name, To: dst.specRef().Name, PacketID: t.packetID, MessageID: t.payload, Outcome: rec.Outcome, - SNRdB: reported, Frame: t.frame, Class: ClassCollision, - Detail: fmt.Sprintf( - "decoded its header, then %.0f symbol(s) were destroyed by a collision "+ - "it could not capture over; beyond what CR 4/%d can repair", - damaged, txPHY.codingRate+4)}) + SNRdB: reported, Frame: t.frame, Class: ClassCollision, Detail: why}) + if dst.Firmware != nil { + if err := dst.Firmware.Bridge.DeliverFailed(t.frame, failureOf(rec.Demod)); err != nil { + e.Ledger.Record(rec) + e.captureWrite(t, src, dst, txPHY, rec) + return fmt.Errorf("engine: deliver failed frame to %s: %w", dst.specRef().Name, err) + } + } case effective < required: rec.Outcome = capture.NotDemodulated // How near it came. Interference is included deliberately: a packet diff --git a/internal/sim/engine/waveform.go b/internal/sim/engine/waveform.go index cd5fa2be..757bc787 100644 --- a/internal/sim/engine/waveform.go +++ b/internal/sim/engine/waveform.go @@ -427,6 +427,10 @@ func (e *Engine) settleWaveform(t transmission, src, dst *Node, c wfCandidate, PacketID: t.packetID, MessageID: t.payload, Outcome: rec.Outcome, SNRdB: r.snrdB, Frame: t.frame, Class: class, Detail: why}) + // No lock is no event at all; a lock is one the chip reports. + if dst.Firmware != nil && r.synced { + _ = dst.Firmware.Bridge.DeliverFailed(r.payload, failureOf(r.stats.HeaderOK)) + } e.Ledger.Record(rec) e.captureWrite(t, src, dst, txPHY, rec) return diff --git a/tools/renode/peripherals/VirtualSX1262Engine.cs b/tools/renode/peripherals/VirtualSX1262Engine.cs index 669cbe7d..1d1fb26a 100644 --- a/tools/renode/peripherals/VirtualSX1262Engine.cs +++ b/tools/renode/peripherals/VirtualSX1262Engine.cs @@ -105,9 +105,9 @@ private void Handle(byte kind, byte[] payload) switch(kind) { case Frame: - // A packet the channel delivered. Only frames that passed CRC - // arrive here, exactly as on hardware: everything else was - // recorded and withheld. + // A packet the channel delivered, and passed its CRC. A frame + // that locked and failed comes as FrameFailed; one that never + // locked does not come at all, exactly as on hardware. if(payload.Length > 0) { lock(chipLock) @@ -117,6 +117,21 @@ private void Handle(byte kind, byte[] payload) } break; + case FrameFailed: + // A frame the receiver locked onto and lost; the first byte is + // where. The chip raises HeaderErr, or RxDone beside CrcErr, + // exactly as the part does. + if(payload.Length >= 1 && lib.DeliverFrameFailed != null) + { + var frame = new byte[payload.Length - 1]; + Array.Copy(payload, 1, frame, 0, frame.Length); + lock(chipLock) + { + lib.DeliverFrameFailed(chip, frame, (UIntPtr)frame.Length, payload[0]); + } + } + break; + case TxDone: lock(chipLock) { @@ -326,6 +341,7 @@ public void Dispose() private const byte TxDone = 0x04; private const byte ChannelBusy = 0x08; private const byte RadioStats = 0x09; + private const byte FrameFailed = 0x0A; // The stats record, whose layout the engine reads on length. private const int StatsLength = 39; diff --git a/tools/renode/peripherals/VirtualSX1262Lib.cs b/tools/renode/peripherals/VirtualSX1262Lib.cs index 017514a0..8dc5f30f 100644 --- a/tools/renode/peripherals/VirtualSX1262Lib.cs +++ b/tools/renode/peripherals/VirtualSX1262Lib.cs @@ -106,6 +106,9 @@ public static VirtualSX1262Lib Open(string path) public TickFn Tick; public SetChannelBusyFn SetChannelBusy; public DeliverFrameFn DeliverFrame; + // Null on a chip model older than 1.3.2, which has no way to be told a + // frame failed: such a node goes on hearing nothing for one, as before. + public DeliverFrameFailedFn DeliverFrameFailed; public TransmitFinishedFn TransmitFinished; public TakeTxFn TakeTx; public SetFemEnabledFn SetFemEnabled; @@ -124,6 +127,7 @@ public static VirtualSX1262Lib Open(string path) public delegate void TickFn(IntPtr chip, ulong nowMs); public delegate void SetChannelBusyFn(IntPtr chip, int busy); public delegate void DeliverFrameFn(IntPtr chip, byte[] frame, UIntPtr len); + public delegate void DeliverFrameFailedFn(IntPtr chip, byte[] frame, UIntPtr len, byte failure); public delegate void TransmitFinishedFn(IntPtr chip); public delegate UIntPtr TakeTxFn(IntPtr chip, byte[] dst, UIntPtr cap); public delegate void SetFemEnabledFn(IntPtr chip, int enabled); @@ -147,6 +151,11 @@ private VirtualSX1262Lib(IntPtr handle, string path) Tick = (TickFn)Bind("vsx_tick", typeof(TickFn)); SetChannelBusy = (SetChannelBusyFn)Bind("vsx_set_channel_busy", typeof(SetChannelBusyFn)); DeliverFrame = (DeliverFrameFn)Bind("vsx_deliver_frame", typeof(DeliverFrameFn)); + var failed = NativeSymbol(handle, "vsx_deliver_frame_failed"); + if(failed != IntPtr.Zero) + { + DeliverFrameFailed = (DeliverFrameFailedFn)Marshal.GetDelegateForFunctionPointer(failed, typeof(DeliverFrameFailedFn)); + } TransmitFinished = (TransmitFinishedFn)Bind("vsx_transmit_finished", typeof(TransmitFinishedFn)); TakeTx = (TakeTxFn)Bind("vsx_take_tx", typeof(TakeTxFn)); SetFemEnabled = (SetFemEnabledFn)Bind("vsx_set_fem_enabled", typeof(SetFemEnabledFn)); From 9af9f1a2b58a18792ded50dccd59f75ad2e32bcd Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 16 Oct 2026 22:53:41 +0100 Subject: [PATCH 2/5] bridge: send a failed frame only to a node that says it takes one A native bridge built before kind 0x0A ends its connection on any kind it does not know, so sending it to today's published builds stopped those nodes and failed the run. A node now says it takes the kind with bit 0 of byte 47 of its radio stats record, which every engine reads on length; one that does not say so is sent nothing, as before. The Renode peripheral's handler is withdrawn until Renode can say so too. --- internal/firmware/bridge.go | 25 ++++++++++++++-- internal/firmware/bridge_test.go | 30 +++++++++++++++---- internal/sim/engine/headercollision_test.go | 10 +++++-- .../renode/peripherals/VirtualSX1262Engine.cs | 22 ++------------ tools/renode/peripherals/VirtualSX1262Lib.cs | 9 ------ 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index ef564d1f..bc6f9fa5 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -62,9 +62,15 @@ const ( // the frame as it arrived. A real SX1262 says so - HeaderErr for a header // it could not read, RxDone beside CrcErr for a payload it could not - and // a firmware that never hears either has error paths nothing exercises. - // A node built before this kind existed skips it, as it skips any kind it - // does not know, and sees what it always saw: nothing. + // Sent only to a node whose radio stats record carries capFrameFailed: a + // native bridge built before this kind ends its connection on a kind it + // does not know, so a node that never says it understands is never sent it + // and sees what it always saw - nothing. kindFrameFailed = 0x0A + + // capFrameFailed is the bit in byte 47 of the radio stats record by which a + // node says it takes kindFrameFailed. + capFrameFailed = 0x01 ) // FrameFailure is where a frame the receiver locked onto went wrong, numbered @@ -122,6 +128,11 @@ type Bridge struct { // stats is the radio's own account of what it was asked. stats RadioStats + // takesFailed is whether the node has said it understands kindFrameFailed. + // A native bridge built before that kind closes its connection on any kind + // it does not know, so it is sent only to a node that has asked for it. + takesFailed bool + // done is closed when the bridge is. Anything waiting on the node must // select on it: a node whose process has gone will never ack, and a wait // with no way to hear about that hangs for as long as its deadline allows - @@ -277,6 +288,7 @@ func (b *Bridge) read(c net.Conn) { } b.mu.Lock() b.stats = st + b.takesFailed = n >= 48 && buf[47]&capFrameFailed != 0 b.mu.Unlock() } @@ -309,7 +321,16 @@ func (b *Bridge) Deliver(frame []byte) error { // DeliverFailed tells the firmware's chip that a frame it locked onto failed, // and where. Nothing is decoded from it; the frame rides along because a CRC // failure leaves the corrupt bytes in the chip's buffer, as the part does. +// +// A node that has not said it takes the kind is told nothing, which is what it +// was always told. func (b *Bridge) DeliverFailed(frame []byte, why FrameFailure) error { + b.mu.Lock() + takes := b.takesFailed + b.mu.Unlock() + if !takes { + return nil + } if err := b.send(kindFrameFailed, append([]byte{byte(why)}, frame...)); err != nil { return fmt.Errorf("firmware: deliver failed frame: %w", err) } diff --git a/internal/firmware/bridge_test.go b/internal/firmware/bridge_test.go index a9f0ed64..25fc15cd 100644 --- a/internal/firmware/bridge_test.go +++ b/internal/firmware/bridge_test.go @@ -84,8 +84,10 @@ func TestDeliveryReachesTheEmulator(t *testing.T) { // A frame the receiver locked onto and lost reaches the chip as its own kind, // with where it failed in front of the bytes, so the chip can raise HeaderErr -// or CrcErr rather than nothing. -func TestAFailedFrameReachesTheEmulatorWithItsCause(t *testing.T) { +// or CrcErr rather than nothing - but only once the node has said it takes +// that kind. A native bridge built before it ends its connection on a kind it +// does not know, so a node that never says so is sent nothing. +func TestAFailedFrameReachesOnlyANodeThatTakesIt(t *testing.T) { b, _ := Listen("127.0.0.1:0", "GB7XYZ") defer func() { _ = b.Close() }() c := fakeRadio(t, b.Addr()) @@ -94,6 +96,24 @@ func TestAFailedFrameReachesTheEmulatorWithItsCause(t *testing.T) { for i := 0; i < 50 && !b.Attached(); i++ { time.Sleep(10 * time.Millisecond) } + if err := b.DeliverFailed([]byte{0x11}, FailedCRC); err != nil { + t.Fatal(err) + } + + record := make([]byte, 48) + record[47] = capFrameFailed + if _, err := c.Write(append([]byte{kindRadioStats, 0, 48}, record...)); err != nil { + t.Fatal(err) + } + for i := 0; i < 100; i++ { + b.mu.Lock() + takes := b.takesFailed + b.mu.Unlock() + if takes { + break + } + time.Sleep(5 * time.Millisecond) + } if err := b.DeliverFailed([]byte{0x22, 0xBB}, FailedHeader); err != nil { t.Fatal(err) } @@ -103,15 +123,15 @@ func TestAFailedFrameReachesTheEmulatorWithItsCause(t *testing.T) { if _, err := io.ReadFull(c, hdr[:]); err != nil { t.Fatal(err) } - if hdr[0] != 0x0A { - t.Errorf("wrong frame kind 0x%02x, want 0x0a", hdr[0]) + if hdr[0] != kindFrameFailed { + t.Fatalf("wrong frame kind 0x%02x, want 0x0a", hdr[0]) } buf := make([]byte, binary.BigEndian.Uint16(hdr[1:])) if _, err := io.ReadFull(c, buf); err != nil { t.Fatal(err) } if string(buf) != string([]byte{2, 0x22, 0xBB}) { - t.Errorf("emulator received % x, want 02 22 bb", buf) + t.Errorf("emulator received % x, want 02 22 bb: the failure sent before it said it takes the kind leaked", buf) } } diff --git a/internal/sim/engine/headercollision_test.go b/internal/sim/engine/headercollision_test.go index 15aa368f..5cb78297 100644 --- a/internal/sim/engine/headercollision_test.go +++ b/internal/sim/engine/headercollision_test.go @@ -111,8 +111,8 @@ func TestTheFirmwareIsToldWhereAFrameFailed(t *testing.T) { } } -// recorder acks every tick, as firmware does, and keeps the first failed -// frame it is handed. +// recorder acks every tick, as firmware does, says it takes failed frames, +// and keeps the first one it is handed. type recorder struct { conn net.Conn mu sync.Mutex @@ -144,8 +144,12 @@ func (r *recorder) serve() { } switch { case hdr[0] == fakeKindTick && len(buf) >= 4: + // A radio stats record that says it takes failed frames, ahead of + // the ack, as a native bridge sends one with every tick. + stats := make([]byte, 3+48) + stats[0], stats[2], stats[3+47] = 0x09, 48, 0x01 reply := []byte{fakeKindAck, 0, 4, buf[0], buf[1], buf[2], buf[3]} - if _, err := r.conn.Write(reply); err != nil { + if _, err := r.conn.Write(append(stats, reply...)); err != nil { return } case hdr[0] == 0x0A: diff --git a/tools/renode/peripherals/VirtualSX1262Engine.cs b/tools/renode/peripherals/VirtualSX1262Engine.cs index 1d1fb26a..669cbe7d 100644 --- a/tools/renode/peripherals/VirtualSX1262Engine.cs +++ b/tools/renode/peripherals/VirtualSX1262Engine.cs @@ -105,9 +105,9 @@ private void Handle(byte kind, byte[] payload) switch(kind) { case Frame: - // A packet the channel delivered, and passed its CRC. A frame - // that locked and failed comes as FrameFailed; one that never - // locked does not come at all, exactly as on hardware. + // A packet the channel delivered. Only frames that passed CRC + // arrive here, exactly as on hardware: everything else was + // recorded and withheld. if(payload.Length > 0) { lock(chipLock) @@ -117,21 +117,6 @@ private void Handle(byte kind, byte[] payload) } break; - case FrameFailed: - // A frame the receiver locked onto and lost; the first byte is - // where. The chip raises HeaderErr, or RxDone beside CrcErr, - // exactly as the part does. - if(payload.Length >= 1 && lib.DeliverFrameFailed != null) - { - var frame = new byte[payload.Length - 1]; - Array.Copy(payload, 1, frame, 0, frame.Length); - lock(chipLock) - { - lib.DeliverFrameFailed(chip, frame, (UIntPtr)frame.Length, payload[0]); - } - } - break; - case TxDone: lock(chipLock) { @@ -341,7 +326,6 @@ public void Dispose() private const byte TxDone = 0x04; private const byte ChannelBusy = 0x08; private const byte RadioStats = 0x09; - private const byte FrameFailed = 0x0A; // The stats record, whose layout the engine reads on length. private const int StatsLength = 39; diff --git a/tools/renode/peripherals/VirtualSX1262Lib.cs b/tools/renode/peripherals/VirtualSX1262Lib.cs index 8dc5f30f..017514a0 100644 --- a/tools/renode/peripherals/VirtualSX1262Lib.cs +++ b/tools/renode/peripherals/VirtualSX1262Lib.cs @@ -106,9 +106,6 @@ public static VirtualSX1262Lib Open(string path) public TickFn Tick; public SetChannelBusyFn SetChannelBusy; public DeliverFrameFn DeliverFrame; - // Null on a chip model older than 1.3.2, which has no way to be told a - // frame failed: such a node goes on hearing nothing for one, as before. - public DeliverFrameFailedFn DeliverFrameFailed; public TransmitFinishedFn TransmitFinished; public TakeTxFn TakeTx; public SetFemEnabledFn SetFemEnabled; @@ -127,7 +124,6 @@ public static VirtualSX1262Lib Open(string path) public delegate void TickFn(IntPtr chip, ulong nowMs); public delegate void SetChannelBusyFn(IntPtr chip, int busy); public delegate void DeliverFrameFn(IntPtr chip, byte[] frame, UIntPtr len); - public delegate void DeliverFrameFailedFn(IntPtr chip, byte[] frame, UIntPtr len, byte failure); public delegate void TransmitFinishedFn(IntPtr chip); public delegate UIntPtr TakeTxFn(IntPtr chip, byte[] dst, UIntPtr cap); public delegate void SetFemEnabledFn(IntPtr chip, int enabled); @@ -151,11 +147,6 @@ private VirtualSX1262Lib(IntPtr handle, string path) Tick = (TickFn)Bind("vsx_tick", typeof(TickFn)); SetChannelBusy = (SetChannelBusyFn)Bind("vsx_set_channel_busy", typeof(SetChannelBusyFn)); DeliverFrame = (DeliverFrameFn)Bind("vsx_deliver_frame", typeof(DeliverFrameFn)); - var failed = NativeSymbol(handle, "vsx_deliver_frame_failed"); - if(failed != IntPtr.Zero) - { - DeliverFrameFailed = (DeliverFrameFailedFn)Marshal.GetDelegateForFunctionPointer(failed, typeof(DeliverFrameFailedFn)); - } TransmitFinished = (TransmitFinishedFn)Bind("vsx_transmit_finished", typeof(TransmitFinishedFn)); TakeTx = (TakeTxFn)Bind("vsx_take_tx", typeof(TakeTxFn)); SetFemEnabled = (SetFemEnabledFn)Bind("vsx_set_fem_enabled", typeof(SetFemEnabledFn)); From 48a5e0ec41c23754665aa8c22e4012150d12910c Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 16 Oct 2026 22:54:23 +0100 Subject: [PATCH 3/5] shortcomings: only a node that says it takes a failed frame is told --- docs/shortcomings.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/shortcomings.md b/docs/shortcomings.md index b24cbaf1..68d4a3d1 100644 --- a/docs/shortcomings.md +++ b/docs/shortcomings.md @@ -494,6 +494,10 @@ the eight-symbol header block, which is coded at CR 4/8, is a header error, and anything else that is lost is a CRC error. In waveform mode the demodulator decides. +Only a node that says it takes the message is told: a native build whose +bridge sets the capability bit in its radio stats record. Emulated boards, and +native builds from before that bit, are told nothing, as before. + Three things are still kinder than the part: - **Only collisions fail a frame.** A signal near the floor that the chip would From af08664827ad64cdcc302b6e921aa42cff42c4c3 Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 16 Oct 2026 23:00:20 +0100 Subject: [PATCH 4/5] engine: the failed-frame test builds its stats record without appending to a sized slice --- internal/sim/engine/headercollision_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/sim/engine/headercollision_test.go b/internal/sim/engine/headercollision_test.go index 5cb78297..7b8a2b99 100644 --- a/internal/sim/engine/headercollision_test.go +++ b/internal/sim/engine/headercollision_test.go @@ -146,10 +146,11 @@ func (r *recorder) serve() { case hdr[0] == fakeKindTick && len(buf) >= 4: // A radio stats record that says it takes failed frames, ahead of // the ack, as a native bridge sends one with every tick. - stats := make([]byte, 3+48) - stats[0], stats[2], stats[3+47] = 0x09, 48, 0x01 - reply := []byte{fakeKindAck, 0, 4, buf[0], buf[1], buf[2], buf[3]} - if _, err := r.conn.Write(append(stats, reply...)); err != nil { + record := make([]byte, 48) + record[47] = 0x01 + msg := append([]byte{0x09, 0, 48}, record...) + msg = append(msg, fakeKindAck, 0, 4, buf[0], buf[1], buf[2], buf[3]) + if _, err := r.conn.Write(msg); err != nil { return } case hdr[0] == 0x0A: From c9842dce97aaaef9918abaa326a9bad0995c7d3d Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 17 Oct 2026 05:10:01 +0100 Subject: [PATCH 5/5] engine: a frame detected and never locked onto reaches the chip as failure 3 A frame swamped by interference the receiver could not capture over is told to the chip by when the interference arrived. Arriving while the preamble was still to be found, it left nothing to lock onto, which the part reports by raising nothing; the chip model takes back the HeaderValid it raised from the carrier's length and leaves PreambleDetected latched. Arriving later, it found a receiver that had locked, and is a header or CRC error as before. In waveform mode a frame with no preamble lock is the same third case. Sent only to a node whose radio stats record sets bit 1 of byte 47, because a chip older than virtual-sx1262 ABI 1.7 reads failure 3 as a CRC error. --- docs/firmware-integration.md | 2 +- docs/shortcomings.md | 51 ++++++++---- internal/firmware/bridge.go | 13 ++- internal/firmware/bridge_test.go | 62 +++++++++++++++ internal/sim/engine/collision.go | 87 +++++++++++++++------ internal/sim/engine/headercollision_test.go | 55 ++++++++++++- internal/sim/engine/transmissions.go | 10 +++ internal/sim/engine/waveform.go | 6 +- 8 files changed, 238 insertions(+), 48 deletions(-) diff --git a/docs/firmware-integration.md b/docs/firmware-integration.md index cb1148a2..3ccf23d8 100644 --- a/docs/firmware-integration.md +++ b/docs/firmware-integration.md @@ -72,7 +72,7 @@ abstraction hides, ADR-0002 needs revisiting rather than patching. |---|---| | `startSendRaw` | Enqueue frame to the RF engine with node id and sim time. Return false if already transmitting. | | `isSendComplete` | True once sim time ≥ start + airtime. | -| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here.** A frame the receiver locked onto and lost reaches the chip as a header error or a CRC error, as the part reports it; one it never locked onto is recorded and withheld, exactly like real hardware. | +| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here.** A frame the receiver locked onto and lost reaches the chip as a header error or a CRC error, as the part reports it; one it could detect and never locked onto is recorded and withheld, and the chip is told so it can take back the header it guessed at. | | `getEstAirtimeFor` | Must equal our own airtime formula. Test this first. | | `getNoiseFloor` | Real value from the channel, including external emitters. | | `setCADEnabled` / CAD result | Answer from actual channel energy, not a guess. This is where firmware CSMA becomes observable. | diff --git a/docs/shortcomings.md b/docs/shortcomings.md index 68d4a3d1..93222782 100644 --- a/docs/shortcomings.md +++ b/docs/shortcomings.md @@ -483,7 +483,7 @@ the same pair and puts it in the status line, the sweep panel prints it beside the cost estimate before the run starts, and the results panel repeats it above the table. Use native for anything being compared. -### 3.2b A failed frame reaches the chip from a collision, and from nothing else +### 3.2b A failed frame reaches the chip from interference, and from nothing else A frame a receiver locked onto and lost reaches the firmware's chip as the part reports it: a header it could not read raises `HeaderErr` and nothing more, and @@ -494,27 +494,44 @@ the eight-symbol header block, which is coded at CR 4/8, is a header error, and anything else that is lost is a CRC error. In waveform mode the demodulator decides. -Only a node that says it takes the message is told: a native build whose -bridge sets the capability bit in its radio stats record. Emulated boards, and -native builds from before that bit, are told nothing, as before. +A frame the receiver could detect and never locked onto is reported too, as +the third case. In calculated mode that is a frame swamped by an interferer it +could not capture over that arrived while the preamble was still to be found; +in waveform mode it is any frame the demodulator found no preamble lock in. +The part raises nothing for it, so the chip model withdraws the `HeaderValid` +it raised from how long the carrier lasted, and leaves `PreambleDetected` set. -Three things are still kinder than the part: +Native builds with strict receive keep the detection flags as the part does: +`PreambleDetected` and `HeaderValid` stay set until the firmware clears them, +and a frame the receiver was not listening for from its preamble to its end is +not received, so a re-arm or a CAD scan part-way through a frame loses it. -- **Only collisions fail a frame.** A signal near the floor that the chip would - lock onto and then lose is, in calculated mode, simply not heard. Real radios - raise header and CRC errors on weak signals all the time. +Only a node that says it takes each message is told: a native build whose +bridge sets the capability bits in its radio stats record. Emulated boards, and +native builds from before those bits, are told nothing, and their chip clears +the detection flags when the carrier ends, as before. + +Four things are still kinder than the part: + +- **Only interference fails a frame.** A signal near the floor that the chip + would lock onto and then lose is, in calculated mode, simply not heard. Real + radios raise header and CRC errors on weak signals all the time. - **The failure arrives when the packet ends.** The calculated path judges a reception once the whole packet is on the air, so `HeaderErr` is raised at the end of the frame rather than the end of its header. -- **The detection flags clear with the carrier.** The datasheet holds - `PreambleDetected` and `HeaderValid` until the firmware clears them; the chip - model drops them when the engine says the channel has gone quiet. It does so - because, until failed frames reached the chip, a frame the engine withheld - left `HeaderValid` up with nothing to end it. - -**Consequence.** A firmware's error paths now run, so a fault in how it handles -a stale `HeaderErr` can be seen. A fault that depends on a detection flag -outliving its carrier cannot, yet. +- **`HeaderValid` is raised from the carrier, not from a decoded header.** The + chip model cannot decode, so it raises the flag once a carrier has lasted as + long as a header takes, and only withdraws it when the frame ends without a + lock. During such a frame a firmware sees a header where the part would show + only a preamble, and holds off transmitting for longer than it would on air. +- **The processor takes no time.** A native node runs its loop in no simulated + time, so a firmware that is slow to collect a packet loses nothing to the + delay, where on a real board the next frame can arrive in the meantime. + +**Consequence.** A firmware's error paths now run, and on native builds with +strict receive a fault in how it handles a stale `PreambleDetected`, or a +receiver re-armed mid-frame, can be seen. A fault that needs the processor to be +slow cannot, yet. ### 3.3 Native nodes cannot catch anything architecture-dependent diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index bc6f9fa5..30da128e 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -71,6 +71,10 @@ const ( // capFrameFailed is the bit in byte 47 of the radio stats record by which a // node says it takes kindFrameFailed. capFrameFailed = 0x01 + // capNoLock is the bit by which it says its chip reads FailedNoLock. A chip + // older than that reads the code as a CRC error and would raise RxDone for + // a frame it never locked onto, so the code goes only where this is set. + capNoLock = 0x02 ) // FrameFailure is where a frame the receiver locked onto went wrong, numbered @@ -84,6 +88,11 @@ const ( // FailedHeader is a header the receiver could not read: the chip raises // HeaderErr, and no RxDone follows. FailedHeader FrameFailure = 2 + // FailedNoLock is a frame the receiver could detect and never locked onto. + // The part raises nothing for it and leaves PreambleDetected latched; the + // chip model withdraws the HeaderValid it raised from the carrier's length, + // because that was its stand-in for a header it cannot decode. + FailedNoLock FrameFailure = 3 ) // ErrClosed is returned once a bridge has been shut down. @@ -132,6 +141,7 @@ type Bridge struct { // A native bridge built before that kind closes its connection on any kind // it does not know, so it is sent only to a node that has asked for it. takesFailed bool + takesNoLock bool // done is closed when the bridge is. Anything waiting on the node must // select on it: a node whose process has gone will never ack, and a wait @@ -289,6 +299,7 @@ func (b *Bridge) read(c net.Conn) { b.mu.Lock() b.stats = st b.takesFailed = n >= 48 && buf[47]&capFrameFailed != 0 + b.takesNoLock = n >= 48 && buf[47]&capNoLock != 0 b.mu.Unlock() } @@ -326,7 +337,7 @@ func (b *Bridge) Deliver(frame []byte) error { // was always told. func (b *Bridge) DeliverFailed(frame []byte, why FrameFailure) error { b.mu.Lock() - takes := b.takesFailed + takes := b.takesFailed && (why != FailedNoLock || b.takesNoLock) b.mu.Unlock() if !takes { return nil diff --git a/internal/firmware/bridge_test.go b/internal/firmware/bridge_test.go index 25fc15cd..d0e84246 100644 --- a/internal/firmware/bridge_test.go +++ b/internal/firmware/bridge_test.go @@ -135,6 +135,68 @@ func TestAFailedFrameReachesOnlyANodeThatTakesIt(t *testing.T) { } } +// A chip older than ABI 1.7 reads failure 3 as a CRC error and would raise +// RxDone for a frame it never locked onto, so a node that takes failed frames +// but has not said its chip reads that code is never sent it. +func TestANeverLockedFrameReachesOnlyAChipThatReadsIt(t *testing.T) { + b, _ := Listen("127.0.0.1:0", "GB7XYZ") + defer func() { _ = b.Close() }() + c := fakeRadio(t, b.Addr()) + defer func() { _ = c.Close() }() + + for i := 0; i < 50 && !b.Attached(); i++ { + time.Sleep(10 * time.Millisecond) + } + say := func(caps byte, want func() bool) { + record := make([]byte, 48) + record[47] = caps + if _, err := c.Write(append([]byte{kindRadioStats, 0, 48}, record...)); err != nil { + t.Fatal(err) + } + for i := 0; i < 100; i++ { + b.mu.Lock() + ok := want() + b.mu.Unlock() + if ok { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("the bridge never read capability byte 0x%02x", caps) + } + read := func() []byte { + var hdr [3]byte + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadFull(c, hdr[:]); err != nil { + t.Fatal(err) + } + buf := make([]byte, binary.BigEndian.Uint16(hdr[1:])) + if _, err := io.ReadFull(c, buf); err != nil { + t.Fatal(err) + } + return buf + } + + say(capFrameFailed, func() bool { return b.takesFailed }) + if err := b.DeliverFailed([]byte{0x11}, FailedNoLock); err != nil { + t.Fatal(err) + } + if err := b.DeliverFailed([]byte{0x22}, FailedHeader); err != nil { + t.Fatal(err) + } + if got := read(); string(got) != string([]byte{2, 0x22}) { + t.Fatalf("received % x, want 02 22: a chip that never said it reads failure 3 was sent it", got) + } + + say(capFrameFailed|capNoLock, func() bool { return b.takesNoLock }) + if err := b.DeliverFailed([]byte{0x33}, FailedNoLock); err != nil { + t.Fatal(err) + } + if got := read(); string(got) != string([]byte{3, 0x33}) { + t.Errorf("received % x, want 03 33", got) + } +} + // The point of the bridge: an emulated node's frame goes through the SAME // channel as a native one. If it took a different path the two backends could // not be compared, which is the whole reason ADR-0010 keeps both. diff --git a/internal/sim/engine/collision.go b/internal/sim/engine/collision.go index 77c9227f..7ad39e4f 100644 --- a/internal/sim/engine/collision.go +++ b/internal/sim/engine/collision.go @@ -43,30 +43,7 @@ import ( func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, concurrent []transmission, nodes []*Node, txPHY phy) (total, header float64) { - type span struct{ from, to uint32 } - var hits []span - for _, other := range concurrent { - if other.packetID == t.packetID || other.from == rx || other.from == t.from { - continue - } - from, to := maxU32(other.startMs, t.startMs), minU32(other.endMs, t.endMs) - if to <= from { - continue - } - if !e.phyOf(nodes[other.from].specRef()).sameChannel(txPHY) { - continue - } - loss, ok := e.pathLoss(other.from, rx) - if !ok { - continue - } - otherDBm := e.rxPowerDBm(nodes, other.from, rx, loss) - if wantedDBm-otherDBm >= captureThresholdDB { - // Captured: the interferer is present, and irrelevant. - continue - } - hits = append(hits, span{from, to}) - } + hits := e.uncapturedSpans(rx, t, wantedDBm, concurrent, nodes, txPHY) if len(hits) == 0 { return 0, 0 } @@ -107,6 +84,58 @@ func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, return math.Ceil(float64(damagedMs) / symbolMs), math.Ceil(math.Min(headerMs, hdrTo-hdrFrom) / symbolMs) } +// span is a stretch of a transmission, in engine milliseconds. +type span struct{ from, to uint32 } + +// uncapturedSpans is where co-channel traffic the receiver could not capture +// over lands on a transmission, one span per interferer, unsorted. +func (e *Engine) uncapturedSpans(rx int, t transmission, wantedDBm float64, + concurrent []transmission, nodes []*Node, txPHY phy) []span { + + var hits []span + for _, other := range concurrent { + if other.packetID == t.packetID || other.from == rx || other.from == t.from { + continue + } + from, to := maxU32(other.startMs, t.startMs), minU32(other.endMs, t.endMs) + if to <= from { + continue + } + if !e.phyOf(nodes[other.from].specRef()).sameChannel(txPHY) { + continue + } + loss, ok := e.pathLoss(other.from, rx) + if !ok { + continue + } + otherDBm := e.rxPowerDBm(nodes, other.from, rx, loss) + if wantedDBm-otherDBm >= captureThresholdDB { + // Captured: the interferer is present, and irrelevant. + continue + } + hits = append(hits, span{from, to}) + } + return hits +} + +// weakFailure is what the chip is told of a frame lost to interference it +// could not capture over. Interference that arrived while the preamble still +// had to be found left the receiver nothing to lock onto; arriving later, it +// found a receiver that had locked, and what it then destroyed decides between +// a header error and a CRC error. +func (e *Engine) weakFailure(rx int, t transmission, wantedDBm float64, + concurrent []transmission, nodes []*Node, txPHY phy, headerDamaged float64) firmware.FrameFailure { + + symbolMs := 1000 * math.Pow(2, float64(txPHY.sf)) / txPHY.bandwidthHz + lockMs := float64(t.startMs) + float64(dsp.PreambleSymbols(txPHY.sf)-4)*symbolMs + for _, h := range e.uncapturedSpans(rx, t, wantedDBm, concurrent, nodes, txPHY) { + if float64(h.from) < lockMs { + return firmware.FailedNoLock + } + } + return failureOf(headerSurvives(headerDamaged)) +} + // headerBlockMs is where a transmission's explicit header sits on the air: the // first interleaver block after the preamble, the two sync symbols and the // 2.25-symbol start of frame, eight symbols long and always coded at CR 4/8. @@ -133,6 +162,16 @@ func failureOf(headerOK bool) firmware.FrameFailure { return firmware.FailedHeader } +// lockFailure is failureOf for a receiver that may never have locked at all, +// which the chip has to hear about too: it guessed at a header from how long +// the carrier lasted, and for a frame it never locked onto the guess was wrong. +func lockFailure(synced, headerOK bool) firmware.FrameFailure { + if !synced { + return firmware.FailedNoLock + } + return failureOf(headerOK) +} + // survivesCorruption reports whether forward error correction can put a packet // back together after that much damage. // diff --git a/internal/sim/engine/headercollision_test.go b/internal/sim/engine/headercollision_test.go index 7b8a2b99..02af6a87 100644 --- a/internal/sim/engine/headercollision_test.go +++ b/internal/sim/engine/headercollision_test.go @@ -111,21 +111,72 @@ func TestTheFirmwareIsToldWhereAFrameFailed(t *testing.T) { } } +// A frame swamped by an interferer it could not capture over is told to the +// chip by when the interferer arrived. Arriving while the preamble still had to +// be found, it left the receiver nothing to lock onto; arriving after the +// header, it found a receiver that had locked and read the header, and the +// payload failed its CRC. +func TestAFrameSwampedBeforeLockIsOneTheChipNeverLockedOnto(t *testing.T) { + told := func(interfererStartMs uint32) []byte { + t.Helper() + e := engine.New(flat{100}, engine.Config{StepMs: 10, Seed: 4417}) + defer func() { _ = e.Close() }() + br, err := firmware.Listen("127.0.0.1:0", "b") + if err != nil { + t.Fatalf("listen: %v", err) + } + e.Add(wfNode("a", 0, 22), nil) + e.Add(wfNode("b", 0.010, 22), &firmware.Node{Bridge: br, Backend: noopBackend{}}) + e.Add(wfNode("c", 0.011, 30), nil) + peer := dialRecorderSaying(t, br.Addr(), 0x03) + defer func() { _ = peer.conn.Close() }() + for i := 0; i < 200 && !br.Attached(); i++ { + time.Sleep(5 * time.Millisecond) + } + if err := e.Run(context.Background(), 10); err != nil { + t.Fatal(err) + } + e.InjectFrame(0, make([]byte, 40)) + if err := e.Run(context.Background(), interfererStartMs); err != nil { + t.Fatal(err) + } + e.InjectFrame(2, make([]byte, 60)) + if err := e.Run(context.Background(), 8000); err != nil { + t.Fatal(err) + } + return peer.failed() + } + + if got := told(30); len(got) == 0 || got[0] != byte(firmware.FailedNoLock) { + t.Fatalf("a frame swamped during its preamble reached the chip as % x, want never locked onto", got) + } + if got := told(120); len(got) == 0 || got[0] != byte(firmware.FailedCRC) { + t.Fatalf("a frame swamped after its header reached the chip as % x, want a CRC failure", got) + } +} + // recorder acks every tick, as firmware does, says it takes failed frames, // and keeps the first one it is handed. type recorder struct { conn net.Conn + caps byte mu sync.Mutex got []byte } func dialRecorder(t *testing.T, addr string) *recorder { + t.Helper() + return dialRecorderSaying(t, addr, 0x01) +} + +// dialRecorderSaying is dialRecorder with the capability byte chosen. +func dialRecorderSaying(t *testing.T, addr string, caps byte) *recorder { t.Helper() c, err := net.Dial("tcp", addr) if err != nil { t.Fatalf("dial bridge: %v", err) } - r := &recorder{conn: c} + r := &recorder{conn: c, caps: caps} go r.serve() return r } @@ -147,7 +198,7 @@ func (r *recorder) serve() { // A radio stats record that says it takes failed frames, ahead of // the ack, as a native bridge sends one with every tick. record := make([]byte, 48) - record[47] = 0x01 + record[47] = r.caps msg := append([]byte{0x09, 0, 48}, record...) msg = append(msg, fakeKindAck, 0, 4, buf[0], buf[1], buf[2], buf[3]) if _, err := r.conn.Write(msg); err != nil { diff --git a/internal/sim/engine/transmissions.go b/internal/sim/engine/transmissions.go index c436fbc6..e89c63ea 100644 --- a/internal/sim/engine/transmissions.go +++ b/internal/sim/engine/transmissions.go @@ -347,6 +347,16 @@ func (e *Engine) deliver(t transmission, concurrent []transmission, cache modCac e.record(Event{AtMs: t.endMs, Kind: "miss", From: src.specRef().Name, To: dst.specRef().Name, PacketID: t.packetID, MessageID: t.payload, Outcome: rec.Outcome, SNRdB: reported, Frame: t.frame, Class: class, Detail: why}) + // Only while the demodulator is free: one following another frame + // has that frame's flags up, and that frame says how it ended. + if dst.Firmware != nil && held == "" { + why := e.weakFailure(i, t, rxDBm, concurrent, nodes, txPHY, headerDamaged) + if err := dst.Firmware.Bridge.DeliverFailed(t.frame, why); err != nil { + e.Ledger.Record(rec) + e.captureWrite(t, src, dst, txPHY, rec) + return fmt.Errorf("engine: deliver failed frame to %s: %w", dst.specRef().Name, err) + } + } default: rec.Demod, rec.CRCOK, rec.FirmwareSaw = true, true, true rec.Outcome = capture.Accepted diff --git a/internal/sim/engine/waveform.go b/internal/sim/engine/waveform.go index 757bc787..3316d518 100644 --- a/internal/sim/engine/waveform.go +++ b/internal/sim/engine/waveform.go @@ -427,9 +427,9 @@ func (e *Engine) settleWaveform(t transmission, src, dst *Node, c wfCandidate, PacketID: t.packetID, MessageID: t.payload, Outcome: rec.Outcome, SNRdB: r.snrdB, Frame: t.frame, Class: class, Detail: why}) - // No lock is no event at all; a lock is one the chip reports. - if dst.Firmware != nil && r.synced { - _ = dst.Firmware.Bridge.DeliverFailed(r.payload, failureOf(r.stats.HeaderOK)) + // A lock the chip reports as such; no lock withdraws its header guess. + if dst.Firmware != nil { + _ = dst.Firmware.Bridge.DeliverFailed(r.payload, lockFailure(r.synced, r.stats.HeaderOK)) } e.Ledger.Record(rec) e.captureWrite(t, src, dst, txPHY, rec)