# RT-4D RF Control API (`radio` key)

Reverse-engineering of the **RF transceiver control path** in the RT-4D stock application firmware
(`rt4d_stock_v3.25_abs_0x08002800.bin`, ARM Cortex-M4F Thumb, vaddr base `0x08002800`).
All addresses are absolute vaddr. This document is an **API reference for rewriting the UI while reusing the
stock RF/DMR functions**, and it respects the hard boundary: it does **not** touch the SPI codeplug format or
the serial/CPS protocol.

---

## 0. TL;DR — the single most important architectural fact

**There is NO discrete RF transceiver chip driven by the MCU.** The RT-4D has *no* AT1846S / RDA1846 / SA828-class
analog transceiver on an MCU-side I²C/SPI bus. Confirmed:

- **Zero I²C hardware** — no `I2C1/2/3` base (`0x40005400/5800/5C00`) literal anywhere in the image; no bit-banged
  AT1846S register-write helper (`reg = (addr<<... )`, 3-byte I²C write) exists.
- **SPI2 (`0x40003800`) is the external data-flash bus only** — its byte-transfer helper `spi_xfer_byte @0x08021538`
  drives codeplug/calibration/font reads (opcode `0x03`, CS on GPIOB); it never talks to an RF PLL.
- The **entire radio transceiver — synthesiser, RX demod (FM/AM/SSB), TX modulator, AMBE vocoder, RSSI, CTCSS/DCS,
  squelch — lives inside the FM100B baseband SoC.** The MCU controls all of it by sending a small binary
  **"ATC" request protocol over USART3** (`0x40004800`) and blocking for the confirm.

Therefore the "RF control API we must reuse" is:
1. the **ATC message layer** (`atc_send` / `atc_send_pl` + ~25 typed wrappers), and
2. a handful of **MCU-local helpers** for things physically wired to the MCU: battery ADC, audio DAC/codec enable,
   band-select GPIO, PA/CS GPIO, and the FM100B reset/hard-reset line.

The UI rewrite should call the **high-level composite functions** (`radio_apply_channel`, `ptt_tx_start`,
`battery_read`) and the FM100B is reprogrammed transparently. Frequencies flow from the codeplug (unchanged format)
through a RAM mirror into these functions — you never re-tune calibration.

---

## 1. RF chip identity & the transport bus

| Item | Finding | Evidence |
|---|---|---|
| RF transceiver | **Integrated in FM100B baseband SoC** (Kirisun-derived DMR chip; ARM7/9-class, WebRTC DSP + AMBE). Not an MCU-side chip. | No I²C base; RF config only appears as USART3 ATC payloads; FM100B strings `ATC_ChFreqSetReq/ATC_SetRfPowerLevelReq/ATC_RssiReadReq` (RE report §5.4). |
| Bus MCU↔FM100B | **USART3 @ `0x40004800`**, byte-oriented, IRQ-driven RX (ISR `0x0802061C`? — actually `0x080205B0`), polled TX. | `usart3_tx_byte @0x08006CB8` loads `0x40004800`; ISR pushes to ring `0x200082EF`. |
| Bus MCU↔SPI-flash | SPI2 `0x40003800` (codeplug/cal/fonts) — **not RF**. | `spi_xfer_byte @0x08021538`. |
| PC/CPS link | USART6 `0x40011400` — untouched, keep as-is. | RE report §4. |

### 1.1 USART3 low-level primitives (raw byte I/O to FM100B)

| vaddr | signature | what it does |
|---|---|---|
| `0x08021EA8` | `u16 usart_read_dr(u32 port)` | returns `port->DR` (`[port+4]`) |
| `0x08021EB0` | `void usart_write_dr(u32 port, u8 b)` | `port->DR = b & 0x1FF` |
| `0x08021EC2` | `bool usart_flag(u32 port, u32 mask)` | `(port->SR & mask) != 0` (RXNE=0x20, TXE=0x80) |
| `0x08006CB8` | `void usart3_tx_byte(u8 b)` | send 1 byte to FM100B (buffers to ring `0x200092EF` when flag `0x20000B67` set, else polls TXE and writes DR) |
| `0x08006C9C` | `void usart3_tx_buf(u8 *buf, u16 len)` | send `len` bytes (loops `usart3_tx_byte`) |

