From: CicadaTAK Date: Fri, 2 Oct 2026 00:00:00 +0000 Subject: [PATCH] morse: report per-peer expected throughput from measured delivery CicadaTAK-Marker: CICADATAK-DRIVER-A18 morse_get_expected_throughput() returned the PHY rate of MMRC's best rate and never applied that rate's success probability. batman-adv's BATMAN_V reads this number as its per-neighbour link metric, so at idle a dead link (whose best rate MMRC has dropped to MCS0) reported MCS0's PHY rate forever and outranked a working weak relay: the field blackhole. With no rate table it returned 0, which drops mac80211 to TX_BITRATE / 3 (sta_info.c), ~10-20x optimistic on morse's VHT width flags. Report measured delivery instead (cicadatak_etput.h): a dead peer (no MAC ACK for 3 batman ELP intervals) reads 1 batman unit, strictly below every live value; a live peer reads its best rate's PHY rate x success probability, floored at 300 kbps (1 MHz) / 600 kbps; a peer with no rate or nothing sent yet reads that live floor. Never 0. MMRC's rate selection is untouched. The ACK clock (cicadatak_ack_clock.h) lives in the SAME allocation as the peer's MMRC table, just past it, stamped in the single mmrc_feedback() funnel: no driver struct changes size, so dot11ah.ko - which compiles against morse.h, where struct morse_sta is embedded by value in struct morse_vif - stays byte-identical, and a (re-)association's fresh kzalloc'd table starts the clock unmeasured. Evidence, regime labels and the simulation: cicadatak.git docs/plans/0.23.0-gateway-queue.md section A18. A runtime switch, the cicadatak_etput module parameter (bool, 0644, default Y): N makes the getter return stock's value byte-for-byte - the PHY rate of MMRC's best rate, 0 with no rate - for a same-build A/B and as a field kill switch, with no reload and no reboot. --- --- a/cicadatak_ack_clock.h +++ b/cicadatak_ack_clock.h @@ -0,0 +1,56 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * CICADATAK-DRIVER-A18: the per-peer MAC acknowledgement clock behind cicadatak_etput.h's dead rule. + * + * It lives in the SAME allocation as the peer's MMRC table, just past it (rc.c morse_rc_sta_add sizes the allocation + * with cicadatak_table_alloc_size). No driver struct changes size - which is the point: struct morse_sta is embedded + * by value in struct morse_vif (sta_backups), and dot11ah.ko compiles against morse.h and reads morse_vif fields laid + * out after it, so a field added to any per-station struct would move them and change dot11ah.ko too. Included only + * by rc.c and mac.c, never through morse.h. + * + * Born and freed with its table: kzalloc zeroes it, mmrc_sta_init clears only the table, so every (re-)association + * starts unmeasured. + * + * THE INVARIANT THIS PLACEMENT DEPENDS ON: cicadatak_ack_clock() recomputes the offset from tb->caps on every access, + * so it is the allocation-time offset only while tb->caps never changes after the allocation. In mm6108-2.0.1 it never + * does (stock line numbers, before this patch): morse_rc_sta_add builds the caps in a LOCAL struct + * (morse_rc_sta_config_guard_per_bw is called only on &caps, rc.c:272), sizes and kzallocs the table from that same + * local, and the caps are copied into the table exactly once - mmrc_sta_init's memcpy (mmrc.c:1361), whose only caller + * is rc.c:293. Nothing else writes tb->caps, the functions handed &tb->caps (rows_from_sta_caps, + * mmrc_memory_required_for_caps) only read through it, and a re-add replaces the whole table. A driver bump that adds + * a second mmrc_sta_init caller or any tb->caps write turns the clock into an out-of-bounds write - which is why + * cicadatak.git pi-gateway/tests/test_rung3_patch.py fails on either. + */ +#ifndef CICADATAK_ACK_CLOCK_H +#define CICADATAK_ACK_CLOCK_H + +#include "mmrc-submodule/src/core/mmrc.h" + +struct cicadatak_ack_clock { + bool attempted; + bool acked; + unsigned long first_attempt; /* jiffies */ + unsigned long last_ack; /* jiffies */ +}; + +/* Where the clock starts: past the table, aligned for its unsigned longs. */ +static inline size_t cicadatak_ack_clock_offset(struct mmrc_sta_capabilities *caps) +{ + size_t n = mmrc_memory_required_for_caps(caps); + + return (n + sizeof(unsigned long) - 1) & ~(sizeof(unsigned long) - 1); +} + +/* Bytes to allocate for a table of these caps plus its clock. */ +static inline size_t cicadatak_table_alloc_size(struct mmrc_sta_capabilities *caps) +{ + return cicadatak_ack_clock_offset(caps) + sizeof(struct cicadatak_ack_clock); +} + +/* The clock of the peer whose MMRC table is tb (allocated with cicadatak_table_alloc_size). */ +static inline struct cicadatak_ack_clock *cicadatak_ack_clock(struct mmrc_table *tb) +{ + return (struct cicadatak_ack_clock *)((u8 *)tb + cicadatak_ack_clock_offset(&tb->caps)); +} + +#endif /* CICADATAK_ACK_CLOCK_H */ --- a/cicadatak_etput.h +++ b/cicadatak_etput.h @@ -0,0 +1,59 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * CicadaTAK blackhole rung 3 (CICADATAK-DRIVER-A18; docs/plans/0.23.0-gateway-queue.md §A18 in cicadatak.git). + * + * The per-peer expected throughput that mac80211 hands batman-adv (BATMAN_V's link metric) used to be the PHY rate + * of MMRC's best rate, with that rate's success probability never applied: a dead idle link reported MCS0's rate + * forever and outranked a working weak relay. This reports measured delivery instead, and is never 0 (0 drops + * mac80211 to TX_BITRATE / 3, which morse's VHT flags make ~10-20x optimistic). + * + * no rate yet, or nothing sent to this peer -> LIVE floor (a new peer starts as a weak live link) + * no MAC ACK for DEAD_AFTER since the later of + * {first attempt, last ACK} -> DEAD (1 batman unit, strictly below every live value) + * otherwise -> max(theoretical * prob / 100, LIVE floor) + * + * LIVE (300 kbps at 1 MHz, 600 above): batman's 100 kbps units truncate at every forward (hop_penalty 30), and + * 3 / 6 units are still held 2 / 5 forwards downstream - exactly stock's MCS0 at 1 / 2 MHz. At 4 MHz stock's MCS0 + * claim (13 units) held 6 forwards on a one-radio chain, 9 across radios; a link genuinely delivering under + * 0.6 Mbps now holds 5 (a 7-node line). DERIVED from batman-adv 2024.3's arithmetic, regime-free; re-derive on a + * change to hop_penalty or batman's unit. DEAD_AFTER = 3 x the fleet's batman elp_interval (2000 ms). + * Pure: the clock is whatever unit the caller passes (jiffies here, ms in the host test), compared wrap-safe. + */ +#ifndef CICADATAK_ETPUT_H +#define CICADATAK_ETPUT_H + +#define CICADATAK_ETPUT_DEAD_KBPS 100 +#define CICADATAK_ETPUT_LIVE_KBPS_1MHZ 300 +#define CICADATAK_ETPUT_LIVE_KBPS 600 +#define CICADATAK_ETPUT_DEAD_AFTER_MS 6000 + +/* a is after b on a wrapping clock (the kernel's time_after) */ +static inline int cicadatak_after(unsigned long a, unsigned long b) +{ + return (long)(b - a) < 0; +} + +static inline unsigned int cicadatak_etput_live_kbps(unsigned int oper_bw_mhz) +{ + return oper_bw_mhz == 1 ? CICADATAK_ETPUT_LIVE_KBPS_1MHZ : CICADATAK_ETPUT_LIVE_KBPS; +} + +static inline unsigned int cicadatak_etput_kbps(int have_rate, unsigned int theoretical_kbps, unsigned int prob, + int attempted, int acked, unsigned long first_attempt, + unsigned long last_ack, unsigned long now, unsigned long dead_after, + unsigned int oper_bw_mhz) +{ + unsigned int live = cicadatak_etput_live_kbps(oper_bw_mhz); + unsigned long since; + unsigned int v; + + if (!have_rate || !attempted) + return live; + since = (acked && cicadatak_after(last_ack, first_attempt)) ? last_ack : first_attempt; + if (cicadatak_after(now, since + dead_after)) + return CICADATAK_ETPUT_DEAD_KBPS; + v = theoretical_kbps / 100 * prob; + return v > live ? v : live; +} + +#endif /* CICADATAK_ETPUT_H */ --- a/mac.c +++ b/mac.c @@ -33,6 +33,8 @@ #include "mesh.h" #ifdef CONFIG_MORSE_RC #include "rc.h" +#include "cicadatak_etput.h" +#include "cicadatak_ack_clock.h" #else #include "minstrel_rc.h" #endif @@ -5830,25 +5832,42 @@ sinfo->filled |= BIT_ULL(NL80211_STA_INFO_TX_BITRATE); } +/* CICADATAK-DRIVER-A18: N = stock's report, byte-for-byte (the PHY rate of MMRC's best rate; 0 with no rate). A + * runtime switch for a same-build A/B and a field kill switch: /sys/module/morse/parameters/cicadatak_etput. */ +static bool cicadatak_etput = true; +module_param(cicadatak_etput, bool, 0644); +MODULE_PARM_DESC(cicadatak_etput, "CicadaTAK: report measured delivery as expected throughput (N = stock PHY rate)"); + static u32 morse_get_expected_throughput(struct ieee80211_hw *hw, struct ieee80211_sta *sta) { struct morse_sta *msta = (struct morse_sta *)sta->drv_priv; struct morse *mors = hw->priv; - const struct mmrc_table *tb = msta->rc.tb; - struct mmrc_rate rate; + struct mmrc_table *tb = msta->rc.tb; + const struct cicadatak_ack_clock *clk = tb ? cicadatak_ack_clock(tb) : NULL; + int have_rate = tb && tb->best_tp.rate != MMRC_MCS_UNUSED; + u32 theoretical = 0; + u32 prob = 0; u32 tput; - if (!tb || tb->best_tp.rate == MMRC_MCS_UNUSED) - return 0; - - rate = tb->best_tp; - tput = BPS_TO_KBPS(mmrc_calculate_theoretical_throughput(rate)); + if (have_rate) { + theoretical = BPS_TO_KBPS(mmrc_calculate_theoretical_throughput(tb->best_tp)); + prob = tb->table[tb->best_tp.index].prob; + } + if (!READ_ONCE(cicadatak_etput)) + return have_rate ? theoretical : 0; + /* CICADATAK-DRIVER-A18: measured delivery, never 0, never above stock's PHY rate (cicadatak_etput.h) */ + tput = cicadatak_etput_kbps(have_rate, theoretical, prob, + clk && clk->attempted, clk && clk->acked, + clk ? clk->first_attempt : 0, clk ? clk->last_ack : 0, jiffies, + msecs_to_jiffies(CICADATAK_ETPUT_DEAD_AFTER_MS), + mors->custom_configs.channel_info.op_bw_mhz); morse_dbg(FEATURE_ID_RATECONTROL, mors, - "%s: Throughput: MCS: %d, BW: %d, GI: %d -> %u\n", - __func__, rate.rate, 1 << rate.bw, rate.guard, tput); + "%s: theoretical %u kbps, prob %u -> %u kbps\n", __func__, theoretical, prob, tput); return tput; } + +MODULE_INFO(cicadatak, "CICADATAK-DRIVER-A18"); #endif static struct ieee80211_ops mors_ops = { --- a/rc.c +++ b/rc.c @@ -13,6 +13,7 @@ #include "bus.h" #include "debug.h" #include "rc.h" +#include "cicadatak_ack_clock.h" #include "pv1.h" /* Enable/Disable the fixed rate (Disabled by default) */ @@ -287,7 +288,8 @@ MORSE_WARN_ON(FEATURE_ID_RATECONTROL, msta->rc.tb); table_mem_size = mmrc_memory_required_for_caps(&caps); MORSE_RC_DBG(mors, "%s: Mem for table: %zd", __func__, table_mem_size); - tb = kzalloc(table_mem_size, GFP_KERNEL); + /* CICADATAK-DRIVER-A18: room for the peer's ACK clock past the table, zeroed (cicadatak_ack_clock.h) */ + tb = kzalloc(cicadatak_table_alloc_size(&caps), GFP_KERNEL); /* Initialise the STA rate control table */ mmrc_sta_init(tb, &caps, msta->avg_rssi); @@ -631,7 +633,18 @@ struct morse_rc_sta *mrc_sta = list_entry(pos, struct morse_rc_sta, list); if (&msta->rc == mrc_sta) { + struct cicadatak_ack_clock *clk = cicadatak_ack_clock(msta->rc.tb); + mmrc_feedback(msta->rc.tb, rates, was_aggregated, acked); + /* CICADATAK-DRIVER-A18: the ACK clock behind the dead rule (cicadatak_etput.h) */ + if (!clk->attempted) { + clk->attempted = true; + clk->first_attempt = jiffies; + } + if (acked) { + clk->acked = true; + clk->last_ack = jiffies; + } break; } }