# RT-4D Stock Firmware — UI Architecture & API Reference (for a UI rewrite)

**Target image:** `stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin` — ARM Cortex-M4F (STM32F407-class Artery AT32F407 clone), Thumb, load/vaddr base **`0x08002800`**. App flash spans `0x08002800 .. 0x0802885C` (~152 KB). All disassembly via capstone `CS_ARCH_ARM + CS_MODE_THUMB`.

This is the **master blueprint** for rewriting the RT-4D's entire UI (standby screen + menu system + all screens + navigation) while **reusing** the stock firmware's display/keys/RF/DMR/codeplug functions as an SDK, and while keeping the **SPI codeplug format** and the **CPS serial protocol** byte-identical so the stock Radtel CPS keeps working. It synthesizes seven subsystem RE docs (`re/main-loop.md`, `re/display.md`, `re/input.md`, `re/radio.md`, `re/dmr.md`, `re/codeplug.md`, `re/main-screen.md`), resolves their contradictions, and preserves every concrete vaddr/signature.

---

## 1. Executive summary

**How the UI is structured.** Boot glue hands off to the application superloop **`app_main @0x0802136C`**. Each pass, the superloop honours a reboot flag, then branches on **serial-session state**: if the radio is *not* in a PC-programming / SPI-write session it runs the **normal UI tick `ui_tick_normal @0x080207DC`**; otherwise it services the CPS/serial paths. The normal UI tick is a cooperative scheduler that, among other periodic tasks, calls exactly two dispatchers:

- **Screen draw:** `ui_draw_dispatch @0x0801E1BC` (called at **`0x080207E6`**) — `tbb`-jumps on the single screen-state byte `g_screen @0x200008B3` (0..11) to the current screen's incremental draw routine.
- **Screen input:** `ui_process_key @0x0801E6EC` (called at **`0x08020816`**) — first calls `keypad_process @0x08005A14` (scan+debounce), then `tbb`-jumps on the same `g_screen` to the current screen's key handler `handler(u8 keycode, u8 keystate)`.

Both dispatchers are keyed on **the same** state byte and their 12-entry `tbb` tables are positionally parallel (index *i* = same screen in both). Drawing is **incremental / dirty-flag driven** into a page-addressed 128×64 mono LCD with **no framebuffer** (pixels stream straight to GDDRAM).

**Single best hook strategy.** Patch the **two `bl` call sites in the scheduler** — `0x080207E6` (draw) and `0x08020816` (key) — to call your own router (`my_draw_router` / `my_key_router`) living in free app flash. Your router owns rendering + navigation and calls the stock lower-level APIs for everything else. This is a **2-instruction redirect** that leaves the superloop, the RF/DMR RX pipeline, battery/scan tasks, and the **entire serial/CPS + SPI region-write path untouched**. (See §4 for exact patch encoding and a phased plan.)

**CPS-compatibility boundary.** Two things must stay byte-identical: (a) the **on-SPI codeplug format** (region bases, record strides/fields, settings dual-bank + `0xABCD` magic, per-unit calibration at `0x000000`), and (b) the **USART6 CPS protocol** (ISR `0x0802061C`, framer `0x0801F864`, dispatcher `0x08019790`, region R/W handler `0x080188D4`, its buffers, and all opcodes). The UI never touches these directly — it mutates the **RAM working copies** of settings/channels and calls the stock **save wrappers**, which preserve the format. Because the CPS transfers raw SPI blocks, preserving the on-flash format automatically preserves CPS compatibility. A third internal bus (USART3 ↔ FM100B DMR baseband, "ATC" `0x68…0x10` framing) carries all RF/DMR programming and is independent of both boundaries — the UI reuses it via the RF/DMR wrappers.

---

## 2. Memory & architecture map