You will **not** call these directly for RF; they are the substrate under the ATC layer.

---

## 2. The ATC message layer — the core RF/DMR command API

### 2.1 Frame format (built in RAM buffer `0x2000706E`)

```
off  field
 0   0x68                 frame start / sync
 1   msg_id               (see §3 table)
 2   arg1                 (byte)
 3   arg2                 (byte)
 4   hdr_checksum (BE16)  computed by chk @0x08002EA8, byte-swapped @0x0800BD2C
 6   payload_len (BE16)   0 for the no-payload variant
 8   arg3 / payload[0..]  (payload copied here by memcpy @0x080062EC)
 8+len  0x10              trailer subtype marker
 ...   checksum
```
Then `usart3_tx_buf(&frame, 8+len+…)` is called and the sender **blocks** on the confirm.

### 2.2 The two core senders (CALLABLE, but you normally call the wrappers)

| vaddr | signature | notes |
|---|---|---|
| `0x0801B044` | `void atc_send(u8 msg_id, u8 a1, u8 a2, u8 a3, u32 timeout)` | no-payload request. Writes frame, sends 0xA bytes, then **spin-waits** on `cnf_flags[msg_id] @0x20007476[msg_id]` becoming ≠0xFF, decrementing a timeout counter at `0x20000C52`; calls scheduler `0x08003050` while waiting. |
| `0x0801B0C4` | `void atc_send_pl(u8 msg_id, u8 a1, u8 a2, u8 a3, u8 *payload, u16 len, u32 timeout)` | payload variant (extra args on stack: `[sp+0x20]=payload`, `[sp+0x24]=len`, `[sp+0x28]=timeout`). Same blocking confirm-wait. |

- **Confirm table:** `0x20007476` is a per-`msg_id` array of confirm flags; before send, `[msg_id]←0xFF`; the USART3
  RX handler (`atc_rx @0x08006D00` region) writes the Cnf back and the sender unblocks. **The confirm often carries the
  return value** (e.g. RSSI, version) into the RX-decoded RAM structs.
- **timeout** arg is a loop count (typ. `0x64`=100, `0xBB8`=3000, `0x3E8`=1000).
- **Helpers:** `chk @0x08002EA8` (frame checksum), `htons @0x0800BD2C` (byte-swap16), `memcpy @0x080062EC`,
  `memset @0x08006038 / 0x08002BEE / 0x08002C52`.

### 2.3 Typed wrappers (the practical entry points)

Every wrapper is `atc_send(msg_id, 1, 1, param, 0x64)` unless noted (the `1,1` are fixed sub-fields). Each takes its
single byte/word parameter in `r0`.

