# RT-4D Firmware — Main Loop, UI State Machine & UI-Rewrite Hook Points

**Key:** `main-loop` · **Target:** `stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin` (ARM Cortex-M4F Thumb, load/vaddr base `0x08002800`).
All disassembly via capstone `CS_ARCH_ARM + CS_MODE_THUMB`.

This is the hook-point document for a full UI rewrite. It nails down (1) the boot→superloop path, (2) the single UI screen-state variable and the **two mirror-image dispatch tables** (draw + input) keyed on it, (3) how screens redraw and transition, and (4) the exact vaddrs and strategy to redirect rendering + navigation into custom code while keeping all lower-level radio/codeplug/serial APIs intact.

---

## 0. TL;DR — the hook

- **Screen-state variable:** `g_screen = *(uint8_t*)0x200008B3` (0..11). A shadow `*(uint8_t*)0x200008B2` holds the previous screen. `0x200008B3` is referenced by **55** literal-pool words across the image — it is the central UI selector.
- **Draw dispatcher:** `ui_draw_dispatch @0x0801E1BC` — `tbb`-jumps on `g_screen` (12 entries) to the current screen's incremental redraw routine. Called every UI tick from the scheduler at `0x080207E6`.
- **Input dispatcher:** `ui_process_key @0x0801E6EC` — pulls a keycode/keystate from the key struct at `0x20000B57`, then `tbb`-jumps on `g_screen` (12 entries, table `@0x0801E780`) to the current screen's key handler `handler(u8 keycode, u8 keystate)`. Called every UI tick from the scheduler at `0x08020816`.
- **Hook strategy (recommended):** patch the two `bl` call sites in the scheduler — `0x080207E6` (`bl ui_draw_dispatch`) and `0x08020816` (`bl ui_process_key`) — to call your own router. Your router owns rendering + navigation and calls the stock lower-level APIs (draw_string, keys, RF, DMR, codeplug). This is a 2-instruction redirect and leaves the serial/CPS path and codeplug format completely untouched. See §5.

---

## 1. Boot → application superloop

### 1.1 Reset / CRT startup (library glue, not app logic)

```
Reset 0x08002AC0: SystemInit(0x0801DA2D) ; then bx __main(0x080029E1)
__main 0x080029E0: bl 0x080029E8 (__scatterload / RW+ZI init, RLE decompressor @0x08002A1E)
                   bl 0x08002AA0 (__rt_entry)
__rt_entry 0x08002AA0: sequence of ARM C-lib inits, then:
                   bl 0x0802136C   <-- the real application main / superloop
```

The `0x08002AA0..0x08002D18` cluster is ARM compiler runtime (heap/stack setup, `bkpt 0xAB` semihosting stubs, the ADC ISR tail). The application proper is **`app_main @0x0802136C`**.

### 1.2 `app_main @0x0802136C` — the top-level superloop  ★ HARD ENTRY POINT

```
0x0802136C  movs r4,#0
0x0802136E  bl 0x0801DAD0                       ; low-level HW/clock/periph bring-up
0x08021372  ldr r0,=0x08002800 ; ldr r1,=0xE000ED08 ; str r0,[r1]  ; VTOR = app vector base
0x08021378  bl 0x08021024                       ; init B
0x0802137C  movs r0,#0xC8 ; bl 0x08007946       ; init C (display/boot?)
0x08021382  bl 0x080127D4                       ; init D
0x08021386  bl 0x08003060                       ; init E (codeplug/settings load)
0x0802138A  b  0x08021414                       ; -> jump to loop top
--- LOOP TOP ---
0x0802138C  if (*(u8*)0x20000C54) bl 0x0801A38C  ; reboot flag -> NVIC_SystemReset
0x08021396  modeA = *(u8*)0x20000B66            ; serial/session mode gate
            modeB = *(u8*)0x20000C12
            spiMode = *(u8*)0x20000C57
            if (modeA==0 && modeB!=2 && modeB!=4 && spiMode==0)
0x080213B2      bl 0x080207DC                    ; ★ NORMAL UI TICK (radio running)
            else  ...                            ; PC-programming / SPI-write session paths:
0x080213C2      modeB==2 -> bl 0x08019A7C        ; CPS mode handler
0x080213E2      modeB==4 -> bl 0x0801F84C        ; serial framer
0x080213EE      modeA!=0 -> bl 0x0801F84C ; bl 0x0801F540 ; drain FM100B rx (0x200082EF, 0x08006CB8)
0x0802140C  bl 0x0801F854                        ; serial poll (every iteration)
0x08021410  bl 0x0801FE50                        ; housekeeping (every iteration)
0x08021414  b  0x0802138C                        ; loop
```

