diff --git a/docs/firmware-integration.md b/docs/firmware-integration.md index d6697c43..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** — 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 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 ece31b3d..93222782 100644 --- a/docs/shortcomings.md +++ b/docs/shortcomings.md @@ -483,6 +483,56 @@ 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 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 +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. + +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. + +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 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. +- **`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 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..30da128e 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -56,6 +56,43 @@ 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. + // 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 + // 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 +// 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 + // 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. @@ -100,6 +137,12 @@ 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 + 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 // with no way to hear about that hangs for as long as its deadline allows - @@ -255,6 +298,8 @@ 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() } @@ -274,8 +319,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 +329,25 @@ 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. +// +// 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 && (why != FailedNoLock || b.takesNoLock) + 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) + } + 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..d0e84246 100644 --- a/internal/firmware/bridge_test.go +++ b/internal/firmware/bridge_test.go @@ -82,6 +82,121 @@ 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 - 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()) + defer func() { _ = c.Close() }() + + 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) + } + + 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] != 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: the failure sent before it said it takes the kind leaked", buf) + } +} + +// 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 168442c6..7ad39e4f 100644 --- a/internal/sim/engine/collision.go +++ b/internal/sim/engine/collision.go @@ -21,20 +21,77 @@ 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) { + + hits := e.uncapturedSpans(rx, t, wantedDBm, concurrent, nodes, txPHY) + if len(hits) == 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 + // transmitter that beat capture, which on any real mesh is a handful. + for i := 1; i < len(hits); i++ { + for j := i; j > 0 && hits[j].from < hits[j-1].from; j-- { + hits[j], hits[j-1] = hits[j-1], hits[j] + } + } + var damagedMs uint32 + cur := hits[0] + for _, h := range hits[1:] { + if h.from > cur.to { + damagedMs += cur.to - cur.from + cur = h + continue + } + if h.to > cur.to { + cur.to = h.to + } + } + damagedMs += cur.to - cur.from + + symbolMs := 1000 * math.Pow(2, float64(txPHY.sf)) / txPHY.bandwidthHz + if symbolMs <= 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) +} + +// 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 { - 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 { @@ -58,36 +115,61 @@ func (e *Engine) corruptedSymbols(rx int, t transmission, wantedDBm float64, } hits = append(hits, span{from, to}) } - if len(hits) == 0 { - return 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 - // transmitter that beat capture, which on any real mesh is a handful. - for i := 1; i < len(hits); i++ { - for j := i; j > 0 && hits[j].from < hits[j-1].from; j-- { - hits[j], hits[j-1] = hits[j-1], hits[j] + 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 } } - var damagedMs uint32 - cur := hits[0] - for _, h := range hits[1:] { - if h.from > cur.to { - damagedMs += cur.to - cur.from - cur = h - continue - } - if h.to > cur.to { - cur.to = h.to - } + 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. +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 } - damagedMs += cur.to - cur.from + return firmware.FailedHeader +} - symbolMs := 1000 * math.Pow(2, float64(txPHY.sf)) / txPHY.bandwidthHz - if symbolMs <= 0 { - return 0 +// 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 math.Ceil(float64(damagedMs) / symbolMs) + return failureOf(headerOK) } // 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..02af6a87 --- /dev/null +++ b/internal/sim/engine/headercollision_test.go @@ -0,0 +1,221 @@ +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) + } +} + +// 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, caps: caps} + 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: + // 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] = 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 { + 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..e89c63ea 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 @@ -334,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 cd5fa2be..3316d518 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}) + // 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) return