diff --git a/README.md b/README.md index 8ad8b356..80482850 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,13 @@ long-range digital video links. channel — can change frame by frame. That turns one dongle into an adaptive-link engine: unequal error protection for video layers, live power control, per-packet frequency hopping. -- **Frequency hopping at FHSS speed.** A channel hop costs ~0.5–2.5 ms - depending on chip — fast enough to hop on every packet - ([how](docs/frequency-hopping.md)). +- **Frequency hopping at FHSS speed, and a hopset that adapts.** A channel hop + costs ~0.5–2.5 ms depending on chip — fast enough to hop on every packet + ([how](docs/frequency-hopping.md)) — in a keyed order an observer cannot + predict. The link also learns: a channel that stops delivering is dropped + from the schedule by authenticated agreement between the two ends, and + revisited later by keyed probes in case it recovers + ([how](docs/fhss.md)). - **Narrowband modes the kernel can't do.** 5 and 10 MHz channels on every supported generation — including the decade-old RTL8812AU and RTL8814AU the vendor never gave narrowband — half/quarter the bandwidth, more range from @@ -239,7 +243,12 @@ one `IRtlDevice` interface covers all four generations. - [FHSS](docs/fhss.md) — the anti-jam design article: keyed SipHash hop schedules, slot-locked lockstep RX, and [jammer resilience](docs/jammer-resilience.md) — measured delivery against - parked and following jammers, and where a follower breaks. + parked and following jammers, and where a follower breaks. It carries on + into the adaptive half: how the two ends agree on a change to the hopset + without either trusting the other, why the endpoint that must decode is the + one that decides, why a transmitter may argue only that a move leaves *it* + worse off rather than that it disagrees, and what stops an adversary who can + make channels look bad from herding the link onto one it then jams. - [Narrowband](docs/narrowband.md) — 5/10 MHz channels across all three generations: the baseband re-clock, the per-chip register machinery, and the walls (RF re-latch edges, per-die clock coupling, the 5 MHz/5 GHz CFO limit). diff --git a/docs/img/hopset_adapt.gif b/docs/img/hopset_adapt.gif new file mode 100644 index 00000000..f604d534 Binary files /dev/null and b/docs/img/hopset_adapt.gif differ diff --git a/docs/img/hopset_herding.gif b/docs/img/hopset_herding.gif new file mode 100644 index 00000000..06289926 Binary files /dev/null and b/docs/img/hopset_herding.gif differ diff --git a/docs/rf-primer.md b/docs/rf-primer.md index 2545b203..15f73abc 100644 --- a/docs/rf-primer.md +++ b/docs/rf-primer.md @@ -3,7 +3,7 @@ devourer talks to a Wi-Fi radio at a very low level — subcarriers, constellations, gain control, the transmit and receive chains. If you're new to that machinery, the terms in the other docs (per-tone SNR, EVM, CCA, AGC, occupied bandwidth) can -feel like jargon. This page is a picture book: ten short animations, each +feel like jargon. This page is a picture book: twelve short animations, each built in the DEVOURER live-monitor style, that show what the machinery actually looks like — from a single subcarrier all the way to a hopping, diversity-combined, bandwidth-hopping link. Read it top to bottom and the rest of the docs will click. @@ -120,6 +120,55 @@ escapes. Done per-packet (`DEVOURER_HOP_*`), hopping doubles as a frequency-diversity interleaver for the outer FEC: losses are spread thin across frequencies instead of wiping out a run of packets on one. +![Adaptive hopset — the jammed channel stops being scheduled](img/hopset_adapt.gif) + +Bounding the damage is not the same as avoiding it. Most interference is +*furniture* — a fixed AP, a neighbouring video link, a microwave, a radar. It +isn't trying to win; it's just sitting there, and it will still be sitting there +in an hour. A hop set that keeps visiting its channel keeps paying for it, every +round, forever. The **receiver** is the endpoint that has to decode, so its +per-channel delivery is the authoritative evidence (energy +readings only *classify* an impairment; a quiet channel with a dead link and a +noisy one that still delivers are opposite decisions). It **proposes**; it never +acts. The transmitter owns the schedule, and answers with an authenticated commit +naming a **future** slot, repeated until that slot arrives — so both ends swap +schedules together and a lost frame can't split them apart. Because the hop order +is **keyed**, dropping a channel re-keys the permutation rather than leaving a +readable gap in a pattern. And exclusion is not a ratchet: keyed **probes** go +back and look — the round, the position and the channel all derived from the key, +so recovery isn't itself a periodic target — and a channel that recovered comes +back. Measured against a narrowband interferer parked on one member of a +four-channel set: delivery 0.72 → 0.83, with the channel restored on its own once +the interferer stopped. + +![Anti-herding — the schedule refuses to shrink past its floor](img/hopset_herding.gif) + +Now take the furniture away and put something *adversarial* there instead, and +the picture above stops being the story — the one that replaces it ends with the +link still bleeding, and that ending is the point. Two measurements say why. +Against a **blind** parked jammer, a keyed hop order and a plainly sequential one +deliver identically — secrecy buys nothing against something that isn't looking; +it only earns its keep against an adversary that *reacts*. And against a jammer that +**follows** — moving onto a still-active channel after every exclusion — the link +does not get better. It gets *walked down*. And notice that every one of those +exclusion requests is individually correct: the receiver really is losing that +channel, and dropping it really would help, for about a dozen dwells. Granted +without limit, that reasonable-looking sequence ends with the link parked on one +frequency of the attacker's choosing, which is exactly where an adversary wants +it. So an adaptive exclusion loop is itself an attack surface, and the shipped +one is built timid on purpose: one channel per update, a mandatory gap between +updates (a *refused* proposal spends it too, so bouncing proposals can't flood +the control path), nothing acted on when the whole band degrades together, and a +hard **floor** on how many channels stay active. When that floor is reached the +next proposal is refused, however well-evidenced it is, and the link spends the +rest of the flight bleeding across the channels it has left rather than being +herded onto one. On the four-channel bench set the floor binds after a single +exclusion, and that's the measured result: exclusion depth one, active set held +at three, no collapse. It is a pass rather than a win, and the difference matters +— the advantage here isn't that the link learns, it's that it learns without +becoming teachable by an adversary. The protocol, the policy and the numbers are +in [`fhss.md`](fhss.md). + ## 9. Trading robustness for throughput in time — bandwidth TDMA ![Bandwidth TDMA — two stations flipping together under a shared clock](img/tdma_schedule.gif) diff --git a/tests/hopset_adaptive_jammer.sh b/tests/hopset_adaptive_jammer.sh index 9cbfd803..94150aac 100755 --- a/tests/hopset_adaptive_jammer.sh +++ b/tests/hopset_adaptive_jammer.sh @@ -356,7 +356,12 @@ else fi ADAPT_DELIV=$(delivery "$OUT/rx_adapt.log") -if [ "$MODE" = "parked" ] || [ "$MODE" = "sense" ]; then +if [ "$MODE" = "parked" ] || [ "$MODE" = "sense" ] || [ "$MODE" = "failsafe" ]; then + # The failsafe needs this phase as much as the receiver-driven modes do: + # an autonomous exclusion that can never be undone is a link that shrinks + # permanently the first time something transient sits on a channel. With + # the receiver still mute, the only evidence that can bring the channel + # back is the transmitter's own keyed probes. echo "== phase C: jammer off — probes must restore the channel, ${RESTORE_S}s ==" kill_jammer : > "$OUT/rx_restore_mark" @@ -477,6 +482,28 @@ elif [ "$MODE" = "failsafe" ]; then echo " rx: $(gm "$OUT/rx_adapt.log" | tr '\n' ' ')" fails=$((fails+1)) fi + # An autonomous exclusion must be undoable by the transmitter's own + # evidence, or the link ratchets down permanently. With the receiver still + # mute through phase C, a restore here can only have come from the keyed + # probes the transmitter aired on the excluded channel. + require "$OUT/tx_adapt.log" '"ev":"hopset.probe"' "TX aired keyed probes" + if grep -F '"origin":"failsafe"' "$OUT/tx_adapt.log" 2>/dev/null | + grep -qF '"kind":"restore"'; then + echo "PASS: the transmitter restored the channel from its own probes" + else + echo "FAIL: no autonomous restore after the jammer stopped" + fails=$((fails+1)) + fi + # ...and the receiver has to have followed that too. + last_rx=$(grep -F '"ev":"hopset.activate"' "$OUT/rx_adapt.log" 2>/dev/null | + tail -1 | sed -n 's/.*"mask":"0x\([0-9a-f]*\)".*/\1/p') + nch=$(( $(tr -cd ',' <<<"$CHANNELS" | wc -c) + 1 )) + full=$(printf '%x' $(( (1 << nch) - 1 ))) + [ "${last_rx:-x}" = "$full" ] && + echo "PASS: receiver ended on the full hopset again (0x$full)" || + { echo "FAIL: receiver ended on 0x${last_rx:-none}, expected 0x$full" + fails=$((fails+1)); } + # Every committed change moved exactly one channel: the autonomous path is # bound by the same structural limits a proposal is. if gm "$OUT/tx_adapt.log" | awk '{print $2}' | diff --git a/tools/hopset_adapt_gif.py b/tools/hopset_adapt_gif.py new file mode 100644 index 00000000..e3480868 --- /dev/null +++ b/tools/hopset_adapt_gif.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Animated adaptive hopset — 'the band heals', in the DEVOURER live-monitor +style. The stationary half of a pair: hopset_herding_gif.py draws the same eight +channels and the same opening act, and ends the other way. + + tools/hopset_adapt_gif.py -o docs/img/hopset_adapt.gif + +The hop set and the interferer's channel are the ones from hop_pattern.gif, so +the three figures of the section read as one story. A stationary occupant sits +on ch 52: the receiver scores the dwells it +loses and asks for the channel to be dropped, the transmitter commits the new +schedule at a named future slot, and the channel stops being visited. Then the +occupant goes away — and nothing would ever know, except that keyed recovery +probes go back and look anyway. Three clean probes and the channel is back in +the set. + +Deliberately scoped to a *stationary* occupant, which is what most interference +is (a fixed AP, another link, a microwave) and the only case where exclusion is +an unambiguous win. An interferer that follows the exclusions has a different +ending; that is the companion figure, not this one. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +CHANS = [36, 40, 44, 48, 52, 56, 60, 64] # the hop set of the section's first figure +JAM = 4 # the occupant sits on ch 52, where it sat there too + +NT = 64 +COMMIT_T = 16 # ch 52 excluded here +STOP_T = 30 # the occupant goes away +PROBES = (36, 44, 52) # keyed recovery probes revisit the excluded channel +RESTORE_T = 58 # three clean probes, and it is back in the set + + +def schedule(rnd): + sched, last = [], -1 + for t in range(NT): + if t in PROBES: + sched.append(JAM) + last = JAM + continue + excluded = COMMIT_T <= t < RESTORE_T + pool = [c for c in range(len(CHANS)) + if c != last and not (excluded and c == JAM)] + c = rnd.choice(pool) + sched.append(c) + last = c + return sched + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="hopset_adapt.gif") + ap.add_argument("--hold", type=int, default=16) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + sched = schedule(random.Random(0x265)) + for t in (3, 9): # make the opening act unmistakable + if sched[t - 1] != JAM and sched[t + 1] != JAM: + sched[t] = JAM + + nC = len(CHANS) + padL, padT, padB = 60, 96, 62 + cellw, gh = 11.0, 300 + gw = int(NT * cellw) + panelW = 230 + W = padL + gw + 24 + panelW + H = padT + gh + padB + cellh = gh / nC + + def cell_xy(ti, ci): + return padL + ti * cellw, padT + (nC - 1 - ci) * cellh + + def tx(t): + return padL + t * cellw + + jam_y = cell_xy(0, JAM)[1] + + imgs = [] + for fi in range(NT + args.hold): + cursor = min(fi, NT - 1) + seen = min(cursor + 1, NT) + excluded = cursor >= COMMIT_T + stopped = cursor >= STOP_T + restored = cursor >= RESTORE_T + img, d = new_frame(W, H) + chrome(d, W, H, "ADAPTIVE HOPSET", + "a stationary occupant on ch 52 — the receiver scores what it " + "decodes, the transmitter commits, the channel stops being " + "visited", fi) + + d.rectangle([padL, padT, padL + gw, padT + gh], outline=(0, 70, 80)) + for ci, ch in enumerate(CHANS): + _, y = cell_xy(0, ci) + d.text((padL - 34, y + cellh / 2 - 7), f"{ch}", font=font(11), + fill=WARN if (ci == JAM and not stopped) else DIM) + d.line([padL, y, padL + gw, y], fill=GRID) + + # the occupant's span, and the excluded span laid over it + d.rectangle([padL, jam_y, tx(min(seen, STOP_T)), jam_y + cellh], + fill=(40, 12, 12)) + if excluded: + xr = tx(min(seen, RESTORE_T)) + d.rectangle([tx(COMMIT_T), jam_y, xr, jam_y + cellh], fill=(16, 19, 26)) + hx = tx(COMMIT_T) + 6 + while hx < xr: + d.line([hx, jam_y + 6, hx - 6, jam_y + cellh - 6], fill=(30, 38, 50)) + hx += 12 + if cursor > COMMIT_T + 5: + lx, ly = tx(COMMIT_T) + 16, jam_y + cellh / 2 - 7 + d.rectangle([lx - 5, ly - 3, lx + 92, ly + 15], fill=(16, 19, 26)) + d.text((lx, ly), "not scheduled", font=font(11), fill=DIM) + + # the trace + lost = 0 + for ti in range(seen): + ci = sched[ti] + x, y = cell_xy(ti, ci) + probe = ti in PROBES + hit = ci == JAM and ti < STOP_T and not probe + if hit: + lost += 1 + fade = 0.45 + 0.55 * (ti / max(1, cursor)) if ti < cursor else 1.0 + col = AMBER if probe else (WARN if hit else CYAN) + d.rectangle([x + 2, y + 8, x + cellw - 2, y + cellh - 8], + fill=tuple(int(c * fade) for c in col)) + if hit: + d.line([x + 2, y + 8, x + cellw - 2, y + cellh - 8], fill=INK) + d.line([x + 2, y + cellh - 8, x + cellw - 2, y + 8], fill=INK) + if probe: # has to read against the hatched, excluded row + d.rectangle([x + 1, y + 7, x + cellw - 1, y + cellh - 7], + outline=AMBER, width=2) + d.line([x + cellw / 2, y + cellh - 6, x + cellw / 2, + padT + gh + 4], fill=(90, 78, 30)) + if ti == cursor and fi < NT: + d.rectangle([x + 1, y + 7, x + cellw - 1, y + cellh - 7], + outline=INK, width=2) + if ti: + x0, y0 = cell_xy(ti - 1, sched[ti - 1]) + d.line([x0 + cellw / 2, y0 + cellh / 2, + x + cellw / 2, y + cellh / 2], fill=(30, 44, 60)) + + # the moments, marked on the axis + if excluded: + d.line([tx(COMMIT_T), padT, tx(COMMIT_T), padT + gh], fill=AMBER, width=2) + d.text((tx(COMMIT_T) - 30, 76), "exclude ch 52", font=font(11), fill=AMBER) + if stopped: + mx = tx(STOP_T) + yy = padT + while yy < padT + gh: + d.line([mx, yy, mx, yy + 5], fill=DIM) + yy += 10 + d.text((mx - 34, 76), "occupant stops", font=font(11), fill=DIM) + if restored: + d.line([tx(RESTORE_T), padT, tx(RESTORE_T), padT + gh], fill=OK, width=2) + d.text((tx(RESTORE_T) - 52, 76), "ch 52 restored", font=font(11), fill=OK) + + d.text((padL, padT + gh + 8), "time → (each cell = one dwell)", + font=font(11), fill=DIM) + if cursor >= PROBES[0]: + d.text((tx(PROBES[0]) - 12, padT + gh + 8), "↑ keyed recovery probes", + font=font(11), fill=AMBER) + + by = padT + gh + 30 + + def bracket(t0, t1, label, col): + x0, x1 = tx(t0), tx(t1) + d.line([x0, by, x1, by], fill=col) + d.line([x0, by - 4, x0, by], fill=col) + d.line([x1, by - 4, x1, by], fill=col) + d.text(((x0 + x1) / 2 - 3.2 * len(label), by + 5), label, + font=font(11), fill=col) + + bracket(0, COMMIT_T, "JAMMED", WARN if not excluded else DIM) + if excluded: + bracket(COMMIT_T, min(seen, RESTORE_T), "ADAPTED", + OK if not restored else DIM) + if restored: + bracket(RESTORE_T, seen, "WHOLE AGAIN", OK) + + # readout + x0 = padL + gw + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lbl, val, c=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 100, y - 3), val, font=font(15, True), fill=c) + y += 30 + + gen = 3 if restored else (2 if excluded else 1) + out = excluded and not restored + mask = "".join("0" if (out and ci == JAM) else "1" for ci in range(nC)) + done = sum(1 for p in PROBES if cursor >= p) + line("generation", f"{gen}", AMBER if excluded else INK) + line("hop set", mask, OK) + line("active", f"{nC - 1 if out else nC}/{nC}", AMBER if out else OK) + line("probes", f"{done}/{len(PROBES)}" if excluded else "—", + AMBER if 0 < done < len(PROBES) else (OK if done else DIM)) + line("dwells lost", f"{lost}", WARN if lost else OK) + + if restored: + st, sc = "RESTORED", OK + elif stopped: + st, sc = "PROBING", AMBER + elif excluded: + st, sc = "ADAPTED", OK + else: + st, sc = "LEARNING", WARN + d.rectangle([x0, y, x0 + 200, y + 30], outline=sc, width=2) + d.ellipse([x0 + 8, y + 10, x0 + 18, y + 20], fill=sc) + d.text((x0 + 28, y + 6), st, font=font(14, True), fill=sc) + y += 44 + + if st == "LEARNING": + cap = ("ch 52 keeps losing", "dwells — the receiver", + "asks for it to go.") + elif st == "ADAPTED": + cap = ("ch 52 is out, committed", "at a named slot so both", + "ends swap together.") + elif st == "PROBING": + cap = ("the occupant left, and", "nothing would ever know —", + "so keyed probes go back", "and look anyway.") + else: + cap = ("three clean probes and", "ch 52 is back in the set.", + "exclusion is evidence,", "not a one-way ratchet.") + for ln in cap: + d.text((x0, y), ln, font=font(11), fill=INK) + y += 15 + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/hopset_herding_gif.py b/tools/hopset_herding_gif.py new file mode 100644 index 00000000..9c3c7944 --- /dev/null +++ b/tools/hopset_herding_gif.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Animated anti-herding — 'the floor is not negotiable', in the DEVOURER +live-monitor style. The counterweight to hopset_adapt_gif.py: same eight +channels, same opening act, and what the machinery does when the interferer is +not furniture but an adversary that follows the exclusions. + + tools/hopset_herding_gif.py -o docs/img/hopset_herding.gif + +The link excludes the jammed channel and recovers, exactly as in the stationary +case. Then the interferer moves onto a channel that is still active, and the +receiver — correctly, on the evidence it has — asks for that one too. And again. +Every one of those requests is individually reasonable, which is the whole +problem: granted without limit they walk the schedule down to a single frequency +of the attacker's choosing. So the schedule stops shrinking at its floor, the +last proposal is refused, and the link spends the rest of the run bleeding on +three channels rather than being parked on one. The loss counter climbs to the +final frame, because it does: this ending is a pass, not a win. + +The floor of three is the shipped one. The walk-down is the policy's behaviour +carried out to the limit; the measured herding run used a four-channel base, +where the floor binds after a single exclusion. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +CHANS = [36, 40, 44, 48, 52, 56, 60, 64] +FLOOR = 3 # the schedule never shrinks past this +NT = 64 +STAGE = 11 # dwells the interferer spends on a channel before it is dropped + +# where the interferer sits, stage by stage: it starts on ch 52 — the channel it +# occupies in both of the section's other figures — and then follows. +JAM_SEQ = [4, 2, 7, 1, 5, 3] # 52, 44, 64, 40, 56, then 48 +COMMITS = [STAGE * (k + 1) for k in range(5)] # 11, 22, 33, 44, 55 +REFUSE_T = 59 # the sixth request, answered with a no + + +def jam_at(t): + return JAM_SEQ[min(t // STAGE, len(JAM_SEQ) - 1)] + + +def excluded_at(t): + """The channels dropped by dwell t — one per commit, in the order the + interferer forced them.""" + return {JAM_SEQ[k] for k, c in enumerate(COMMITS) if t >= c} + + +def schedule(rnd): + sched, last = [], -1 + for t in range(NT): + out = excluded_at(t) + pool = [c for c in range(len(CHANS)) if c != last and c not in out] + c = rnd.choice(pool) + sched.append(c) + last = c + return sched + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="hopset_herding.gif") + ap.add_argument("--hold", type=int, default=16) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + sched = schedule(random.Random(0x11e)) + # every stage has to visibly bleed, or the walk-down looks unmotivated + for k in range(len(JAM_SEQ)): + j = JAM_SEQ[k] + for off in (2, 5, 8): + t = k * STAGE + off + if t < NT - 1 and sched[t - 1] != j and sched[t + 1] != j: + sched[t] = j + + nC = len(CHANS) + padL, padT, padB = 60, 96, 62 + cellw, gh = 11.0, 300 + gw = int(NT * cellw) + panelW = 230 + W = padL + gw + 24 + panelW + H = padT + gh + padB + cellh = gh / nC + + def cell_xy(ti, ci): + return padL + ti * cellw, padT + (nC - 1 - ci) * cellh + + def tx(t): + return padL + t * cellw + + imgs = [] + for fi in range(NT + args.hold): + cursor = min(fi, NT - 1) + seen = min(cursor + 1, NT) + out = excluded_at(cursor) + jam_ci = jam_at(cursor) + refused = cursor >= REFUSE_T + img, d = new_frame(W, H) + chrome(d, W, H, "ANTI-HERDING", + "an interferer that follows every exclusion — each request is " + "reasonable, and granting them all would park the link on one " + "channel", fi) + + d.rectangle([padL, padT, padL + gw, padT + gh], outline=(0, 70, 80)) + for ci, ch in enumerate(CHANS): + _, y = cell_xy(0, ci) + d.text((padL - 34, y + cellh / 2 - 7), f"{ch}", font=font(11), + fill=WARN if ci == jam_ci else (DIM if ci not in out else (60, 72, 90))) + d.line([padL, y, padL + gw, y], fill=GRID) + + # every span the interferer has occupied + for k, j in enumerate(JAM_SEQ): + t0 = k * STAGE + t1 = min(seen, (k + 1) * STAGE if k + 1 < len(JAM_SEQ) else NT) + if t1 <= t0: + continue + _, y = cell_xy(0, j) + d.rectangle([tx(t0), y, tx(t1), y + cellh], fill=(40, 12, 12)) + + # and every channel it has cost us, dark from its commit onward + for k, c in enumerate(COMMITS): + if cursor < c: + continue + j = JAM_SEQ[k] + _, y = cell_xy(0, j) + xr = tx(seen) + d.rectangle([tx(c), y, xr, y + cellh], fill=(16, 19, 26)) + hx = tx(c) + 6 + while hx < xr: + d.line([hx, y + 5, hx - 6, y + cellh - 5], fill=(30, 38, 50)) + hx += 12 + + # the trace + lost = 0 + for ti in range(seen): + ci = sched[ti] + x, y = cell_xy(ti, ci) + hit = ci == jam_at(ti) + if hit: + lost += 1 + fade = 0.45 + 0.55 * (ti / max(1, cursor)) if ti < cursor else 1.0 + d.rectangle([x + 2, y + 6, x + cellw - 2, y + cellh - 6], + fill=tuple(int(c * fade) for c in (WARN if hit else CYAN))) + if hit: + d.line([x + 2, y + 6, x + cellw - 2, y + cellh - 6], fill=INK) + d.line([x + 2, y + cellh - 6, x + cellw - 2, y + 6], fill=INK) + if ti == cursor and fi < NT: + d.rectangle([x + 1, y + 5, x + cellw - 1, y + cellh - 5], + outline=INK, width=2) + if ti: + x0, y0 = cell_xy(ti - 1, sched[ti - 1]) + d.line([x0 + cellw / 2, y0 + cellh / 2, + x + cellw / 2, y + cellh / 2], fill=(30, 44, 60)) + + # one amber line per granted exclusion — the staircase down to the floor + for k, c in enumerate(COMMITS): + if cursor < c: + continue + d.line([tx(c), padT, tx(c), padT + gh], fill=AMBER, width=1) + d.text((tx(c) - 12, 78), f"gen {k + 2}", font=font(10), fill=AMBER) + if refused: + rx = tx(REFUSE_T) + d.line([rx, padT, rx, padT + gh], fill=WARN, width=2) + # inside the grid, clear of the gen labels crowding the top band + d.rectangle([rx + 4, padT + 6, rx + 74, padT + 24], fill=(8, 11, 18)) + d.text((rx + 8, padT + 7), "REFUSED", font=font(11, True), fill=WARN) + + d.text((padL, padT + gh + 8), "time → (each cell = one dwell)", + font=font(11), fill=DIM) + + by = padT + gh + 30 + + def bracket(t0, t1, label, col): + x0, x1 = tx(t0), tx(t1) + d.line([x0, by, x1, by], fill=col) + d.line([x0, by - 4, x0, by], fill=col) + d.line([x1, by - 4, x1, by], fill=col) + d.text(((x0 + x1) / 2 - 3.2 * len(label), by + 5), label, + font=font(11), fill=col) + + walked = min(seen, COMMITS[-1]) + bracket(0, walked, "WALKED DOWN — 8 ACTIVE, THEN 7, 6, 5, 4", + WARN if not refused else DIM) + if seen > COMMITS[-1]: + bracket(COMMITS[-1], seen, "HELD AT 3", AMBER) + + # readout + x0 = padL + gw + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lbl, val, c=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 100, y - 3), val, font=font(15, True), fill=c) + y += 30 + + act = nC - len(out) + mask = "".join("0" if ci in out else "1" for ci in range(nC)) + line("generation", f"{len(out) + 1}", AMBER if out else INK) + line("hop set", mask, AMBER if act == FLOOR else OK) + line("active", f"{act}/{nC}", AMBER if act == FLOOR else INK) + line("floor", f"{FLOOR}", AMBER if act == FLOOR else DIM) + line("interferer", f"ch {CHANS[jam_ci]}", WARN) + line("dwells lost", f"{lost}", WARN if lost else OK) + + if refused: + st, sc = "HELD AT FLOOR", AMBER + elif act == FLOOR: + st, sc = "JAMMED AGAIN", WARN + elif out: + st, sc = "FOLLOWED", WARN + else: + st, sc = "LEARNING", WARN + d.rectangle([x0, y, x0 + 200, y + 30], outline=sc, width=2) + d.ellipse([x0 + 8, y + 10, x0 + 18, y + 20], fill=sc) + d.text((x0 + 28, y + 6), st, font=font(14, True), fill=sc) + y += 44 + + if st == "LEARNING": + cap = ("ch 52 keeps losing", "dwells — the receiver", + "asks for it to go.") + elif st == "FOLLOWED": + cap = ("granted — and it moved", "onto another active", + "channel. and another.", "every request is fair;", + "the pattern is not.") + elif st == "JAMMED AGAIN": + cap = ("three channels left, and", "the interferer is sitting", + "on one of them again.") + else: + cap = ("refused. the floor is", "not negotiable — better", + "to bleed on three than", "be herded onto one.") + for ln in cap: + d.text((x0, y), ln, font=font(11), fill=INK) + y += 15 + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main())