| Region | Range / value | Notes |
|---|---|---|
| Bootloader flash | `0x08000000 .. 0x08002800` | separate image; CPS bootloader opcodes `0x39`/`0x57`. Do not touch. |
| **App flash** | `0x08002800 .. ~0x0802885C` | this image (~152 KB). Vector base set into VTOR by `app_main`. |
| **Free app flash** | **`0x08029000+`** (and gaps at `0x08028860/0x08028900`, already used by russification cave + font) | inject the custom UI layer here (§4.4). |
| SRAM | `0x20000000 .. 0x20020000` (128 KB), initial SP `0x2000AE48` | UI state vars live in `0x2000xxxx`. |
| **External SPI data-flash** | 4 MB (dumped in `radio-spi-dump.bin`) | codeplug + calibration + fonts. Read via `spi_flash_read @0x08021828` (opcode 0x03). **Codeplug format frozen.** |
| MCU peripherals | RCC `0x40023800`; USART1 `0x40010000`, USART2 `0x40004400`, **USART3 `0x40004800` (FM100B DMR)**, **USART6 `0x40011400` (PC/CPS)**; SPI2 `0x40003800`; ADC1 `0x40012000` (batt/RSSI); DAC `0x40007400` (audio); TIM1/2/9/10; GPIOA–F | TIM2 ISR `@0x0801DDCD` = tick; SysTick unused. |
| Display | mono **128×64**, 8 pages × 128 cols, **bit-banged GPIO** (RS `0x40010000`, CLK/DATA GPIOB `0x40020400`, CS GPIOA `0x40020000`), **no framebuffer** | fonts fetched from SPI `0x19C000` (ASCII 14 B/glyph) / `0x19E000` (GBK 28 B/glyph). |

**Three independent buses (keep them straight):** USART6 = CPS (frozen), USART3 = FM100B/DMR (reuse via ATC wrappers), SPI2/GPIO SPI = codeplug+fonts (format frozen; reads OK). The UI rewrite touches only the LCD GPIO and reads.

---

## 3. The main loop & screen state machine — exact hook points

### 3.1 Boot → superloop (verified)

```
Reset 0x08002AC0 → SystemInit(0x0801DA2D) → __main 0x080029E0 → __rt_entry 0x08002AA0
   → bl 0x0802136C            ; app_main — the application superloop
app_main 0x0802136C:
   hw_init(0x0801DAD0); VTOR=0x08002800; init B/C/D/E; then LOOP:
   0x0802138C  if (*(u8*)0x20000C54) nvic_system_reset(0x0801A38C)   ; reboot flag
   0x08021396  if (mode 0x20000B66==0 && 0x20000C12∉{2,4} && 0x20000C57==0)
   0x080213B2      bl 0x080207DC        ; ★ NORMAL UI TICK  (ui_tick_normal)
               else  … CPS / SPI-write session handlers …            ; ← DO NOT TOUCH
   0x0802140C  bl 0x0801F854 (serial_poll)   ; every pass — CPS path — keep
   0x08021410  bl 0x0801FE50 (housekeeping)  ; every pass — keep
   0x08021414  loop
```

### 3.2 `ui_tick_normal @0x080207DC` — the two dispatch calls to hook (verified by disasm)

```
0x080207E6  bl 0x0801E1BC   ; ★ ui_draw_dispatch   (SCREEN DRAW — hook here)
0x08020816  bl 0x0801E6EC   ; ★ ui_process_key     (SCREEN INPUT — hook here)
```

Both are unconditional every UI pass. Other scheduler calls (`0x0801E050` side-key handler, `0x0801E3F8` line-buffer builder, and period-gated RSSI/battery/scan tasks throttled by counters `0x20000BF1..BF8`) can be left as-is or repointed for full control.

### 3.3 The screen state machine (single selector + two mirror `tbb` tables)

```
g_screen      = *(u8*)0x200008B3   ; current screen id, 0..11 — 55 literal-pool xrefs
g_screen_prev = *(u8*)0x200008B2   ; previous screen (back/restore)
```

Both dispatchers guard `cmp g_screen,#0x0C; bhs default` before the `tbb`. Draw-table base `@0x0801E1CE`, input-table base `@0x0801E784` (each 12 one-byte half-word offsets). **Navigation = write the target id to `0x200008B3`** (+ per-screen enter side-effects); optionally save old id to `0x200008B2`.

| id | draw handler | input handler | screen |
|---:|---|---|---|
| **0** | `0x08013BD8` | `0x08017F60` | **Home / standby (VFO/channel)** — primary |
| 1 | default no-op | default no-op | blank / transient |
| 2 | `0x08013980` | `0x08007B10` | VFO/standby-key screen (MENU key → id 10) |
| 3 | `0x08013A44` | `0x0800FE3C` | freq-input / dial (icon `0x0802505C`) |
| 4 | `0x08014978` | `0x0802074C` | screen 4 (icon `0x08025115`) |
| 5 | `0x08013940` | `0x08007784` | numeric entry A (hex 0–F) |
| 6 | `0x080139EC` | `0x08007CD0` | numeric entry B (dec 0–9) |
| 7 | `0x08013804` | `0x08007E68`→`0x08017F60` | alt/dual home (variant of 0) |
| 8 | `0x08013B6C` | via `0x0800F930` | list/scroll screen |
| 9 | `0x08013F7C` | no-op | status/info screen |
| **10 (0xA)** | **`0x080142C0`** | **`0x08017294`** | **MENU system** ★ |
| 11 (0xB) | `0x08014104` | `0x0801D3B0` | SMS / text editor |