**Interpretation.** The superloop first honours a reboot request, then branches on **serial-session state**: when the radio is *not* in a PC-programming / SPI-write session (`0x20000B66==0`, `0x20000C12∉{2,4}`, `0x20000C57==0`), it runs the **normal UI tick `0x080207DC`**. Otherwise it services the CPS/serial paths. `0x0801F854` (serial poll) and `0x0801FE50` (housekeeping) run unconditionally every pass.

> **Boundary note (respect the CPS/codeplug contract).** The serial session vars `0x20000B66 / 0x20000C12 / 0x20000C57 / 0x20000C54` and the handlers `0x08019A7C / 0x0801F84C / 0x0801F540 / 0x0801F854` are the **CPS protocol + SPI region-write engine** (documented in RT-4D_RE_Report §4). A UI rewrite must leave this entire `else` branch and the two unconditional serial calls **exactly as-is** — that is the serial/CPS boundary. Only replace what happens *inside* the normal UI tick `0x080207DC`.

| vaddr | name | signature | notes |
|---|---|---|---|
| 0x0802136C | `app_main` | `void app_main(void) __attribute__((noreturn))` | The application superloop. Sets VTOR, runs inits, then loops. **Do not relocate** — reset path branches here. |
| 0x0801DAD0 | `hw_init` | `void hw_init(void)` | clock/peripheral bring-up (called first) |
| 0x08003060 | `codeplug_load_init` | `void(void)` | last init; loads settings/channels from SPI (candidate) |
| 0x0801A38C | `nvic_system_reset` | `void(void) noreturn` | writes AIRCR `0x05FA0004` (reboot). Gated by `0x20000C54`. |
| 0x080207DC | `ui_tick_normal` | `void(void)` | **the normal-mode UI scheduler** (see §2) |
| 0x0801F854 | `serial_poll` | `void(void)` | runs every loop; part of CPS path — keep |
| 0x0801FE50 | `housekeeping` | `void(void)` | runs every loop (battery/timers) |

---

## 2. `ui_tick_normal @0x080207DC` — the cooperative UI scheduler

This is **not** the state machine itself; it is a fixed list of periodic subsystems, several gated by down-counters so they run at different rates. The screen draw + key dispatch are two of its calls.

```
0x080207DE  bl 0x0801F84C   ; serial framer (shared)
0x080207E2  bl 0x0801F9C0   ; ? (reads 0x20000C8A menu-key state, 0x200029BB struct)
0x080207E6  bl 0x0801E1BC   ; ★ ui_draw_dispatch  (SCREEN DRAW — hook here)
0x080207EA  bl 0x0801E050   ; ui_sidekey_dispatch (side/long-press hotkeys — see §3.3)
0x080207EE  bl 0x0801E3F8   ; build display line buffer (reads 0x200008B3)
0x080207F2  bl 0x0801FAC8   ; ...
0x080207F6  bl 0x0801F504
0x080207FA  bl 0x0801FDA4
0x080207FE  bl 0x0801FDDC
0x08020802  bl 0x0801FE0C
0x08020806  bl 0x0801DFD0
--- period-gated tasks: counter at 0x20000BF1.. reloads to a period when it hits 0 ---
0x0802080A  if(--tick@0x20000BF1==0){reload 1;   bl 0x0801E6EC(key?) ...}   see note
0x08020816  bl 0x0801E6EC   ; ★ ui_process_key   (SCREEN INPUT — hook here)
0x0802081A  bl 0x08020288
   ... 0x20000BF2 (reload 4), 0x20000BF3 (reload 0xA), 0x20000BF5 (0xC8),
       0x20000BF6 (0x1F4), 0x20000BF8 (0x3E8): slower periodic tasks (RSSI, battery, scan, etc.)
0x080208DC  return
```