| vaddr | msg_id | inferred signature | inferred meaning (FM100B ATC symbol) |
|---|---|---|---|
| `0x08006E6C` | `0x06` | `void atc_call_process(u8 a, u8 call_type, u32 target_id, u8 r3)` | **Start call / key DMR TX** (`ATC_CallProcessReq`). call_type 1=Private,2=Group,4=AllCall. Payload: type + BCD DMR-ID (via `id2bcd @0x0800786C`) + freq(`0x20007DA9[5]`) + 16-byte block. |
| `0x08007084` | `0x07` | `void atc_w07(...)` | channel/slot-related set (payload built from RAM `0x2000A5FD-0x38`, freq×; timeout 3000) |
| `0x08006FD8` | `0x0A` | `void atc_w0A(...)` | payload set (RX-related) |
| `0x0800760A` | `0x02` | `void atc_set_call_spk_vol(u8 v)` | DMR **called speaker volume** (from settings `[0x188]%25`) |
| `0x08007530` | `0x0B` | `void atc_set_call_mic_gain(u8 v)` | DMR **call MIC gain** (settings `[0x187]%25`) |
| `0x08007548`/`0x08007598` | `0x0C` | `void atc_set_color_code(u8 cc)` / template variant | **DMR color code / off-CTCSS** (from `[0x63]`) |
| `0x080075F4` | `0x4D` | `void atc_set_dig_squelch(u8 v)` | **DMR squelch level** (settings `[0x193]%17`) |
| `0x080075DC` | `0x55` | `void atc_set_sms_mode(u8 v)` | SMS/monitor flag (`ATC_SmsmodeSetReq`, settings `[0x196]&1`) |
| `0x08006DFC` | `0x25` (indirect) | `void atc_w_sms2(u8 v)` | second SMS/monitor flag (settings `[0x195]&1`); sends a fixed 0x1F-byte template |
| `0x08006C4C` | `0x49` | `void atc_set_denoise(u8 tx, u8 rx)` | **TX/RX denoise** (settings `[0x185]`,`[0x186]`) |
| `0x0800719C` | `0x49` | `void atc_w49b(u8 v)` | init-time variant (payload from an ADR const) |
| `0x08006C88` | `0x25` | `void atc_w25(void)` | init handshake (`atc_send(0x25,1,1,1)`) |
| `0x08006C74` | `0x05` | `void atc_w05(void)` | `atc_send(0x05,1,1,2)` — init/enable |
| `0x080071CC` | `0x45` | `void atc_w45(u8 v)` | init default (called with 2) |
| `0x080075C6` | `0x48` | `void atc_w48(u8 v)` | init default (called with 0xF) — likely AGC/EQ default |
| `0x08007670`/`0x08007620` | `0x4C` | `void atc_w4C(u8 v)` / template variant | RX enable / mute (called with 1) |
| `0x08007688`/`0x080076CC` | `0x09` | `void atc_query09(void)` / `atc_w09(u8)` | fixed 0x18-byte query/keepalive (sets `0x20000C3F` busy flag) |
| `0x080074FA` | `0x2A` | `void atc_set_radio_id(u32 dmr_id)` | **set radio's own DMR ID** (`ATC_RadioIDSetReq`); 4-byte LE payload |
| `0x080074D2` | `0x57` | `void atc_w57(u8 v)` | 2-byte set (init-time, called with 0) |
| `0x080071E2` | `0x62` | `void atc_ch_enable(u8 rx_en, u8 tx_en)` | **channel RX/TX wait/enable** (`ATC_CurChannelWaitSetReq`); 2-byte payload |
| `0x0800720C` | `0x82` | `void atc_channel_set(chan_cfg *cfg)` | **★ SET RX FREQ + TX FREQ + MODE + BW + CC + call-type ★** (`ATC_ChannelSetReq`) — see §4 |
| `0x0800736C` | `0x81` | `void atc_set_mute_code(u16 code)` | analog **mute code / DCS value** (`[cfg+0x14]`) |
| `0x08007404` | `0x84` | `void atc_set_rxgroup(u8 gl_index)` | **RX group list upload** (reads groups `0xC6000` stride `0x50`, contacts `0x5E000` stride `0x15`) = `ATC_DigChGroupSetReq` |
| `0x080071E2`… | `0x62` | (see above) | |

> **Naming confidence:** the msg_ids and calling conventions are *certain* (decoded directly). The English names are
> inferred from (a) the caller context in `radio_apply_channel` (which settings byte feeds each), (b) the payload shape,
> and (c) the FM100B `ATC_*` symbol list. Treat the ★ ones (`0x82` freq/mode, `0x06` call, `0x2A` radio-id, `0x62`
> enable) as high-confidence; the audio/denoise/squelch ones as medium-high.

---

## 3. ★ `atc_channel_set` @0x0800720C — the RX/TX frequency + mode setter

**Signature:** `void atc_channel_set(chan_cfg *cfg)` (msg_id `0x82`, 0x14-byte payload).

`cfg` is a channel-parameter block (the RAM staging struct, e.g. `0x20002E7A`, `0x20007DA9`, or a copy of a 48-byte
codeplug channel record). Field layout used by this function:

| cfg off | field | used how |
|---|---|---|
| `+0x00` | flags byte | bit1→bandwidth(+1), bit2→a flag, bits6-7→RX/TX permission (checked by caller) |
| `+0x01` | flags2 | high nibble → modulation (FM/AM/SSB) |
| `+0x05` | **RX freq** (u32 LE, 10 Hz units = MHz×100000) | `rx_hz = rxfreq × 10` → 4 bytes **big-endian** into payload |
| `+0x09` | **TX freq** (u32 LE, 10 Hz units) | `tx_hz = txfreq × 10` → 4 bytes big-endian |
| `+0x11` | contact index (u16) | reads contact rec at `0x5E000 + idx*0x15` → call type (0→1 Priv, 1→2 Grp, 2→4 All); target ID or `0xAAAAAAAA` for all-call |
| `+0x13` | CTCSS/DCS select | (handled by caller via `0x0C`/`0x84`) |
| `+0x14` | mute code / DCS (u16) | (caller → `0x81`) |

Key disassembly:
```
0800720c push {r4,r5,r6,lr}; r4 = cfg
08007220 ldr r0,[r4,#5]          ; RX freq (10Hz)
08007224 ldr r1,=0x16e3600       ; 24000000 = 240.00000 MHz band threshold
08007226 cmp r0,r1               ; >=240MHz -> band flag 0x20000C34 = 1 (UHF) else 0 (VHF)
0800725a add r0,r0,r0,lsl#2 ; lsls#1 → r0*10   ; convert 10Hz→Hz
...store BE at payload+0xf (RX), +0x13 (TX)...
08007340 ldrb r0,[r4,#0x14]      ; extra param
08007354 movs r0,#0x82 ; bl atc_send_pl   ; send ChannelSet
```

**Frequency units — DEFINITIVE:** codeplug stores `MHz × 100000` (i.e. **10 Hz units**, matches
`FREQ_MULTIPLIER=100000`). This function multiplies by **×10** to hand the FM100B **plain Hz** (big-endian u32).
So: `payload_hz = codeplug_value × 10`. Example: `43880000 (10Hz) → 438800000 Hz`.

**Callers (reuse these, or call `atc_channel_set` directly):** `0x0801AEE8` (inside `radio_apply_channel`),
`0x0801F590`, `0x0801F5C0` (dual-watch/scan band re-tune).

---

## 4. ★ `radio_apply_channel` @0x0801AE9C — the composite "tune the radio" entry point

**This is the function the new UI should call to make the radio adopt a channel.** It takes the channel-config block
and pushes *everything* (freq, mode, power/enable, color code, squelch, gains, radio-ID, CTCSS/DCS, denoise) to the
FM100B in one shot, reading auxiliary values from the RAM settings mirror `0x200029BB`.

**Signature:** `void radio_apply_channel(chan_cfg *cfg)` (`cfg` in `r0`).

Sequence (evidence = disassembly `0x0801AE9C`–`0x0801B016`):
```
if (cfg->flags>>6 == 0)  // normal RX/TX channel
    atc_query09()                     // 0x8007688  quiet/prep
    delay(0x14)                       // 0x8007946
    atc_channel_set(cfg)              // 0x800720C  ★ RX/TX freq + mode + BW
    atc_ch_enable(cfg&1, cfg&1)       // 0x80071E2  msg 0x62
    if (dmr) {
        atc_set_call_spk_vol(settings[0x188]%25)  // 0x800760A msg 0x02
        atc_set_call_mic_gain(settings[0x187]%25) // 0x8007530 msg 0x0B
        atc_set_dig_squelch(settings[0x193]%17)   // 0x80075F4 msg 0x4D
        atc_set_sms_mode(settings[0x196]&1)       // 0x80075DC msg 0x55
        atc_w_sms2(settings[0x195]&1)             // 0x8006DFC
        atc_set_color_code(cfg[0x63-region])      // 0x8007598 msg 0x0C
    } else {  // analog
        atc_set_denoise(settings[0x185], settings[0x186]) // 0x8006C4C msg 0x49
    }
    // radio ID: channel-custom (cfg+0x16) if cfg bit3 set, else settings[0x180]
    atc_set_radio_id(...)             // 0x80074FA msg 0x2A
    // CTCSS/DCS:
    if (cfg[0x13]==0) atc_set_color_code_off() // 0x8007548 msg 0x0C
    else              atc_set_rxgroup(cfg[0x13]-1) // 0x8007404 msg 0x84
    atc_set_mute_code(cfg[0x14])      // 0x800736C msg 0x81
else if (cfg->flags>>6 == 1)  // special/FM-broadcast/monitor branch
    atc_query09(); atc_w4C_tpl();     // 0x8007688, 0x8007620
    delay(0x14)
    GPIOA->BSRR = 0x4000              // band/PA GPIO bit14 set
    ... reset several RAM state bytes, call 0x801D938 (RX open) ...
    apply_backlight(settings[0x10D])  // 0x80049E4
```