Canonical transition (Home→Menu, in id-2 handler): `bl 0x0801AD9C` (build top-level list) → `movs r0,#0xA; strb r0,[=0x200008B3]`.

### 3.4 Contradiction resolved — there are TWO dispatch systems; only one is the live UI

`re/input.md` documents a **second** router, `screen_dispatch @0x08018DF4`, keyed on `ctx[+1] @0x20002121` (0..5), reached from `ui_input_service @0x08005E54`. Verified xrefs settle which is authoritative:

- `ui_tick_normal @0x080207DC` is called from the superloop (`0x080213B2`) and calls **`ui_process_key @0x0801E6EC`** at `0x08020816`; `ui_process_key` itself calls `keypad_process @0x08005A14`. **This `g_screen`/`0x200008B3` path (12 screens) is the LIVE normal-mode UI.**
- `ui_input_service @0x08005E54` has **exactly one caller, `0x08010D08`**, which sits behind a blocking `delay_ms(0xBB8)` — a **special sub-mode / config context**, not the normal tick. Its router `screen_dispatch @0x08018DF4` (also called from `0x0800A8D8`, `0x080172CE` inside menu code) drives a smaller **5-entry** secondary state machine used by that sub-mode and certain menu-internal editors.

**Conclusion for the rewrite:** hook the **primary** path (`0x080207E6` / `0x08020816`, `g_screen @0x200008B3`). The secondary `screen_dispatch`/`ctx @0x20002121` machine is real but subordinate; if a screen you replace routes into it, override that screen's handler too. Both use the **same key struct `0x20000B57`** (byte +2 = keycode, +3 = keystate), so key reads are uniform.

---

## 4. Callable API reference — the "SDK" a new UI links against

All addresses are absolute Thumb vaddrs; when taking a function pointer, set bit0. AAPCS: r0..r3 = args, extra args on stack.

### 4.1 Display (reuse verbatim — the graphics toolkit)

Panel: 128×64 mono, 8 pages × 128 cols, 1 bpp, **no framebuffer** (immediate). "Row" = 2 pages (16 px). `mode`: 0 normal / 1 inverse (selection highlight) / 2 outline.

| vaddr | name | C signature | notes |
|---|---|---|---|
| **`0x08008A50`** | **draw_string** | `void draw_string(u8 page, u8 x, const char *s, u16 len, u8 mode /*[sp+0x28]*/)` | **primary text API.** ASCII (+7px) & GBK/CJK/Cyrillic (lead≥0x80, +14px) auto-routed; wraps X~121. |
| `0x08007FB8` | ascii_blit | `void(u8 page,u8 x,u8 ch,u8 mode)` | one 7×16 ASCII glyph; SPI font `0x19C000+(ch-0x20)*14`. |
| `0x08008454` | gbk_blit | `void(u8 page,u8 x,u16 gbk,u8 mode)` | one 14×16 double-byte glyph; SPI font `0x19E000`. |
| `0x08008530` | gbk_char_at | `void(u8 page,u8 x,u16 gbk)` | standalone wide-char draw. |
| `0x080089AC` | small_char | `void(u8 page,u8 x,char ch)` | compact 5×8 font (1 page). |
| `0x08008100` | big_char | `void(u8 page,u8 x,u8 ch,u8 mode)` | large freq digits (33 B/glyph). |
| `0x08008B90` | draw_number_row | `void(u8 page,u8 x,const u8*digits,u16 n,u8 mode)` | big-digit row, 12px stride (freq). |
| `0x08008BC6` | draw_str_spaced | `void(u8 page,u8 x,const u8*s,u16 n,u8 mode)` | small-font row, 6px stride. |
| `0x08008D24` | blit_cols | `void(u8 page,u8 count,const u8*cols,u8 src)` | 1-page raw blit; **`src=0` ⇒ clear** (0x00). |
| `0x08008D62` | blit_rect | `void(u8 page,u8 x,u8 pages,u8 width,const u8*bmp /*[sp]*/)` | multi-page bitmap/icon blit (use to flush an SRAM shadow). |
| `0x08008DAA` | draw_box | `void(u8 page_origin)` | rounded-rect popup/menu border. |
| `0x08008CF0` | draw_hline_seg | `void(u8 col,bool on)` | 2-px separator/underline. |
| `0x08008224` | draw_frame_corners | `void(u8 x,u8 page)` | selection frame corners. |
| `0x0800870C` / `0x08008874` | draw_marker5 / draw_icon_batt | `void(u8 page,bool on)` | A/B arrow (col 123) / battery marker. |
| `0x08008A04` | draw_icon_signal | `void(u8 page,bool on)` | signal icon (col 21). |
| `0x08014A7C` | lcd_set_pos | `void(u8 page,u16 col)` | cursor; `col_cmd = 0xB7 − x`. |
| `0x08014B28` | lcd_write_col | `void(u8 column_bits)` | stream 8 vertical px (LSB=top), auto-advance. |
| `0x08014C34` | lcd_set_brightness | `void(u8 level)` | level 0..4 → PWM duty {0,5,0x1E,0x64,0xFF}. |
| `0x08021828` | spi_flash_read | `void(void*dst,u32 addr,u32 len)` | **font/codeplug read** (opcode 0x03) — reads only. |
| `0x08007946` | delay_ms | `void(u32 ms)` | busy delay. |