Concretely, the **two dispatch calls you care about** are both unconditional every UI pass:
- `0x080207E6  bl 0x0801E1BC`  → screen **draw** dispatch
- `0x08020816  bl 0x0801E6EC`  → screen **key** dispatch

(Down-counter reloads observed: `0x20000BF1`→1, `0x20000BF2`→4, `0x20000BF3`→0xA, `0x20000BF5`→0xC8, `0x20000BF6`→0x1F4, `0x20000BF8`→0x3E8 — these throttle the slower periodic tasks; the two dispatchers themselves are not throttled.)

---

## 3. The UI state machine

### 3.1 The state variable

```
g_screen  = *(uint8_t*)0x200008B3   ; current screen id, 0..11 (0x0B)
g_screen_prev = *(uint8_t*)0x200008B2   ; previous screen (used for restore/back)
```

Both dispatchers guard with `cmp g_screen,#0x0C ; bhs <default>` before the `tbb`, so **valid ids are 0..11**.

### 3.2 The two mirror dispatch tables (draw + input)

Both are `tbb [pc,r0]` byte-offset jump tables indexed by `g_screen`. They are **positionally parallel**: index *i* in the draw table and index *i* in the input table are the same screen.

**DRAW dispatcher** `ui_draw_dispatch @0x0801E1BC`:
```
0x0801E1BE  bl 0x080136E4                 ; clear per-field dirty flags (row @0x20002512)
0x0801E1C2  r0 = *(u8*)0x200008B3
0x0801E1C6  cmp r0,#0x0C ; bhs 0x0801E228 (default/no-op)
0x0801E1CA  tbb [pc,r0]                   ; table @0x0801E1CE
```

**INPUT dispatcher** `ui_process_key @0x0801E6EC` (tbb inside at `0x0801E780`):
```
0x0801E6EE  bl 0x08005A14                 ; keypad scan/debounce
0x0801E6F4  if(*(u8*)0x20000B5A==0) return ; keystate (0x20000B57+3) == no-key -> bail
0x0801E6FC  if(*(u8*)0x20000B59==0xFF) return ; keycode (0x20000B57+2) == none -> bail
            ... global lock / special-mode guards ...
0x0801E778  r0 = *(u8*)0x200008B3
0x0801E77C  cmp r0,#0x0C ; bhs 0x0801E804 (default)
0x0801E780  tbb [pc,r0]                   ; table @0x0801E784
   each case: r2=0x20000B57; r1=[r2,#3](keystate); r0=[r2,#2](keycode); bl <handler>
0x0801E806  after dispatch: *(u8*)(0x20000B57+2) = 0xFF  ; consume keycode
```

The **key event struct** is at `0x20000B57`: byte +2 = keycode, byte +3 = keystate/edge (1 = press/repeat). Handlers receive `(r0=keycode, r1=keystate)`.

### 3.3 Screen table — id → {draw handler, input handler}

Verified by decoding both `tbb` tables (bytes are half-word offsets from the table base):