**Callers:** `0x08002FE0`, `0x08009F08`, `0x0800B7B8`, `0x0800BBD4`, `0x0801F490` (channel change, VFO set, zone
switch, scan). In the rewrite, call `radio_apply_channel(cfg)` after you populate `cfg` (a 48-byte codeplug channel
record, or a synthesized VFO record) — the codeplug format is untouched.

**RAM boundary object:** `0x200029BB` = **RAM mirror of `main_settings` (SPI `0x002000`)**. The UI reads/writes this
struct (offsets match `rt4d_codeplug` `RadioSettings`, e.g. `[0x188]`=call spk vol, `[0x193]`=digital squelch,
`[0x180]`=radio DMR-ID, `[0x10D]`=backlight); the RF apply reads from it. Persisting it back to SPI keeps the codeplug
format intact.

---

## 5. ★ `ptt_tx_start` @0x08007E78 — PTT on / start TX

**Signature:** `void ptt_tx_start(u8 mode)` (`mode` in `r0`: distinguishes DMR vs analog / call-type).

Disassembly `0x08007E78`–`0x08007EFE`:
```
08007e78 push {r4,lr}; r4=mode
         ... call-start-beep if settings[0x18d] (0x801B684) ...
08007e90 if (band_flag 0x20000C34 == 1)  GPIOA->BSRR = (1<<10)   // set  band/PA bit10  (UHF)
08007ea4 else                            GPIOA->BSRR = (1<<10)<<16 // reset band bit10  (VHF)
   switch(mode):
     0: dmr_tx(0xFF, cur_contact_id 0x20000B3C[..0x11])  // bl 0x8006D00  (DMR key)
     1: atc_call_process(1, call_type 0x20000C9E, target 0x20000CA8, 0)   // Private
     2: atc_call_process(1, 0x20000C13, 0x20000C14, 1)                    // ...
     3: atc_call_process(1, 1, 0x20000CA8, 2)                             // AllCall
08007ef8 tx_state 0x20000B6E = 3   // "transmitting"
```
- **DMR TX** goes through `dmr_tx @0x08006D00` (the USART3 DMR-record/AMBE path).
- **Analog/DMR-call TX** goes through `atc_call_process @0x08006E6C` (msg `0x06`).
- The **band-select / PA-enable GPIO** is **GPIOA pin 10** (`0x40020000`, BSRR `+0x18`/`+0x28`), driven by band flag
  `0x20000C34` (set in `atc_channel_set` when RXfreq ≥ 240 MHz).

**Callers (PTT key handlers):** `0x0801EC34`, `0x0801ED86`, `0x0801ED94`, `0x0801ED9C`.

**PTT off / stop TX:** the reverse path returns to RX by re-running the RX-open (`0x0801D938`) and clearing
`tx_state 0x20000B6E`; the analog carrier key is released via `atc_ch_enable`/`atc_w4C`. For a rewrite, calling
`radio_apply_channel(cfg)` (which re-opens RX) after dropping PTT restores RX cleanly. (A dedicated
`atc_call_release` exists in the `0x06`/`0x62` family; the tx_state byte `0x20000B6E` and `0x20000B73` gate it.)

---

## 6. TX power, squelch, bandwidth, CTCSS/DCS, mode — where each lives