Clear full screen: loop `blit_cols(pg,128,0,0)` for `pg=0..7`. Selection highlight: `draw_string(..., mode=1)`.

### 4.2 Input (reuse — keypad/PTT)

Polled GPIO **4×4 matrix** (not ADC ladder, no rotary encoder). PTT = GPIOA pin12. Side keys = codes `0x11`/`0x12`.

| vaddr | name | C signature | notes |
|---|---|---|---|
| `0x0801130C` | keypad_decode | `u8(void)` | raw matrix word `0x20000B7C` → key code; `0xFF`=none. No debounce. |
| `0x08005A14` | keypad_process | `void(void)` | scan+debounce+long/repeat; fills key struct `KeyEv @0x20000B57`. Called by `ui_process_key`. |
| `0x08012C24` | key_event_clear | `void(void)` | ack/consume pending key (sets +2 to 0xFF). |
| `0x0801B294` | keypad_scan_col | `void(int col)` | scan one column into `0x20000B7C`. |
| `0x0801B398` | keypad_task | `void(void)` | column-drive state machine (call each ~1ms if you own the loop). |
| `0x08021316` | gpio_read_pin | `int(void*port,u32 mask)` | 1 if all masked IDR bits set. |
| `0x08021C6E` | gpio_write_pin | `void(void*port,u32 mask,int state)` | ODR set/clear. |

**Key struct `KeyEv @0x20000B57`:** +2 = delivered keycode (`0xFF`=none), +3 = keystate (1=press/short/long, 2=repeat), +5 = u16 hold-ticks (long/repeat threshold `0x2BC`=700), +0x1E (`0x20000B75`) = PTT flag. Stock consumes by writing `0xFF` to +2 after dispatch (`0x0801E806`).

**Key-code enum:** `0x00..0x09`=digits 0–9, `0x0B`=MENU/M, `0x0C`=UP, `0x0D`=DOWN, `0x0E`=`*`, `0x0F`=`#` (also menu-enter sentinel), `0x10`=EXIT, `0x11`=SIDE1, `0x12`=SIDE2, `0xFF`=none. (Non-digit label assignment is medium-high; confirm silk-screen on-device.)

### 4.3 Radio / RF (reuse — FM100B ATC layer). There is NO MCU-side RF chip; all RF/DMR lives in the FM100B, driven over USART3.