| id | draw handler | input handler | inferred screen | evidence |
|---:|---|---|---|---|
| 0 | `0x08013BD8` | `0x08017F60` | **Home / VFO-A main** (default landing) | most `strb g_screen,#0` "return home" sites; `0x08013BD8` reads big status struct `0x200009C3+0x42` |
| 1 | *(default no-op `0x0801E228` / input `0x0801E804`)* | — | **blank / transient** | table byte 0x2D→no-op; input 0x40→no-op |
| 2 | `0x08013980` | `0x08007B10` | **VFO/standby key screen** | `0x08007B10` handles MENU key (10) → sets g_screen=0xA (menu entry, §3.4); EXIT/side keys |
| 3 | `0x08013A44` | `0x0800FE3C` | **screen w/ icon @`0x0802505C`** (freq-input / dial) | draws bitmap via `0x08008D62`; input checks `0x20000855` |
| 4 | `0x08014978` | `0x0802074C` | **screen w/ icon @`0x08025115`** | bitmap blit; input digit `0xC` handling |
| 5 | `0x08013940` | `0x08007784` | **numeric entry A** (0–F) | input: `cmp r4,#0xF; bl 0x08012EF8` (hex digit) |
| 6 | `0x080139EC` | `0x08007CD0` | **numeric entry B** (0–9) | input: `cmp r4,#9; bl 0x08012F78` (dec digit) |
| 7 | `0x08013804` | `0x08007E68`→`0x08017F60` | **alt of screen 0** | input forwards to id-0 handler `0x08017F60` |
| 8 | `0x08013B6C` | `0x08007D…`/via `0x0801E7E4→0x0800F930` | **list/scroll screen** | draw calls `0x080085D6`; several handlers special-case `g_screen==8` (`0x0800F930`) |
| 9 | `0x08013F7C` | *(input tbl byte 0x33→no-op region `0x0801E7EA`)* | **status/info screen** | draw reads `0x20000A3D+0x39`, `0x20000A7B` |
| **10 (0xA)** | **`0x080142C0`** | **`0x08017294`** | **MENU system** ★ | draw = the menu render loop (facts §Display); input = full menu key handler (largest, `sub sp,#0x64`) |
| 11 (0xB) | `0x08014104` | `0x0801D3B0` | **SMS / editor** (text) | draw blits chars at fixed cols; input handles 0–9 (`cmp r4,#9`) |

Notes:
- **Standby "home" screen (id 0/7)** is the radio's idle/operating screen (frequency, channel, RX/TX, DMR-rx overlay). The full home rendering also runs through the period-gated tasks and `0x0801E3F8` (line-buffer builder) — the dispatched draw handler `0x08013BD8` does the incremental field redraw.
- **id 1** is a genuine no-op slot (both tables route it to the shared "do nothing" tail). Treat as "transition/blank".
- All input handlers share the uniform prototype **`void screen_key(uint8_t keycode, uint8_t keystate)`**.

### 3.4 How transitions happen (state writes)

Screen changes are plain byte stores `strb rN, [=0x200008B3]`. Enumerated immediate-write sites (subset; `movs rN,#imm` immediately before the `strb`):

| new id | example write sites | meaning |
|---:|---|---|
| 0 | `0x08003490, 0x080094AA, 0x0800AA70, 0x0800AE4C, 0x0800B200, 0x0801E120, 0x0801F06C, 0x0801F81E, …` (many) | return to Home / EXIT |
| 1 | `0x080094AE, 0x080095DC, 0x08009760, 0x0801E5DA, 0x0801F698, …` | enter transient/blank |
| 2 | `0x0801A2BE` | enter VFO/standby key screen |
| 3 | `0x0800B112, 0x08018344, 0x0801A2DE` | enter freq-input/dial |
| 4 | `0x0800BC58, 0x0801E15A` | enter screen 4 |
| 5 | `0x08009842` | enter numeric-entry A |
| 7 | `0x0801A66C` | enter alt-home |
| 8 | `0x0800AF0A` | enter list/scroll |
| 9 | `0x080094D2` | enter status/info |
| **10 (0xA)** | **`0x0800B92A`** (from Home MENU key, §below) | **enter MENU** |
| 11 (0xB) | `0x0800B85C` | enter SMS/editor |
| 0x11 | `0x080094A4` | (id 17 — value out of 0..11 range; likely a sub-mode byte, not a screen; used by a specialized editor) |
| 0x2A | `0x08009A9C` | (id 42 — same: sub-mode marker, not a screen dispatch id) |