| RF parameter | How it is set | Function / evidence |
|---|---|---|
| **RX frequency** | `cfg[+5]` (10 Hz) → ×10 → Hz | `atc_channel_set @0x0800720C` (msg 0x82) |
| **TX frequency** | `cfg[+9]` (10 Hz) → ×10 → Hz | same |
| **Mode FM/AM/SSB** | `cfg[+1]` high nibble → payload | same (0x82). Values 0=FM,1=AM,2=SSB per codeplug `AnalogModulation`. |
| **Bandwidth W/N** | `cfg[+0]` bit1 → payload (`bit+1`) | same (0x82). 0=Wide/25k, 1=Narrow/12.5k. |
| **TX power Hi/Lo** | carried in `atc_channel_set` payload flags (from codeplug byte); FM100B applies power DAC from its own NV cal via `ATC_SetRfPowerLevelReq`. **The MCU does not compute a power DAC value** — it sends the Hi/Lo level and the FM100B uses its NV calibration. | msg 0x82 payload + FM100B `SPCali_PowerOpt` |
| **Squelch (DMR)** | `settings[0x193]` → `atc_set_dig_squelch` | `0x080075F4` (msg 0x4D) |
| **Squelch (analog)** | `settings[0x102]` region + `atc` analog SQ path | analog SQ is an FM100B cal (`SPCali_AnaSQthOpt`); level pushed via the analog-set family |
| **Color code** | `atc_set_color_code` | `0x08007548/0x08007598` (msg 0x0C) |
| **CTCSS/DCS** | `cfg[+0x13]` select → `atc_set_rxgroup`/off; `cfg[+0x14]` value → `atc_set_mute_code` | `0x08007404` (0x84), `0x0800736C` (0x81) |
| **Radio DMR ID** | `settings[0x180]` or `cfg[+0x16]` | `atc_set_radio_id @0x080074FA` (msg 0x2A) |
| **MIC gain / SPK vol (DMR)** | `settings[0x187]`,`[0x188]` | `0x08007530` (0x0B), `0x0800760A` (0x02) |
| **TX/RX denoise (analog)** | `settings[0x185]`,`[0x186]` | `atc_set_denoise @0x08006C4C` (0x49) |

**Power note:** because Hi/Lo maps to an FM100B-internal calibrated DAC, the UI must only pass the codeplug power
byte through `radio_apply_channel`; it must **never** try to write a raw power value — that would require the per-unit
calibration and risk PA damage.

---

## 7. MCU-local RF-adjacent helpers (not FM100B)

### 7.1 Battery voltage (ADC1)
- `battery_read @0x0801094C` — `void battery_read(void)`. Software-starts ADC1 (`adc_sw_start @0x08020B4C`,
  CR2.SWSTART bit30), waits up to 20 samples, then `batt = (adc_raw << 2) / 0x42` → stored at **`0x200008B0`**
  (accumulator raw `0x2000089C`). Divisor `0x42`(66) ⇒ result is in **0.1 V units** (feeds the `BATT:x.xV` string).
  Callers: `0x08012816` (boot/about), `0x0801E288` (periodic/low-batt check).
- ADC helpers: `adc_sw_start @0x08020B4C(port,en)`, ADC ISR `@0x08002D1C` accumulates into `0x2000089C`.
- **RSSI** is **not** an MCU ADC read — RSSI/signal-quality is read back from the FM100B via ATC query
  (`ATC_RssiReadReq`/`ATRssiQueryCnf`, msg-id in the `0x09`/query family) and lands in an RX-decoded RAM struct.

### 7.2 Audio (DAC / codec)
- DAC control at `0x40007400`: `dac_enable_chX @0x08020F90 / 0x08020FA4 / 0x08020FE0` toggle DAC CR enable/trigger
  bitfields (bit0/bit16/bit1/bit17). Used to gate the audio path.
- **Speaker volume for voice** is largely an FM100B setting (`atc_set_call_spk_vol` msg 0x02, `SPMicVoiceCnf`); the
  MCU DAC is the tone/beep/analog-audio out. Amp-enable is a GPIO (see below).