| vaddr | name | C signature | notes |
|---|---|---|---|
| **`0x0801AE9C`** | **radio_apply_channel** | `void(chan_cfg *cfg)` | ★ composite "tune the radio": pushes freq/mode/BW/power/CC/SQ/gains/ID/CTCSS to FM100B in one shot. **Call this after populating `cfg`.** |
| **`0x0800720C`** | **atc_channel_set** | `void(chan_cfg *cfg)` (msg 0x82) | ★ RX+TX freq + mode + bandwidth. Freq = codeplug 10 Hz units ×10 → Hz BE. `cfg[+5]`=RXfreq, `[+9]`=TXfreq, `[+1]`hi-nibble=modulation, `[+0]`bit1=narrow. |
| **`0x08007E78`** | **ptt_tx_start** | `void(u8 mode)` | ★ PTT on / start TX. mode 0=DMR, 1/2/3=analog/private/allcall. Sets band GPIO (GPIOA pin10). |
| `0x08006E6C` | atc_call_process | `void(u8 a,u8 type,u32 id,u8 r3)` (msg 0x06) | start call / key DMR TX (type 1=Priv,2=Grp,4=All). |
| `0x080071E2` | atc_ch_enable | `void(u8 rx,u8 tx)` (msg 0x62) | channel RX/TX enable. |
| `0x080074FA` | atc_set_radio_id | `void(u32 dmr_id)` (msg 0x2A) | set our DMR ID. |
| `0x080075F4` | atc_set_dig_squelch | `void(u8)` (msg 0x4D) | DMR squelch. |
| `0x08007548`/`0x08007598` | atc_set_color_code | `void(u8)` (msg 0x0C) | color code / off. |
| `0x0800736C` | atc_set_mute_code | `void(u16)` (msg 0x81) | analog DCS/mute value. |
| `0x08007404` | atc_set_rxgroup / read_group_list | `void(u8 idx)` (msg 0x84) | RX-group/CTCSS upload; also reads group list `0xC6000+idx*80`. |
| `0x08007530`/`0x0800760A` | atc_set_call_mic_gain / spk_vol | `void(u8)` (msg 0x0B / 0x02) | DMR mic gain / spk vol. |
| `0x08006C4C` | atc_set_denoise (a.k.a. dmr_set_radio_id in dmr.md) | `void(u8 tx,u8 rx)` (msg 0x49) | analog denoise / ID set — see §note. |
| `0x0801B044` | atc_send | `void(u8 id,u8 a1,u8 a2,u8 a3,u32 to)` | core no-payload sender; blocks on confirm. |
| `0x0801B0C4` | atc_send_pl | `void(u8 id,u8 a1,u8 a2,u8 a3,u8*pl,u16 len,u32 to)` | core payload sender; blocks on confirm. |
| `0x0801094C` | battery_read | `void(void)` → `0x200008B0` (0.1 V) | ADC1 battery. (`0x08010960` returns the value.) |
| `0x08020B4C` | adc_sw_start | `void(u32 port,u8 en)` | ADC software start. |

**RAM boundary object `main_settings` mirror:** `re/radio.md` uses `0x200029BB`, `re/codeplug.md` uses `0x20002014`. These are two named handles into the settings working area (offsets = `rt4d_codeplug.RadioSettings`); the RF apply reads settings fields from this mirror. Treat `0x20002014` as the canonical 4 KB working copy of SPI `0x002000`, and `0x200029BB` as a settings sub-region pointer used by the RF path; when in doubt, read/modify via the codeplug save wrappers (§4.5) so the on-flash format stays correct.

**msg 0x49 name conflict:** `re/radio.md` calls `0x08006C4C` `atc_set_denoise(tx,rx)`; `re/dmr.md` calls it `dmr_set_radio_id(idHi,idLo)`. Both agree it's a **4-byte payload cmd `0x49` sender**; the *semantics* are unresolved (LOW confidence). For an ID set, prefer the dedicated `atc_set_radio_id @0x080074FA` (msg 0x2A). Verify 0x49 on-target before relying on either name.

### 4.4 DMR (reuse — FM100B protocol over USART3, `0x68…0x10` framing; independent of CPS)

| vaddr | name | C signature | notes |
|---|---|---|---|
| `0x08003050` | poll_serial | `void(void)` | **pump**: run FM100B RX parse + CPS framer + RX drain once. Call in any wait loop. |
| `0x08018CB0` | fm100b_rx_parse | `int(void)` | consume one framed FM100B message, verify checksum, dispatch; returns 1 if consumed. |
| `0x08006348` | fm100b_on_frame | `void(u8*frame)` | master `*Cnf`/`*Ind` dispatch (sets `resp[cmd]`, jump-tables Inds). |
| `0x08006CFC` | dmr_call_start_from_contact | `void(u8 dummy,u16 contact_idx)` | originate call to stored contact (maps type, latches curcall). |
| `0x08006FD8` | dmr_call_resend | `void(void)` | re-send current-call setup (PTT continue). |
| `0x0800736C` | dmr_sms_send | `void(u16 target)` | send SMS (msg 0x82 header + 0x81 payload). |
| `0x08006D00` | dmr_contact_read | `void(u8 dummy,u16 idx,out u8 rec[21])` | read 21-B contact record `idx*27 + 0x5E000` (name/ID/type). |
| `0x080074FA` | atc_set_radio_id | `void(u32 dmr_id)` (msg 0x2A) | set own DMR ID (canonical). |
| `0x0801A38C` | nvic_system_reset | `noreturn void(void)` | reboot (remote-kill enforcement). |