**Canonical transition example — Home → Menu** (in the id-2 standby key handler `0x08007B10`, MENU key path lands in `0x0800B900`):
```
0x0800B91C  bl 0x0801AD9C            ; menu-open side effects (build top-level list)
0x0800B926  movs r0,#0xA
0x0800B928  ldr r1,=0x200008B3
0x0800B92A  strb r0,[r1]             ; g_screen = 10 (MENU)
```
So **navigation = write the target id to `0x200008B3`** (optionally saving the old value to `0x200008B2` for "back"), plus per-screen enter side-effects. A custom router replicates exactly this.

---

## 4. How a screen is (re)drawn each loop

The draw model is **incremental / dirty-flag driven**, not full-frame:

1. `ui_draw_dispatch @0x0801E1BC` first calls `0x080136E4`, which walks a dirty-flag row at `0x20002512` (loop of 8+) and force-marks fields dirty on screen entry.
2. It then `tbb`-dispatches to the current screen's redraw routine (§3.3). Each routine reads its per-field "changed?" bytes (e.g. `0x200008F5`, `0x20000914`, `0x20000A84`) and only when set calls the text/glyph primitives:
   - `draw_string @0x08008A50` — `void draw_string(u8 y_page /*r0*/, u8 x /*r1*/, const char* s /*r2*/, u8 len /*r3*/, u8 mode /*[sp+0x28]*/)`; mode 0=normal,1=inverse,2=outline. (Confirmed head: `mov sl,r0; mov r7,r1; mov r5,r2; mov fp,r3`.)
   - `0x08008D62` — bitmap/icon blitter (`draw_bitmap(mode,x,y,const u8* bmp)`)
   - `0x080089AC`, `0x080085D6`, `0x08008798` — box/line/clear helpers.
3. The framebuffer is flushed to the LCD over SPI2 by the period-gated tasks; the ASCII glyph blitter `0x08007FB8` and wide/GBK blitter `0x08008454` pull font bitmaps from SPI flash (per facts).

**Consequence for a rewrite:** because draw is gated behind dirty flags and a `tbb` on `g_screen`, replacing the dispatched routine (or the dispatcher call) cleanly takes over rendering for that screen without fighting the stock partial-redraw logic — as long as your code marks the whole area dirty / clears+redraws each frame itself.

---

## 5. Recommended HOOK POINTS for a custom UI router

Goal: our code owns **rendering + navigation + which key does what**, while calling stock lower-level APIs (draw_string `0x08008A50`, keypad `0x08005A14`, RF/DMR/codeplug helpers, and — untouched — the serial/CPS engine). Three options, most-preferred first.

### Option A (recommended): redirect the two scheduler dispatch calls
Patch the two `bl` instructions in `ui_tick_normal`:

| patch site | stock instr | change to |
|---|---|---|
| **`0x080207E6`** | `bl 0x0801E1BC` (ui_draw_dispatch) | `bl my_draw_router` |
| **`0x08020816`** | `bl 0x0801E6EC` (ui_process_key) | `bl my_key_router` |

- Your `my_key_router` reads the same key struct at `0x20000B57` (+2 keycode, +3 keystate) — call stock `0x08005A14` first if you want the stock debounce, or read raw. After handling, write `0xFF` to `0x20000B57+2` to consume, exactly as stock does at `0x0801E806`.
- Your `my_draw_router` renders via `draw_string @0x08008A50` and the blit/clear helpers, keyed on your own screen model. You may keep or ignore `g_screen@0x200008B3`.
- **Everything else in the superloop and scheduler is preserved**, so RF, DMR RX, scan, battery, and the entire serial/CPS + SPI region-write path (`0x080213B8` else-branch, `0x0801F854`, `0x0801FE50`) keep working unchanged. This is the minimal, cleanest cut: **2 instructions**.
- Keep `0x080207EA bl 0x0801E050` (side-key/long-press handler) if you still want stock side-key semantics, or repoint it too for full control.