### 7.3 Key GPIOs (for the rewrite)
| GPIO | purpose | evidence |
|---|---|---|
| GPIOA (`0x40020000`) BSRR, **bit10** (`0x400`) | **band-select / PA enable** (VHF vs UHF; set on TX) | `0x8007E90`, `0x801F574`, `0x801F5A4`, `0x801AFD8` (bit14 `0x4000` in special branch) |
| GPIOB (`0x40020400`) BSRR `+0x28` | **SPI-flash CS** and FM100B reset toggles | `spi_flash_read @0x08021828` (`0x40020428`), `0x8007F60` (FM100B reset, bit set/reset via `0x40020418`) |
| `delay @0x08007946(ms)` | busy delay used around FM100B commands/reset | pervasive |

### 7.4 SPI flash / calibration read (used by RF setup, keep format)
- `spi_flash_read @0x08021828` — `void spi_flash_read(void *dst, u32 addr, u32 len)`. Opcode `0x03`, CS on GPIOB;
  handles 3-byte vs 4-byte addressing (chip-id `0x18/0x19` at `0x20000C1C`). This reads the **calibration block at SPI
  `0x000000`**, channels, contacts (`0x5E000`), groups (`0xC6000`), and fonts.
- **How calibration feeds RF:** the MCU does **not** apply RF calibration itself. The 4 KB cal block at SPI `0x000000`
  is per-unit factory data that the **FM100B** consumes (its `SPCaliFreqSetCnf` / `SPCali_*Opt` NV items) to trim
  VCO/PLL, TX power DAC, and squelch/RSSI thresholds. The MCU only reads cal for display/backup. **The UI must reuse
  the stock apply path (which sends frequency + Hi/Lo level and lets the FM100B self-calibrate); it must not re-tune.**

---

## 8. The reusable "set radio to F/mode/power then PTT" call sequence

For the rewritten UI, the clean, minimal sequence (all stock functions, codeplug + CPS untouched):

```c
// 1. Build/obtain a channel-config block `cfg` (a 48-byte codeplug channel record, or a VFO
//    record you synthesize in the SAME on-flash format — do NOT change the format).
//    Set:  cfg[+5]=rx_freq_10Hz  cfg[+9]=tx_freq_10Hz
//          cfg[+0]: bit1=narrow, bits6-7=rx/tx-perm, power bit as in codeplug
//          cfg[+1]: high nibble = modulation (0 FM,1 AM,2 SSB)
//          cfg[+0x13]/[+0x14]=CTCSS-DCS select/value,  cfg[+0x11]=contact index
//    (rx/tx freq in codeplug 10 Hz units = MHz*100000)

// 2. Make sure the RAM settings mirror 0x200029BB holds the desired
//    color-code / squelch / gains / radio-ID (offsets = rt4d RadioSettings).

// 3. Push the whole channel to the FM100B (freq, mode, BW, power, CC, SQ, ID, CTCSS):
radio_apply_channel(cfg);          // 0x0801AE9C

// 4. To transmit:
ptt_tx_start(mode);                // 0x08007E78   (mode 0 = DMR, 1/2/3 = analog/call variants)
//    -> sets band GPIO (GPIOA bit10) and keys TX via atc_call_process/dmr_tx

// 5. To stop TX / return to RX:
//    clear tx_state 0x20000B6E and re-open RX; simplest robust way is:
radio_apply_channel(cfg);          // re-runs the RX-open path
```

If you need finer control instead of the composite, call the wrappers directly:
`atc_channel_set(cfg)` (freq/mode/BW), `atc_ch_enable(rx,tx)`, `atc_set_color_code(cc)`,
`atc_set_dig_squelch(sq)`, `atc_set_radio_id(id)`, `atc_call_process(a,type,id,r3)`.

---

## 9. Master callable-entry-point table