**Incoming-call state (read to render RX overlay):** `0x20007DC2` → `+0` type(0=Grp,1=Priv,2=All), `+1` u32 dest/TG, `+5` u32 caller ID. Status bytes `0x20000C3C/3D`. Resolve caller **name** by ID via `find_contact_by_id @0x08005810` (fallback: decimal ID). `resp[cmd]` array `@0x20007476` (0xFF=pending). Convert on-wire BE IDs with `be32_to_u32 @0x080112B8`.

### 4.5 Codeplug / Settings (reuse — format-safe read/save; DO NOT re-implement)

Low-level SPI (reuse; addressing/opcodes baked in):

| vaddr | name | C signature |
|---|---|---|
| `0x08021828` | spi_flash_read | `void(void*dst,u32 addr,u32 len)` (opcode 0x03) |
| `0x08021924` | spi_flash_erase4k | `void(u32 sector_idx)` (opcode 0x20) |
| `0x08021A70` | spi_flash_program | `void(u32 addr,const void*src,u32 len)` (page-split) |
| `0x080217B8` | spi_page_program | `void(u32 addr,const void*src,u16 len)` (opcode 0x02, ≤256 B) |
| `0x080109DE` | checksum | `u8(const void*buf,u32 len)` (8-bit sum, seed 0) |
| `0x08010540` / `0x0801058C` | flash_write_guard_enter / _exit | `void(void)` (bracket erase/program batches) |

Per-record read/save wrappers (call these — they keep format + dual-bank correct):

| vaddr | name | C signature | commits/reads |
|---|---|---|---|
| `0x08004F20`/`0x080055A0` | read_channel | `void(u16 idx)` | `spi_flash_read(&liveChan[band], 0x4000+idx*48, 48)`; dest `0x20002DEA + band*48`. |
| `0x08005810` | find_contact_by_id | `bool(u32 id,u8 type,char*out_name16)` | scan contacts `0x5E000` (27-B stride). |
| `0x08007404` | read_group_list | `void(u16 idx)` | `0xC6000 + idx*80`; resolves members. |
| `0x08009B90` | read_addressbook_contact | `void(uint slot, out)` | `0x126000 + slot*32` (32-B). |
| `0x08009480` | read_message | `void(…)` | 200 B text → `0x20006F86`. |
| `0x08004CB0` | settings_save | `void(void)` | commit `main_settings`: guard → stage `0x2000`→`0x20002EBE` → erase sector 1 → program 4 KB → guard-exit. |
| `0x080061E0` | contacts_compact | `void(uint idx)` | format-safe contact delete. |
| `0x08004AB0` | codeplug_backup_to_shadow | `void(void)` | full "Backing up…" region backup. |

---

## 5. The main / standby screen — data sources + draw + replacement

**Screen id 0**, draw handler **`home_draw @0x08013BD8`**, input handler `0x08017F60`. (Alt/dual variant = id 7 → `0x08013804`.)

**Render pipeline (data-driven, two-stage):**

```
SPI channels (0x004000, 48B)  → cached into  g_chcache @0x20002DEA  (48B stride, [area])
        │
        ▼  format_area_display(u8 area,u8 hi,u8 shift)  @0x08011414
        │     reads cache freq/tones/mode/name → formats ASCII into g_disp
        ▼
g_disp @0x200009C3  (display struct; Area A +0, Area B +0x43, stride 0x43)
        │     + sets element dirty flags in g_dirty @0x200024EB
        ▼  home_draw @0x08013BD8   (every UI tick; paints element k iff g_dirty[k]!=0)
        ▼  draw_string / draw_number_row / draw_str_spaced / draw_marker5  → GDDRAM
```

**Key `g_disp @0x200009C3` fields:** `+0x00` top name/tag line; `+0x15` `"CH-nnn"`/`"A-"`/`"D-"` prefix; `+0x1C` status flag (`"HD"`); `+0x1E` element mode (0 blank/1 freq/2 name); `+0x20` main Area-A string (`"438.80000"`/name/`"CH MODE"`/`"VFO MODE"`); `+0x32` Area-B string; `+0x42` active area; `+0x63` A/B arrow marker. Companion `g_disp2 @0x20000A28` holds the `"ANA"`/`"DMR"` tag. Channel cache fields (`0x20002DEA+area*0x30`): `[+0]>>6`=digital, `[+5]`=RXfreq (MHz×100000), `[+9]`=TXfreq, `[+0xD]&0xFFF`=RX tone, `[+0x20]`=16-B name. DMR RX overlay ctx `g_call @0x2000A6C5` (`+1` type, `+2` ID, `+0x38` name).