### Option B: replace the two `tbb` jump tables (per-screen, incremental)
Repoint individual entries in the draw table (`@0x0801E1CE`, 12 bytes) and input table (`@0x0801E784`, 12 bytes) to your own handlers, one screen at a time. Because entries are **1-byte half-word offsets from the table base**, a target must be within `+0..+0x1FE` of the base; to jump far, keep a stock case as a 2-instruction trampoline (`b.w my_handler`) inside range. This lets you migrate screens gradually while stock screens still work. More fiddly than Option A.

### Option C: own the screen id + provide new handlers
Keep the dispatchers, but treat `0x200008B3` as your state var and point all 12 draw/input slots at your dispatch trampolines. Effectively Option B for all 12 at once; no advantage over Option A.

### Lower-level APIs to reuse (stable call targets)
| purpose | vaddr | prototype |
|---|---|---|
| draw text | `0x08008A50` | `draw_string(u8 y_page, u8 x, const char* s, u8 len, u8 mode@sp+0x28)` |
| draw icon/bitmap | `0x08008D62` | `draw_bitmap(u8 mode, u8 x, u8 y, const u8* bmp)` |
| ascii glyph blit | `0x08007FB8` | (per facts; SPI font @0x19C000) |
| wide/GBK glyph blit | `0x08008454` | (per facts; SPI font @0x19E000) |
| keypad scan/debounce | `0x08005A14` | `void keypad_scan(void)` (fills `0x20000B57`) |
| SPI codeplug read | `0x08021828` | `spi_read(dst, byteaddr, len)` — **keep format** |
| menu-open helper | `0x0801AD9C` | builds top-level menu list (call if reusing stock menu data) |
| reboot | `0x0801A38C` | `nvic_system_reset()` |

**Do NOT touch** (serial/CPS + codeplug boundary — hard constraint): the superloop else-branch `0x080213B8..0x0802140A`, `serial_poll 0x0801F854`, the framer/dispatcher `0x0801F84C / 0x08019790 / 0x080188D4`, and the SPI region-write engine. These implement the stock CPS protocol and the on-flash codeplug format; leaving them byte-identical is what keeps the stock Radtel CPS working.

---

## 6. Key RAM state variables (UI)

| addr | width | name | role |
|---|---|---|---|
| `0x200008B3` | u8 | `g_screen` | current UI screen id (0..11) — **the state machine selector** |
| `0x200008B2` | u8 | `g_screen_prev` | previous screen (back/restore) |
| `0x20000B57` | struct | `g_key` | key event: +2 keycode, +3 keystate(1=press) |
| `0x20000C3D` | u8 | `g_sidekey` | side/long-press keycode consumed by `0x0801E050` |
| `0x20000BF1..BF8` | u8/u16 | scheduler down-counters | throttle slow periodic tasks in `ui_tick_normal` |
| `0x20002512` | u8[8+] | dirty-flag row | per-field "needs redraw" flags (cleared by `0x080136E4`) |
| `0x20000C54` | u8 | reboot request | superloop → `nvic_system_reset` |
| `0x20000B66 / 0x20000C12 / 0x20000C57` | u8 | serial-session mode gates | select UI vs CPS/SPI-write path (**do not repurpose**) |

---

## 7. Confidence

- **HIGH** — superloop (`0x0802136C`), normal-UI scheduler (`0x080207DC`), the single screen-state var `0x200008B3`, and the two mirror `tbb` dispatchers (draw `0x0801E1BC`, input `0x0801E6EC`) with their 12-entry tables. All directly disassembled and cross-checked (55 xrefs to the state var; both tbb tables decoded; the Home→Menu transition traced end-to-end).
- **HIGH** — the recommended hook (patch `bl` at `0x080207E6` and `0x08020816`); both call sites verified in the scheduler disassembly, and the serial/CPS boundary is cleanly separated in the superloop.
- **MEDIUM** — the *English names* assigned to screen ids 3/4/5/6/8/9/11: the dispatch structure and handler vaddrs are certain, but exact screen semantics are inferred from handler behaviour (digit ranges, bitmap vs text, forwarding) rather than from a label string on each. ids 0/7 (Home), 2 (standby-key), 10 (Menu), 11 (SMS/editor) are well-anchored.