| vaddr | name | signature | confidence |
|---|---|---|---|
| `0x0801AE9C` | `radio_apply_channel` | `void(chan_cfg*)` | **high** — verified 5 callers, full body |
| `0x08007E78` | `ptt_tx_start` | `void(u8 mode)` | **high** — 4 PTT callers |
| `0x0800720C` | `atc_channel_set` (RX/TX freq+mode+BW) | `void(chan_cfg*)` msg 0x82 | **high** |
| `0x08006E6C` | `atc_call_process` (key TX / start call) | `void(u8 a,u8 type,u32 id,u8 r3)` msg 0x06 | **high** |
| `0x08006D00` | `dmr_tx` (DMR record/AMBE TX) | `void(u8 a, u16 contact)` | med-high |
| `0x080074FA` | `atc_set_radio_id` | `void(u32 dmr_id)` msg 0x2A | high |
| `0x080071E2` | `atc_ch_enable` (RX/TX wait) | `void(u8 rx,u8 tx)` msg 0x62 | high |
| `0x080075F4` | `atc_set_dig_squelch` | `void(u8)` msg 0x4D | med-high |
| `0x08007548`/`0x08007598` | `atc_set_color_code` | `void(u8)` msg 0x0C | med-high |
| `0x0800736C` | `atc_set_mute_code` (DCS val) | `void(u16)` msg 0x81 | med |
| `0x08007404` | `atc_set_rxgroup` (CTCSS/DCS/RX-group) | `void(u8 idx)` msg 0x84 | med |
| `0x08007530` | `atc_set_call_mic_gain` | `void(u8)` msg 0x0B | med |
| `0x0800760A` | `atc_set_call_spk_vol` | `void(u8)` msg 0x02 | med |
| `0x08006C4C` | `atc_set_denoise` | `void(u8 tx,u8 rx)` msg 0x49 | med |
| `0x0801B044` | `atc_send` | `void(u8 id,u8,u8,u8,u32 to)` | **high** (core) |
| `0x0801B0C4` | `atc_send_pl` | `void(u8 id,u8,u8,u8,u8*pl,u16 len,u32 to)` | **high** (core) |
| `0x08006C9C` | `usart3_tx_buf` | `void(u8*,u16)` | high |
| `0x08006CB8` | `usart3_tx_byte` | `void(u8)` | high |
| `0x0801094C` | `battery_read` | `void(void)` → `0x200008B0` (0.1 V) | high |
| `0x08020B4C` | `adc_sw_start` | `void(u32 port,u8 en)` | high |
| `0x08021828` | `spi_flash_read` | `void(void*,u32 addr,u32 len)` | **high** |
| `0x08021538` | `spi_xfer_byte` | `u8(u8)` on SPI2 | high |
| `0x08007946` | `delay_ms` | `void(u32)` | high |
| `0x08002EA8` | `atc_checksum` | `u16(u8*,u16)` | med |
| `0x0800786C` | `dmr_id_to_bcd` | `u32(u32)` | med |

### Key RAM state (the UI/RF boundary)
| addr | meaning |
|---|---|
| `0x200029BB` | **RAM mirror of main_settings (SPI 0x2000)** — offsets = `RadioSettings` |
| `0x20007DA9` | ATC call/freq staging struct (`[0]=type,[1..4]=id,[5..8]=freq`) |
| `0x20002E7A` / `0x20002120` | channel-config staging blocks (used by scan/dual-watch) |
| `0x20007476[msg_id]` | ATC confirm-flag array (0xFF=pending) |
| `0x2000706E` | ATC TX frame build buffer |
| `0x20000C34` | band flag (0=VHF <240 MHz, 1=UHF) → GPIOA band bit |
| `0x20000B6E` | TX/call state (3 = transmitting) |
| `0x200008B0` | battery voltage (0.1 V units) |
| `0x200008B0`←`0x2000089C` | battery ADC raw accumulator |

---

## 10. Boundary compliance (codeplug + CPS unchanged)

- The RF API operates on a **channel-config block in the stock 48-byte codeplug format** and on the **RAM settings
  mirror `0x200029BB`** whose layout equals `rt4d_codeplug.RadioSettings`. Reusing these keeps the SPI codeplug format
  identical, so the stock CPS round-trips.
- All RF programming is **USART3 ATC traffic to the FM100B** — completely separate from the USART6 CPS/serial protocol
  (`0x34/0x52/region-id` framing). Rewriting the UI and calling these functions changes nothing the CPS observes.
- **Calibration (SPI `0x000000`) is consumed by the FM100B, not recomputed by the MCU.** The rewrite reuses the stock
  freq/power/mode apply path, so the per-unit factory tuning is honored and never overwritten.