**Helpers for formatting your own home screen:** `num_to_ascii(val,ndigits) @0x08018530` (→ scratch `0x200024D0`), `str_insert_char(buf,ch,pos,len) @0x080135A8` (splice the `.`), `memcpy_off(dst,src,dstoff,len) @0x080062EC`.

**Two replacement strategies:**
- **A (least work):** keep calling `format_area_display(area,hi,shift) @0x08011414` (does codeplug→ASCII math), then read the ready strings from `g_disp` and paint them in your own layout.
- **B (max control):** ignore `g_disp`; read the channel cache `0x20002DEA` + `g_call` directly, format with the helpers, paint with §4.1 primitives.

Both only **read** RAM caches and call display/ADC primitives — the codeplug format and CPS protocol are untouched.

---

## 6. DO-NOT-CHANGE list vs REUSE list — the compatibility contract

### DO NOT CHANGE (format & protocol — CPS-visible)

1. **SPI codeplug region bases & strides:** calibration `0x000000` (4 KB, per-unit, **never write**); settings `0x002000` bank0 + `0x003000` shadow; channels `0x004000`/48; zones `0x01C000`/48; contacts `0x05C000`(read `0x5E000`)/27; group-lists runtime `0x0C6000`/80; enc-key-names `0x0D0000`/48; msgs `0x094000`; fm `0x0D6000`; dtmf-names `0x0C7000`; addressbook `0x126000`/32; fonts `0x19C000`/`0x19E000`; unicode index `0x3F0000`.
2. **Record field layouts:** channel freq = u32 LE `MHz×100000` @ `+0x05`; contact type@+0 / id@+1 LE; 16-B `0xFF`-padded names; enums.
3. **Settings dual-bank + `0xABCD` magic @ offset `0x0C`** (bank0 `0x2000` / shadow `0x3000`). Keep the magic value and offset.
4. **USART6 CPS protocol:** ISR `0x0802061C`, framer `0x0801F864`, top dispatcher `0x08019790`, region R/W handler `0x080188D4`; buffers `0x20002EBE` / `0x200092EF` / `0x20000C5C..64`; opcodes `0x34(/0x10/0x54/0x58/0xEE)`, `0x52` (read 1 KB), `0x40`+`0x90..0xA5` region writes, `0xA4` addressbook; 8-bit-sum-seed-0 checksum.
5. **Superloop CPS branch:** the `else`-branch `0x080213B8..0x0802140A` (`0x08019A7C`, `0x0801F84C`, `0x0801F540`) and the two unconditional serial calls `serial_poll 0x0801F854` + `housekeeping 0x0801FE50`. Keep "PC Programming" mode reachable.

### REUSE (call these; don't re-implement)

- **Display §4.1**, **Input §4.2**, **Radio §4.3**, **DMR §4.4**, **Codeplug §4.5** tables above.
- **Live RAM structs:** settings working copy `0x20002014`, VFO/band `0x20002DBB` (`+1`=active band), live channel cache `0x20002DEA(+band*48)`, incoming DMR call `0x20007DC2`, DMR RX overlay `0x2000A6C5`, key struct `0x20000B57`.

**Contract:** if the rewritten UI (a) mutates only the RAM working structs and commits via the §4.5 save wrappers, and (b) leaves the §6.4/6.5 serial path byte-identical, then the on-SPI bytes remain exactly what stock produces and the stock Radtel CPS round-trips unchanged.

---

## 7. Recommended UI-rewrite architecture

### 7.1 Custom UI layer, injected into free flash

Place a custom UI layer (screen router + custom screens + a small shadow-framebuffer if desired) in **free app flash `0x08029000+`** (avoid the russification cave/font at `0x08028860/0x08028900`). It hooks the main-loop screen/key dispatch and calls the stock SDK (§4).

```
      superloop 0x0802136C
        └─ ui_tick_normal 0x080207DC
             ├─ 0x080207E6  bl ►  my_draw_router   (was bl 0x0801E1BC)
             └─ 0x08020816  bl ►  my_key_router    (was bl 0x0801E6EC)

my_draw_router(): render current custom screen via draw_string/blit_cols/... (§4.1).
                  For not-yet-migrated screens, tail-call stock 0x0801E1BC.
my_key_router():  call keypad_process 0x08005A14 (or read KeyEv 0x20000B57 directly),
                  dispatch to custom screen; consume by writing 0xFF to 0x20000B59.
                  For not-yet-migrated screens, tail-call stock 0x0801E6EC.
```

### 7.2 The patch (2 instructions)

