diff --git a/docs/aggregation.md b/docs/aggregation.md index 946a020..10ce6e9 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -53,8 +53,11 @@ re-airing one frame N times. ## TX-status reports (`tx.report`) -`DeviceConfig tx.report` (env `DEVOURER_TX_REPORT`) sets `SPE_RPT` in every -data descriptor; the firmware answers each transmission with a CCX report, +`DeviceConfig tx.report` (env `DEVOURER_TX_REPORT`, value = sampling divisor +N) sets `SPE_RPT` in every Nth data descriptor — 1 = every frame; above +~1.25 k fps sample with N ≥ fps/1300 or the fw's ~1.3 k reports/s CCX +emission ceiling collapses coverage (docs/scheduled-mac.md). The firmware +answers each requested transmission with a CCX report, decoded at the C2H RX sites into `tx.report` events (`src/TxReport.h`): `state` (0 = delivered/completed, 1 = retry-drop), `retries` (hardware retransmissions), `queue_time_raw`, `final_rate`, `bmc`. HalMAC reports echo diff --git a/docs/scheduled-mac.md b/docs/scheduled-mac.md index 3e5b914..d07f5db 100644 --- a/docs/scheduled-mac.md +++ b/docs/scheduled-mac.md @@ -217,8 +217,14 @@ separately proven (`tests/ack_responder_check.sh`). MISSED_RPT_NUM field is stuffed with a constant on this fw (verified against the 8822B/C/E vendor headers — parse is exact, the fw just doesn't populate it), so tag gaps are the only drop signal. Above the - ceiling, either sample SPE_RPT 1-in-N to keep the demanded rate under - ~1.3 k/s or account report-less frames as "unknown". + ceiling, sample: `DEVOURER_TX_REPORT=N` requests the report on every Nth + frame while the tag still stamps every frame, so received-tag deltas are + exact multiples of N and coverage of the sampled frames is deterministic + (measured at 2.4 k fps: N=1 collapses to 56%, N=2 delivers 100.0% of the + sampled reports, zero off-modulo anomalies; the sampled ok-rate read + 99.71% against a 99.99% ledger truth — pessimistic by the ACK-loss + asymmetry, the safe direction). Pick N ≥ fps/1300, or account + report-less frames as "unknown". 2. **Closed-loop hardware ACK + autonomous retry is GO on Jaguar1 and Jaguar3** (100% delivery, retries ≈ 0.2–0.3) including retargeting an arbitrary UE MAC mid-session (re-arm `SetAckResponder`, change the diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 048741a..6fca6dc 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -106,7 +106,8 @@ devourer::DeviceConfig devourer_config_from_env() { cfg.tx.cw_tone_gain = static_cast(v) & 0x1F; if (env_long("DEVOURER_TX_USB_AGG", &v) && v > 0) cfg.tx.usb_agg_max = static_cast(v); - cfg.tx.report = env_flag("DEVOURER_TX_REPORT"); + if (env_long("DEVOURER_TX_REPORT", &v)) /* sampling divisor N, 0..255 */ + cfg.tx.report = static_cast(v < 0 ? 0 : v > 255 ? 255 : v); if (const char *e = env_str("DEVOURER_TX_AMPDU_MODE")) { devourer::AmpduMode m; if (devourer::parse_ampdu_mode(e, m)) diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index caa46c8..23fa626 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -219,8 +219,17 @@ struct DeviceConfig { * `tx.report` events. The TX-side link sensor. On the HalMAC chips the * descriptor SW_DEFINE also carries a rotating 8-bit tag the report * echoes (per-frame correlation). Default off (descriptors - * byte-identical). Needs an RX loop to deliver the C2H reports. */ - bool report = false; + * byte-identical). Needs an RX loop to deliver the C2H reports. + * + * Value = sampling divisor N: 1 requests a report on EVERY frame, N > 1 + * on every Nth (0..255). The CCX emission path saturates at ~1.3–1.4 k + * reports/s (docs/scheduled-mac.md), so above ~1.25 k fps pick + * N >= fps/1300 and coverage of the SAMPLED frames stays deterministic + * instead of load-collapsing. HalMAC dies stamp the SW_DEFINE tag on + * every frame regardless of sampling, so consecutive received-report + * tags differ by exactly N — any other delta is a dropped report + * (consumers must know N; tests/txrpt_coverage_attrib.py --sample-n). */ + int report = 0; /* env: DEVOURER_TX_AMPDU_MODE="tid/maxnum[/density[/noack[/maxtime_hex]]]" * — arm the first-class A-MPDU TX mode (src/AmpduMode.h) at the end of * bring-up: mark data frames aggregatable and program the MAC pacing diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 876ed43..a55da29 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1163,11 +1163,17 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_GID_8812(usb_frame, static_cast(0x3F)); } SET_TX_DESC_SW_DEFINE_8812(usb_frame, static_cast(0x001)); - /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a per-frame CCX TX report - * (delivered / retry count / queue time — src/TxReport.h). Dword2, inside - * the checksummed 32 bytes. */ + /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a CCX TX report (delivered / + * retry count / queue time — src/TxReport.h), sampled every Nth frame + * (cfg value = N). The 8812 report format has no tag echo, so sampling + * here only relieves the report rate — per-frame attribution stays + * order-based. Dword2, inside the checksummed 32 bytes. */ if (_cfg.tx.report) - SET_TX_DESC_SPE_RPT_8812(usb_frame, 1); + SET_TX_DESC_SPE_RPT_8812( + usb_frame, + _tx_ccx_ctr.fetch_add(1) % static_cast(_cfg.tx.report) == 0 + ? 1 + : 0); SET_TX_DESC_RETRY_LIMIT_ENABLE_8812(usb_frame, 1); if (!is_8814a) { /* 88XXau leaves DATA_RETRY_LIMIT=0 for monitor injection on 8814A diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index b7b5638..04e85cb 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -62,6 +62,13 @@ class RtlJaguarDevice : public IRtlDevice { * see SetTxPacketPowerStep. */ std::atomic _tx_pkt_pwr_step{0}; + /* CCX report sampling counter (cfg.tx.report = N — request a report on + * every Nth frame). The 8812 report format has no SW_DEFINE tag echo, so + * unlike Jaguar2/3 this counter drives only the request cadence. 64-bit: + * a narrow counter's wrap jumps the sampling phase for any N that doesn't + * divide it (2^32 is ~20 days at field frame rates). */ + std::atomic _tx_ccx_ctr{0}; + /* CW single-tone (StartCwTone/StopCwTone) saved state for a clean restore: * the pre-tone RF 0x00 and four BB dwords — RFE-pinmux words on 8812/8821 * (0xCB0/0xEB0/0xCB4/0xEB4), per-path TX-scale words on 8814 (0xC1C/0xE1C/ diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 75a4bcd..6646e7f 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1521,12 +1521,17 @@ size_t RtlJaguar2Device::build_tx_block(const uint8_t *packet, size_t length, static_cast(hdrlen >> 1), ndpa, data_sc, pkt_pwr_step, pkt_offset); if (_cfg.tx.report) { - /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a per-frame CCX TX report; - * the report echoes SW_DEFINE's low byte, so stamp a rotating tag for - * per-frame correlation (src/TxReport.h). Both fields sit inside the - * checksummed span — re-checksum (idempotent). */ - SET_TX_DESC_SPE_RPT_8822B(out, 1); - SET_TX_DESC_SW_DEFINE_8822B(out, _tx_rpt_tag.fetch_add(1) & 0xff); + /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a CCX TX report; the + * report echoes SW_DEFINE's low byte, so stamp a rotating tag for + * per-frame correlation (src/TxReport.h). The tag goes on EVERY frame + * while the request samples every Nth (cfg value = N): the fw's CCX + * emission saturates at ~1.3k reports/s, and with the tag continuous a + * received-report tag delta other than N is a dropped report. Both + * fields sit inside the checksummed span — re-checksum (idempotent). */ + const uint64_t k = _tx_rpt_tag.fetch_add(1); + SET_TX_DESC_SPE_RPT_8822B( + out, k % static_cast(_cfg.tx.report) == 0 ? 1 : 0); + SET_TX_DESC_SW_DEFINE_8822B(out, static_cast(k & 0xff)); jaguar2::cal_txdesc_chksum_8822b(out); } /* Per-frame retry limit from cfg (DEVOURER_TX_RETRY_LIMIT, default 0) — diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 77c5d18..436f93a 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -249,8 +249,12 @@ class RtlJaguar2Device : public IRtlDevice { /* Default per-packet TXPWR_OFSET LUT step (0 = none) — see SetTxPacketPowerStep. */ std::atomic _tx_pkt_pwr_step{0}; /* Rotating SW_DEFINE tag stamped when tx.report is on — the CCX report - * echoes its low byte, correlating reports to frames (src/TxReport.h). */ - std::atomic _tx_rpt_tag{0}; + * echoes its low byte, correlating reports to frames (src/TxReport.h). + * 64-bit: the counter also gates the sampled SPE_RPT request (every Nth + * frame), and a narrow counter's wrap would jump the sampling phase for + * any N that doesn't divide it — off-modulo tag deltas masquerading as + * dropped reports at every seam. */ + std::atomic _tx_rpt_tag{0}; /* A-MPDU TX mode (SetAmpduMode). Read lock-free in the TX descriptor path * (same pattern as _tx_mode_default); a control-plane write during TX is * the caller's to sequence and at worst tears one frame's mode benignly. */ diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index cf6b67e..b07516e 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1999,12 +1999,17 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, bw_desc, sgi != 0, ldpc != 0, stbc, bmc, ndpa, data_sc, pwr_type, pkt_offset); if (_cfg.tx.report) { - /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a per-frame CCX TX report; - * the report echoes SW_DEFINE's low byte, so stamp a rotating tag for - * per-frame correlation (src/TxReport.h). Both fields sit inside the - * checksummed span — re-checksum (idempotent). */ - SET_TX_DESC_SPE_RPT_8822C(out, 1); - SET_TX_DESC_SW_DEFINE_8822C(out, _tx_rpt_tag.fetch_add(1) & 0xff); + /* DEVOURER_TX_REPORT: SPE_RPT asks the fw for a CCX TX report; the + * report echoes SW_DEFINE's low byte, so stamp a rotating tag for + * per-frame correlation (src/TxReport.h). The tag goes on EVERY frame + * while the request samples every Nth (cfg value = N): the fw's CCX + * emission saturates at ~1.3k reports/s, and with the tag continuous a + * received-report tag delta other than N is a dropped report. Both + * fields sit inside the checksummed span — re-checksum (idempotent). */ + const uint64_t k = _tx_rpt_tag.fetch_add(1); + SET_TX_DESC_SPE_RPT_8822C( + out, k % static_cast(_cfg.tx.report) == 0 ? 1 : 0); + SET_TX_DESC_SW_DEFINE_8822C(out, static_cast(k & 0xff)); jaguar3::cal_txdesc_chksum_8822c(out); } /* Per-frame retry limit from cfg (DEVOURER_TX_RETRY_LIMIT, default 0) — diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index f8031a3..71fa82b 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -249,8 +249,12 @@ class RtlJaguar3Device : public IRtlDevice { std::atomic _tx_pwr_override{-1}; std::atomic _tx_pwr_offset_steps{0}; /* Rotating SW_DEFINE tag stamped when tx.report is on — the CCX report - * echoes its low byte, correlating reports to frames (src/TxReport.h). */ - std::atomic _tx_rpt_tag{0}; + * echoes its low byte, correlating reports to frames (src/TxReport.h). + * 64-bit: the counter also gates the sampled SPE_RPT request (every Nth + * frame), and a narrow counter's wrap would jump the sampling phase for + * any N that doesn't divide it — off-modulo tag deltas masquerading as + * dropped reports at every seam. */ + std::atomic _tx_rpt_tag{0}; /* Per-packet TX-power banks (SetTxPacketPowerOffsetQdb / radiotap * DBM_TX_POWER). _txpkt_banks is the allocation policy, * mutated under _reg_mu; _txpkt_img mirrors its committed 0x1e70[31:16] diff --git a/tests/arq_e2e_delivery.sh b/tests/arq_e2e_delivery.sh index 068d65b..db80ee3 100644 --- a/tests/arq_e2e_delivery.sh +++ b/tests/arq_e2e_delivery.sh @@ -43,6 +43,7 @@ CH=${CH:-36} MAC1=${MAC1:-02:12:34:56:78:9a} # DUT responder identity = drone RA TX_SA=${TX_SA:-02:aa:bb:cc:dd:01} # drone TA (unicast — the I/G footgun) RETRY_LIMIT=${RETRY_LIMIT:-3} # field report used 3 +DRONE_REPORT_N=${DRONE_REPORT_N:-1} # CCX sampling divisor (1 = every frame) DRONE_RATE=${DRONE_RATE:-MCS3} DRONE_PAYLOAD=${DRONE_PAYLOAD:-512} # >= 30 so the pctr stamp fits DRONE_GAP_US=${DRONE_GAP_US:-1000} # ~1k fps video-sim @@ -163,7 +164,7 @@ env DEVOURER_VID="$DRONE_VID" DEVOURER_PID="$DRONE_PID" DEVOURER_CHANNEL="$CH" \ DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA="$MAC1" DEVOURER_TX_SA="$TX_SA" \ DEVOURER_TX_RATE="$DRONE_RATE" DEVOURER_TX_PAYLOAD_BYTES="$DRONE_PAYLOAD" \ DEVOURER_TX_GAP_US="$DRONE_GAP_US" \ - DEVOURER_TX_REPORT=1 DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + DEVOURER_TX_REPORT="$DRONE_REPORT_N" DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ DEVOURER_DIS_CCA="$DRONE_DIS_CCA" \ DEVOURER_TX_PWR_OFFSET_QDB="$PWR_QDB" \ DEVOURER_LOG_LEVEL=warn DEVOURER_EVENTS=stdout \ diff --git a/tests/txrpt_coverage_attrib.py b/tests/txrpt_coverage_attrib.py index 8c2d333..78f8e11 100644 --- a/tests/txrpt_coverage_attrib.py +++ b/tests/txrpt_coverage_attrib.py @@ -26,7 +26,7 @@ from collections import Counter -def analyze(rundir): +def analyze(rundir, sample_n=1): n = 0 submitted = 0 prev_tag = None @@ -66,20 +66,41 @@ def analyze(rundir): raise SystemExit(f"{rundir}: no tagged tx.report events " f"(J1-format reports carry no tag)") name = rundir.rstrip("/").split("/")[-1] - cov = 100.0 * n / max(1, submitted) dur_s = (t_last - t_first) / 1000.0 if (t_first is not None and t_last and t_last > t_first) else 0 rate = n / dur_s if dur_s else 0 - unrep = sum(k * v for k, v in gaps.items()) - small = sum(k * v for k, v in gaps.items() if k <= 2) - top = ", ".join(f"{k}x{v}" for k, v in sorted(gaps.items())[:8]) - print(f"\n== {name}: submitted={submitted} reports={n} " - f"coverage={cov:.1f}%" - + (f" achieved={rate:.0f} rpt/s" if rate else "")) - print(f" unreported={unrep} gap-hist [{top}" - f"{', ...' if len(gaps) > 8 else ''}] " - f"max={max(gaps) if gaps else 0} " - f"in-gaps<=2: {100.0 * small / max(1, unrep):.1f}%") + if sample_n > 1: + # Sampled run: only every Nth frame requested a report, so the raw + # tag deltas should be exact multiples of N. k*N = k-1 sampled + # reports dropped; a non-multiple delta is an anomaly. + # The request fires on k % N == 0, i.e. frame 0 first — ceil, not + # floor, or odd totals undercount the expectation by one. + expected = (submitted + sample_n - 1) // sample_n + lost = anomalies = 0 + for d, c in gaps.items(): # keys are delta-1 + delta = d + 1 + if delta % sample_n == 0: + lost += (delta // sample_n - 1) * c + else: + anomalies += c + cov = 100.0 * n / max(1, expected) + print(f"\n== {name}: submitted={submitted} sample_n={sample_n} " + f"expected={expected} reports={n} sampled-coverage={cov:.1f}%" + + (f" achieved={rate:.0f} rpt/s" if rate else "")) + print(f" sampled reports lost={lost} off-modulo anomalies=" + f"{anomalies}") + else: + cov = 100.0 * n / max(1, submitted) + unrep = sum(k * v for k, v in gaps.items()) + small = sum(k * v for k, v in gaps.items() if k <= 2) + top = ", ".join(f"{k}x{v}" for k, v in sorted(gaps.items())[:8]) + print(f"\n== {name}: submitted={submitted} reports={n} " + f"coverage={cov:.1f}%" + + (f" achieved={rate:.0f} rpt/s" if rate else "")) + print(f" unreported={unrep} gap-hist [{top}" + f"{', ...' if len(gaps) > 8 else ''}] " + f"max={max(gaps) if gaps else 0} " + f"in-gaps<=2: {100.0 * small / max(1, unrep):.1f}%") if len(missed_vals) == 1: (mv, _), = missed_vals.items() print(f" missed field: constant {mv} on every report " @@ -96,8 +117,18 @@ def analyze(rundir): def main(): - for rd in sys.argv[1:]: - analyze(rd) + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("runs", nargs="+") + ap.add_argument("--sample-n", type=int, default=1, + help="DEVOURER_TX_REPORT sampling divisor the run used: " + "reports are requested on every Nth frame, so " + "received-tag deltas should be exact multiples of N " + "(k*N = k-1 sampled reports dropped; a non-multiple " + "is an anomaly)") + a = ap.parse_args() + for rd in a.runs: + analyze(rd, a.sample_n) if __name__ == "__main__":