Each site is a Thumb BL (4 bytes). Recompute the BL immediate for the new target:

| site | stock | new |
|---|---|---|
| `0x080207E6` | `bl 0x0801E1BC` | `bl my_draw_router` |
| `0x08020816` | `bl 0x0801E6EC` | `bl my_key_router` |

Encode BL (T1) for target `T` from PC `P=site+4`: `off=(T−P)`; `S=off>>24&1`; `imm10=(off>>12)&0x3FF`; `imm11=(off>>1)&0x7FF`; `J1=~(off>>23)&1 ^ ... ` — use a small assembler/capstone-keystone or the standard `bl` encoder; verify the round-trip disassembles to the intended target before flashing. (Both sites already contain a BL, so only the 4-byte immediate changes.)

### 7.3 Phased plan

1. **Phase 0 — scaffolding & recovery.** Build the injected blob; add `my_draw_router`/`my_key_router` that *tail-call the stock dispatchers unchanged*. Flash; confirm the radio behaves identically (proves the hook + relocation are correct, zero behavior change). Keep the stock `.bin` for recovery.
2. **Phase 1 — replace the standby screen (id 0).** In `my_draw_router`, `if (g_screen==0) my_home(); else stock_draw();` (Strategy A from §5). In `my_key_router`, own id-0 keys (channel up/down via `read_channel` + `radio_apply_channel`, MENU→set `g_screen=10`, PTT via `ptt_tx_start`). Everything else stock.
3. **Phase 2 — replace the menu (id 10).** Own `g_screen==10` draw+key; drive a custom menu model; read/write settings via `0x20002014` + `settings_save @0x08004CB0`. Reuse stock menu-open helper `0x0801AD9C` only if convenient.
4. **Phase 3 — per-screen migration.** Replace ids 2/3/4/5/6/8/9/11 one at a time; each stays behind an `if (g_screen==k)` guard, falling back to stock for the rest. Migrate the secondary `screen_dispatch` sub-mode (§3.4) only if a replaced screen enters it.
5. **Phase 4 — full ownership (optional).** Once all screens are custom, drop the fallbacks; optionally repoint the side-key handler `0x0801E050` and line-buffer builder `0x0801E3F8` too.

### 7.4 Keep it flashable / recoverable

- The custom layer lives **above** the stock image; the two 4-byte patches are the only edits to stock code — trivially revertible.
- **Do not** move `app_main`, the vector table, or the CPS branch. Keep **PC-Programming mode reachable** so a bad UI can still be re-flashed by the stock CPS (or the bootloader `0x39`/`0x57` path) — this is the recovery guarantee.
- Because the codeplug format is untouched, a recovery re-flash of stock firmware finds a valid codeplug and boots normally.
- Test each phase against the stock CPS (read-back + write) to confirm the on-flash format is still byte-identical.

---

## 8. Open questions / lowest-confidence items (resolve on-target)

1. **msg 0x49 semantics** (`0x08006C4C`): denoise vs radio-ID set — the two subsystem docs disagree. Prefer `atc_set_radio_id @0x080074FA` (msg 0x2A) for ID; trace 0x49's caller (channel-settings menu) on-device. (LOW)
2. **`main_settings` handle** `0x20002014` vs `0x200029BB` — confirm which is the 4 KB working copy vs a sub-pointer, and that `settings_save` stages the right one before commit. (MEDIUM)
3. **Non-digit key labels** `0x0B/0x0C/0x0D/0x10` (MENU/UP/DOWN/EXIT) and side-key combo origin (`0x11`/`0x12`) — confirm silk-screen mapping on hardware. (MEDIUM-HIGH)
4. **Secondary `screen_dispatch @0x08018DF4`** (5-entry, `ctx @0x20002121`) reachability — enumerate exactly which stock screens/sub-modes route into it so migration covers them. (MEDIUM)
5. **Group-lists `0xC6000` vs CPS `0x07C000`, enc-key-names `0xD0000` vs `0x082000`, zones 48-B vs 512-B** — firmware and `constants.py` disagree; round-trip with stock CPS before writing these regions; reuse firmware wrappers to stay stock-correct. (MEDIUM — codeplug boundary)
6. **DMR TG/Color-Code standby overlay** render sequence (`g_call @0x2000A6C5`, near `0x08006754`) — only partially traced; confirm when redesigning the RX overlay. (MEDIUM)
7. **Free-flash extent & alignment** — verify `0x08029000+` is erased/available on the actual part and pick a flash-page-aligned base for the injected blob. (LOW — mechanical)
