RT-4D: реверс прошивки, русификация, кастомный UI, флешеры

- Полный RE стока V3.25 (Cortex-M4F) + FM100B: карта памяти, протокол, codeplug, UI-архитектура
- Русификация: свой CP1251-шрифт + патч рендера, перевод меню и надписей, ребренд Ru-4D V3.25
- Блюпринт переделки UI + C-тулчейн (clang thumbv7em), доказан инъекцией
- Готовые флешеры: WebSerial .html и Windows .exe со вшитой прошивкой
- Дамп SPI рации, стоковая прошивка, инструменты сборки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Этот коммит содержится в:
2026-07-08 15:47:22 +09:00
co-authored by Claude Opus 4.8
Коммит ae36c3b729
72 изменённых файлов: 24124 добавлений и 0 удалений
+807
Просмотреть файл
@@ -0,0 +1,807 @@
# Radtel RT-4D — Firmware Reverse-Engineering Report
## Executive summary
- **What it is.** The Radtel RT-4D is a dual-band DMR handheld radio running application firmware version **RT-4D V3.25** (build date `DATE:2026-02-05`). It supports DMR digital voice/data (Tier II, time-slots, color codes, talkgroups, SMS, encryption) plus wideband analog RX including **FM / AM / SSB** demodulation.
- **Two processors.** (A) A **main MCU** — ARM Cortex-M4F (Thumb, STM32F4-compatible register map), FPU enabled, flash base `0x08000000`, bootloader `0x08000000..0x08002800`, application at `0x08002800`. (B) A dedicated **FM100B DMR baseband SoC** — a classic **ARM (ARM-mode, ARMv4/v5, ARM7/9-class)** processor with an 8-entry IRQ/FIQ vector table, running a POSIX-style RTOS with an embedded WebRTC DSP + AMBE vocoder. The two talk over an internal UART.
- **Likely chips.** The MCU is definitively **not a genuine ST part** (it writes RCC registers ST leaves Reserved); it is an **STM32F407-class Cortex-M4F clone, most likely Artery AT32F407/AT32F403A** (ranked candidates below). The FM100B is an MMU-less baseband ARM core (no CP15 anywhere in 1.46 MB).
- **What we extracted.** Full MCU memory map + peripheral census; complete interrupt/vector map with per-ISR peripheral identification; the entire on-device menu tree (from a fixed-width blob at `0x080253ED`); the PC serial/flashing protocol (opcodes `0x34`/`0x52`/region-writes, plus the bootloader `0x39` protocol); the FM100B Req/Cnf/Ind message interface (~129 symbols); and a fully annotated 4 MB SPI data-flash map from a live radio dump.
- **Key opportunities.** Well-defined SPI region write protocol for codeplug modding; a documented "Update DMR Chip" path to reflash the FM100B baseband; rich DMR remote-command (stun/kill/wake/monitor) and encryption code reachable from named string anchors; extended SPI write opcodes (`0x9C..0xA5`) and an internal `0xABCD` magic-gated handler that the public CPS tools do not touch.
- **Key risks.** The **4 KB calibration block at SPI `0x000000` is per-unit factory data and irreplaceable** — any errant region write or full erase destroys it. The MCU's true silicon vendor is inferred from the register map, not read from an IDCODE, so an SVD/debug setup must be validated on-target. The bootloader image is not in the app binary, so internal-flash reflashing is only understood at the protocol level.
## Provenance
The MCU images were obtained from the **official RT-4D firmware upgrade package** (release `20260205`): the vendor RAR was unpacked to a ZIP containing a .NET updater ("Ido_Update"); inside that updater the application firmware is carried as an **Intel-HEX string stored in the managed (`#US`) string heap**, which was decoded into an absolute-addressed binary. This yields `rt4d_stock_v3.25_abs_0x08002800.bin` (155,740 bytes, load base `0x08002800`) and the equivalent absolute `rt4d_stock_v3.25.ihex` (base `0x08000000`). The **FM100B DMR baseband** image (`FM100B_V1.2.0.32_20260130.bin`, 1,527,808 bytes, ARM base `0x00000000`) ships in the separate "DMR Upgrade Tool 260204" package. Independently, a **live 4 MB SPI data-flash dump** (`radio-spi-dump.bin`) was read directly off a physical radio; it is data (calibration + codeplug + font/DSP asset ROMs), not code, and is cross-referenced throughout against the community CPS constants in `rt4d-cps/rt4d_codeplug/constants.py`.
## 1. MCU Identification & Memory Map
### 1.1 Reset & startup path (from vaddr 0x08002AC0)
The application vector table (`0x08002800`) begins `SP=0x2000AE48`, `Reset=0x08002AC1`. The reset handler is a minimal CMSIS-style stub:
```
0x08002ac0 ldr r0,[pc,#0x24] ; r0 = 0x0801DA2D (SystemInit, thumb)
0x08002ac2 blx r0
0x08002ac4 ldr r0,[pc,#0x24] ; r0 = 0x080029E1 (__main / app entry)
0x08002ac6 bx r0
```
Literals resolved: `lit@0x08002AE8 = 0x0801DA2D` (SystemInit), `lit@0x08002AEC = 0x080029E1` (main).
**SystemInit @ 0x0801DA2C** — fully decoded from its literal pool (`0x0801DAC0..0x0801DACC`):
| Insn | Target | Op | Meaning |
|---|---|---|---|
| 0x0801DA2E | **CPACR 0xE000ED88** | `r|=0x00F00000` | Enable FPU CP10/CP11 full access → **Cortex-M4F confirmed** |
| 0x0801DA40 | **RCC_CR 0x40023800+0x00** | set bit0 | HSION |
| 0x0801DA50 | RCC_CR | wait `(CR>>1)&1` | wait HSIRDY |
| 0x0801DA5C | **RCC_CFGR +0x08** | `&=~3` | SW=HSI |
| 0x0801DA6E | RCC_CFGR | wait `(CFGR>>2)&3==0` | wait SWS=HSI |
| 0x0801DA7C | RCC_CR | `&=0xFEF2FFFF` | clear HSEON(16), HSEBYP(18), CSSON(19), PLLON(24) |
| 0x0801DA88 | **RCC_CFGR +0x08** | `=0x40000000` | reset CFGR (MCO2=SYSCLK) |
| 0x0801DA92 | **RCC_PLLCFGR +0x04** | `=0x00033002` | PLL reset value |
| 0x0801DA98 | **RCC +0xA0 (0x400238A0)** | `=0x000F0000` | *non-ST extended RCC register* |
| 0x0801DAA2 | **RCC_CIR +0x0C** | `=0x009F0000` | clear all clock IRQ flags |
| 0x0801DAB6 | **SCB_VTOR 0xE000ED08** | `=0x08000000` | vector table base |
The actual PLL/HSE bring-up lives in a separate HAL-style driver (`~0x0801DB20`): it enables HSE, programs PLL via helpers, spins on lock, sets AHB/APB prescalers through **RCC_CFGR bitfield helpers** at `0x08020B90` (`bfi …,#0,#0xC` = HPRE/PPRE fields), and switches `SW=PLL` (`r0=2`) waiting `SWS==2`. **FLASH_ACR 0x40023C00** is referenced once (`lit@0x0801DB70`) inside this driver for wait-state latency — canonical STM32F4 FLASH interface base.
### 1.2 Peripheral literal census (0x40000000–0x5009FFFF, 0xE0000000–0xE00FFFFF)
Word-aligned literal-pool constants matching the STM32F4 base grid (counts are literal occurrences):
| Base | Peripheral | Notes |
|---|---|---|
| 0x40003800 | SPI2/I2S2 | canonical |
| 0x40004400 / 0x40004800 | USART2 / USART3 | canonical |
| 0x40007000 | PWR | canonical |
| 0x40007400 (+0x10) | DAC | ch1/ch2 DHR — canonical |
| 0x40010000 | USART1 (×9) | canonical |
| 0x40011400 | USART6 (×6) | canonical (used for PC programming link, §4) |
| 0x40012000 | ADC1 | canonical |
| 0x40014000 | TIM9 | canonical |
| 0x40020000 | **GPIOA (×42)** | canonical |
| 0x40020400 / 0x40020800 / 0x40021400 | GPIOB / GPIOC / GPIOF | canonical AHB1 GPIO stride 0x400 |
| 0x40023000 | CRC | canonical |
| **0x40023800** | **RCC** (×14) | see §1.3 |
| 0x40023C00 | FLASH interface | canonical |
| 0x40026000 / 0x40026400 | DMA1 / DMA2 | canonical |
| 0xE000ED88 | CPACR (FPU) | Cortex-M4F |
| 0xE000ED08 | SCB_VTOR | ARMv7-M |
| **0xE0042000** | **DBGMCU** (×3 literals) | see §1.4 |
| 0xE0000004 / 0xE0001101 | ITM / DWT | ARMv7-M debug |
**Absent (significant):** no USB-OTG (0x50000000 / 0x40040000), no RNG (0x50060000), no Ethernet, no I2C base literals — the radio uses UARTs, SPI2, GPIO, ADC1, DAC, CRC, and DMA only.
### 1.3 Register-map fingerprint — the decisive evidence
Genuine STM32F407 RCC registers end at **PLLI2SCFGR = 0x40023884**. The firmware repeatedly reads/writes three RCC offsets that **do not exist on a genuine STM32F407**:
- **RCC+0xA0 (0x400238A0)** — written `0x000F0000` in SystemInit; loaded at 4 further sites (0x08020BAC, 0x08020DA0, 0x08020E7C, 0x08021BC4).
- **RCC+0xA4 (0x400238A4)** — 0x08020C20.
- **RCC+0x68 (0x40023868)** — 0x08020F70 (`str r1,[r0]` writer helper).
These extended clock-tree registers in the 0x68/0xA0/0xA4 window, combined with a fully STM32F4-identical GPIO/USART/SPI/DMA/ADC/CRC/RCC-core layout, are the classic signature of an **STM32F407-compatible clone**, most consistent with **Artery AT32F403A/AT32F407** (Artery's "CRM" block places additional MISC/PLL registers in exactly this 0x90–0xB0 range, while keeping RCC core offsets 0x00/0x04/0x08/0x0C/0x40/0x44 bit-identical to ST). The core clock helpers at 0x08020B90–0x08020C0E manipulate CFGR HPRE (bits 4-7), PPRE1 (10-12), PPRE2 (13-15) exactly as ST — so the clone is register-compatible on the documented registers and merely *adds* vendor registers.
### 1.4 Device-ID / UID / signature checks
- **DBGMCU 0xE0042000** appears as 3 literal-pool words (0x08012A9C/AB0/AC4). This is the STM32-family DBGMCU base (present on genuine ST *and* all F407 clones). It is used for debug-freeze/config, **not** as an IDCODE dispatch — there is **no** compare of an IDCODE value against a device table near a UID read.
- **No unique-ID base is referenced anywhere:** neither STM32 UID `0x1FFF7A10` / flash-size `0x1FFF7A22`, nor Artery/APM32/F1-style `0x1FFFF7E8`. A full aligned scan of the 0x1FFF0000–0x1FFFFFFF system-memory range returned **zero** literals. The firmware never reads a chip UID, so software does not self-identify the die.
- **No vendor ASCII strings** (`artery`, `at32`, `geehy`, `apm32`, `gd32`, `stm32`) exist in the image; the only version string is `VER :RT-4D V3.25` @ 0x0800BA88.
### 1.5 Ranked candidate list (adversarial)
| Rank | Candidate | Confidence | Evidence for | Evidence against |
|---|---|---|---|---|
| 1 | **Artery AT32F407 / AT32F403A** | **~55%** | Extended RCC/CRM registers at +0x68/+0xA0/+0xA4 that genuine STM32F407 lacks; ST-identical core RCC/GPIO/USART/DMA offsets; M4F; these radios are widely known to use Artery clones. | Cannot see a UID/IDCODE self-check to prove the die; the extended registers are inferred-as-Artery from map position, not read back from a datasheet-matched value. |
| 2 | **GigaDevice GD32F407 / GD32F303** | ~18% | Also STM32F4-map-compatible clone with extra RCC bits; M4-class; common in Chinese radios. | GD32's extra RCC registers cluster differently (e.g. ADDCTL at 0xCC/0xC8), not cleanly at 0xA0/0xA4. |
| 3 | **Geehy APM32F407** | ~12% | STM32F407 drop-in clone, M4F, same peripheral map. | APM32 tracks ST's RCC map closely and does not add registers exactly at +0xA0/+0xA4; less likely. |
| 4 | **Genuine STM32F407** | ~10% | Every documented register offset matches ST exactly; SP/SRAM/flash all in ST-legal ranges. | **Writes to RCC+0xA0/+0xA4/+0x68 which are Reserved on genuine STM32F407** — a stock ST part would ignore these; their deliberate, repeated use argues the silicon actually implements them → argues *against* genuine ST. |
| 5 | F405/F103-class | <5% | — | Ruled out: FPU/CP10-11 enabled (M4F, not M3 → not F103); 0x40011400/DAC/GPIOF present and 128KB SRAM span → F407-class not F405-minimal. |
**Adversarial note on the top pick:** the AT32 call is a map-position inference, not a hard read. The only *proven* facts are (a) M4F, (b) STM32F4 register-map compatible on all standard peripherals, and (c) it drives three RCC registers that genuine STM32F407 does not define. Any of the three named clones would satisfy (a)–(c); AT32 is favored on prior-art (Radtel/other Chinese DMR radios shipping Artery parts) plus the specific 0xA0/0xA4 placement, but the die cannot be excluded (GD32/APM32) without a live IDCODE read from the chip. The safest defensible statement: **"STM32F407-class ARM Cortex-M4F clone, most likely Artery AT32F407, definitively not a genuine ST part given the extended RCC writes."**
### 1.6 Memory map
| Region | Range | Size | Evidence |
|---|---|---|---|
| Bootloader (flash) | 0x08000000 – 0x080027FF | 10 KB | Given; app vaddr base 0x08002800; SystemInit sets VTOR=0x08000000 |
| Application (flash) | 0x08002800 – 0x08028A9C | 155,740 B (~152 KB) | `rt4d_stock_v3.25_abs_0x08002800.bin` size; flash literals reach 0x08021xxx |
| Flash device total | 0x08000000 – 0x0803FFFF (min) | ≥256 KB (F407 class) | Highest densely-used 64K page 0x08020000 (×147); sparse hits to 0x080C0000 are data-table/false, not code |
| Main SRAM | 0x20000000 – 0x2001FFFF | **128 KB contiguous** | SRAM literals span up to 0x2001Fxxx; initial `SP=0x2000AE48` (top-of-stack ~43 KB into SRAM) |
| CCM SRAM (0x10000000) | — | not confirmed | `0x1000exxx` literals are misaligned/odd → Thumb immediates, not data pointers; no confirmed CCM data use |
| DBGMCU | 0xE0042000 | — | debug config |
| Cortex-M4 SCS | 0xE000E000 (VTOR 0xED08, CPACR 0xED88), ITM 0xE0000000, DWT 0xE0001000 | — | ARMv7-M private peripheral bus |
**Peripheral bases in use:** RCC 0x40023800 (+extended 0x68/0xA0/0xA4), FLASH-IF 0x40023C00, PWR 0x40007000, CRC 0x40023000, GPIOA/B/C/F (0x40020000/0400/0800, 0x40021400), USART1 0x40010000, USART2 0x40004400, USART3 0x40004800, USART6 0x40011400, SPI2 0x40003800, ADC1 0x40012000, DAC 0x40007400, TIM9 0x40014000, DMA1 0x40026000, DMA2 0x40026400.
**Clock summary:** M4F @ FPU-enabled; HSI → HSE → PLL bring-up (`PLLCFGR` staged from reset `0x00033002`), SYSCLK switched to PLL (`SW=2`) with FLASH wait-states set via `0x40023C00`; bus dividers programmed through RCC_CFGR HPRE/PPRE1/PPRE2 bitfields.
## 2. Vector Table & Interrupt Map
The application vector table sits at the app base **0x08002800** (the reset vector reprograms `SCB->VTOR` to this after the bootloader hands off). Word0 = initial SP **0x2000AE48**, word1 = Reset **0x08002AC1**. Every entry is an odd (Thumb) address, confirming a Cortex-M image.
### 2.1 Shared stub handlers
Two "do-nothing" targets dominate the table, and both are tight infinite loops (`B .`):
```
0x08002AD8: b #0x08002AD8 ; NMI / HardFault / SysTick vector target (word 0x08002AD9)
0x08002ADA: b #0x08002ADA ; generic default IRQ target (word 0x08002ADB)
```
- **0x08002AD9** is the target of the NMI, HardFault, and **SysTick** slots (exceptions #2, #3, #15). SysTick is therefore *not* used — there is no tick ISR; timing is handled by a TIM (see below).
- **0x08002ADB** is the shared `Default_Handler` wired into every unused external IRQ slot.
Slots that are literally `0x00000000` (words 7–10, 13, and the reserved Cortex-M slots) are the architecturally-reserved gaps and unused vendor IRQs.
### 2.2 System exception vectors (#0–#15)
| # | Exception | Handler | Used? |
|---|-----------|---------|-------|
| 0 | Initial SP | 0x2000AE48 | — |
| 1 | Reset | 0x08002AC1 | unique |
| 2 | NMI | 0x08002AD9 | stub loop |
| 3 | HardFault | 0x08002AD9 | stub loop |
| 4 | MemManage | 0x08002AC1* | (word 0x08002AC1 pattern reused) |
| 5–6 | Bus/UsageFault | 0x080127D1 / 0x08014E1D | unique |
| 7–10 | Reserved | 0x00000000 | — |
| 11 | SVCall | 0x0801A845 | unique |
| 12 | DebugMon | 0x08007869 | unique |
| 13 | Reserved | 0x00000000 | — |
| 14 | PendSV | 0x08018C1D | unique |
| 15 | SysTick | 0x08002AD9 | **stub loop (unused)** |
SVCall/PendSV being unique but SysTick being a stub is the classic signature of a bare-metal cooperative loop with hardware-timer scheduling, or an RTOS that drives the scheduler from a TIM rather than SysTick.
### 2.3 External IRQ map (#16 = table word 16, off 0x40)
Non-default (unique) handlers, with the peripheral identified from the base address loaded in each ISR's literal pool:
| IRQ# | STM32F4 name | Handler | Used? | Evidence / notes |
|------|--------------|---------|-------|------------------|
| 0 | WWDG | 0x08002AD9 | — | (word16 = 0x08002AD9 stub) |
| 11 | EXTI / DMA1_Stream0-ish region | 0x080076E5 | ✅ unique | small flag-setter |
| 18 | **ADC** | 0x08002D1D | ✅ unique | literal `0x40012000` = ADC1 base; reads conversion, increments a counter |
| 25 | **TIM1_UP / TIM10** | 0x0801DBB1 | ✅ unique | literal `0x40010000`; large handler, drives display/keypad scan bytes at 0x2000BF1.. |
| 28 | **TIM2** | 0x0801DDCD | ✅ unique | literal `0x40007410` region + SRAM state; timing/tick replacement for SysTick |
| 39 | **USART3** | 0x080205B1 | ✅ unique | literal `0x40004800` = USART3 base — RX ISR, ring-buffer push (internal FM100B link, §4) |
| 71 | **USART6** | 0x0802061D | ✅ unique | literal `0x40011400` = USART6 base — RX ISR, ring-buffer push (PC programming link, §4) |
All other external slots hold the shared default stub **0x08002ADB** or `0x00000000`, i.e. their peripherals' interrupts are disabled.
> **Index caveat:** exact IRQ numbering (e.g. TIM1 vs the precise EXTI line) is inferred from the peripheral base each ISR touches rather than from position alone, because the vendor may be an F407 clone (AT32/APM32/GD32) with a slightly reordered NVIC table. The **peripheral identity from the literal pool is the hard evidence**; the STM32F4 IRQ# column is the best-fit standard name.
### 2.4 Disassembly of clearly-used ISRs
**USART3 RX ISR @0x080205B0 (IRQ 39)** — the two UART ISRs are structurally identical; both call the same pair of helpers `0x8021EC2` (check-flag) and `0x8021EA8` (read-byte), then push into a SRAM ring buffer:
```
0x080205B0: push {r4,lr}
0x080205B4: movs r1,#0x20 ; flag mask 0x20 = RXNE (USART_SR bit5)
0x080205B6: ldr r0,[pc,#0x48] ; ->0x40004800 USART3 base
0x080205B8: bl #0x8021EC2 ; if(SR & RXNE)
0x080205BC: cbz r0,#0x80205FC
0x080205BE: ldr r0,[pc,#0x40] ; USART3
0x080205C0: bl #0x8021EA8 ; r0 = USART3->DR (read byte)
0x080205C4: uxtb r4,r0
0x080205C6: ldr r0,[pc,#0x3c] ; ->0x20000C2C rx ring struct
0x080205C8: ldrh r1,[r0] ; head index
0x080205CA: ldr r0,[r0]
0x080205CE: str r0,[r2] ; advance write pointer
```
Buffer sits at SRAM `0x20000C2C`/`0x20007575`.
**USART6 RX ISR @0x0802061C (IRQ 71)** — same shape, base `0x40011400` (USART6), buffer at `0x20000C60`/`0x200082EF`.
> **Serial-link assignment (cross-referenced with §4):** USART6 (`0x40011400`) is the **external PC programming link**; USART3 (`0x40004800`) is the **internal MCU↔FM100B DMR-baseband UART** (§4.5 shows the DMR-record parser at `0x08006D00` referencing USART3). The FM100B link uses the larger SRAM buffer (`0x200082EF`).
**ADC ISR @0x08002D1C (IRQ 18)**:
```
0x08002D1C: push {r4,lr}
0x08002D1E: movs r1,#0x20 ; ADC_SR EOC-class flag
0x08002D20: ldr r0,[pc,#0x34] ; ->0x40012000 ADC1 base
0x08002D22: bl #0x80209D4 ; test flag
0x08002D2C: bl #0x80209CE ; clear flag
0x08002D30..38: ldr/adds/str ; ++conversion counter at 0x20000FB8-region
```
Services **ADC1** — battery-voltage / RSSI / (possibly volume-knob) sampling.
**TIM ISR @0x0801DBB0 (IRQ 25, TIM1_UP/TIM10) and @0x0801DDCC (IRQ 28, TIM2)** both open with the update-flag idiom:
```
0x0801DBB2: movs r1,#1 ; TIM_SR UIF (bit0)
0x0801DBB4: ldr r0,[pc,#0x180] ; ->0x40010000 TIM1
0x0801DBB6: bl #0x8021C5C ; if(SR & UIF)
0x0801DBC2: bl #0x8021C56 ; clear UIF
```
The TIM1 handler then writes a large block of GPIO/state bytes at `0x20000BF1..0x20000BFA` (display column / keypad-matrix scan). The TIM2 handler (base region `0x40007410`) is the periodic software-tick that stands in for the disabled SysTick.
### 2.5 Active peripherals (from non-default ISRs)
Based purely on which vectors are unique (not the stub), the firmware actively drives:
- **USART3 + USART6** — the two RX-interrupt-driven serial links (internal FM100B DMR link and external CPS host link).
- **ADC1** — analog sampling (battery / RSSI).
- **TIM1 (or TIM10)** — display/keypad matrix scanning.
- **TIM2** — periodic system tick (replacing the unused SysTick).
- **SVCall / PendSV** — context/service switching (RTOS-style), while **SysTick is deliberately stubbed**.
Everything else — WWDG, PVD, RTC, all DMA streams, SPI, I2C, USB_OTG_FS, the remaining TIMs and USARTs — points at the shared `Default_Handler` (0x08002ADB) or is null, i.e. those peripherals are either polled or unused at the NVIC level. Notably **no DMA stream ISR is active**, so the UART links are handled byte-by-byte in interrupt context (consistent with the ring-buffer push seen in the USART ISRs), and **USB_OTG_FS has no ISR** (programming is over USART, not native USB).
## 3. Strings, Menu Tree & Feature Inventory
All addresses are absolute in the MCU application image (load base `0x08002800`). 4,744 raw ASCII runs (len ≥ 3) were extracted; ~970 are "wordy" strings. The bulk of the useful UI text sits in a contiguous string/label region roughly `0x08004C00``0x08028900`, with a single dense fixed-width **master menu blob at `0x080253ED`** that decodes the entire menu hierarchy exactly.
### 3.1 String Classification
**Version / build markers**
- `0x0800BA88` `VER :RT-4D V3.25` — firmware banner (the anchor named in the hard facts).
- `0x0800BA9F` `DATE:2026-02-05` — build date.
- `0x0800BAB4` `DMR :` — placeholder for the FM100B baseband version read back at boot.
- `0x0800BACB` `IC :` — SPI-flash chip-ID label, followed by the JEDEC decode table `25Q80 1MB` / `25Q16 2MB` / … / `25Q512 64MB` / `Unknown` at `0x0800BAE3``0x0800BB38`.
- `0x0800BB44` `BATT:0.0V` — battery voltage readout.
**Menu items** — see the reconstructed tree (§3.2). The canonical source is the fixed-width blob at `0x080253ED` (14-char label + 2-digit index records).
**DMR / digital features**
- `0x0800CFCB` `DMR Time Slot: 1` / `0x0800CFE0` `DMR Time Slot: 2`
- `0x0800D007` `DMR Encrypt: Off`
- `0x08016BDF` `Encryption (D)`, `0x080165D0` `Encryption Set`
- `0x08016BF8` `TX Politely (D)`, `0x08016C10` `Promiscuous (D)`, `0x08016C24` `Channel ID (D)`, `0x08016C3C` `ID Select (D)`
- `0x080167B8` `Color Code (D)`, `0x080167CC` `Contacts (D)`
- `0x0800CCE0` `Digital Mode`, `0x0800CD28` `Promiscuous: On/Off`, `0x0800CD50` `Dual Slot : Off/On`
- Call-type labels: `Individual` / `Group Call` / `All Call` (multiple copies, e.g. `0x0800A35F`, `0x0800A374`, `0x08012A71`) with `SID:` / `GID:` / `AID:` prefixes at `0x0800A36C`/`A380`/`A3A8`.
**Remote-control / kill / alarm (high value — see §3.4)**
- `0x08003457` ` DTMF Remote Kill`, `0x0800346C` `DMR Remote Kill`
- `0x08006B30` `DMR Remote Stun`, `0x0801E858`/`0x0801ECB8` `DMR/DRM Remote Stun`, `0x0801E844`/`0x0801ECA4`/`0x08019CB8` `DTMF Remote Stun`
- `0x0800C767``0x0800C7CC` `Remote Stun:` / `Remote Kill:` / `Wake Up:` (edit fields)
- `0x08006AEC` `Emergency Alarm`, `0x08006B03` `Being Searched`, `0x08006B1B` `Radio Wake Up`, `0x08019CCC` `DTMF Wake up`
- `0x0800A5B0` `Being Monitored`, `0x0801D72B` `Monitor Mode`
- `0x0801ED28` `Prohibit TX` (result of a stun), `0x0800C7E0`/`C7F4`/`C808` `Radio Online` / `Radio Offline` / `Check Failed` (radio-check / presence).
**Analog / signalling features**
- `0x08009A67` `RX CTC/DCS (A)`, `0x08009A7C` `TX CTC/DCS (A)`, `0x080166FC` `DCS Encrypt (A)`, `0x0800A023` `Remote CTC/DCS`, `0x0801F2AB` `NO CTC/DCS`
- `0x08016744` `Scrambler (A)`, `0x0800AFE7` `Mute Code (A)`, `0x08016714` `Band Width (A)`, `0x0801675C` `Busy Lock (A)`, `0x0801672C` `Tail Tone (A)`
- DTMF suite: `Send Single Tone` (`0x0800C743`), `Area A DTMF:` / `Area B DTMF:` (`0x0801B26C`/`B280`), plus DTMF Delay/Interval/Duration/Select/Display/TX Gain/Control menu labels.
- `0x0800C288` `TX End Tone: MDC` — MDC-1200 signalling.
**Modes / RX**
- `0x0800CC7B`/`CC90`/`CCA4` `RX Mode : AM / SSB / FM` — the receiver supports AM & SSB, not just FM.
- `0x0800B86B`/`B873`/`B878` ` FM ` / ` AM ` / `SSB` mode tags.
- `0x0800CCFF` `Analog VOX : On/Off`, `0x0800C1E3` `Dual Standby:Off/On`.
**Calibration / service**
- `0x08019C87` `Calibration OK!`
- `0x08014950` `Update DMR Chip` + `0x08014964` `Please Wait...` — the FM100B baseband firmware-flash path invoked from the MCU.
- `0x08019842`/`0x08019714`-area & `0x080198F0` `PC Programing` — CPS/serial programming mode.
- Backup/restore: `0x08004C8B` `Backing up...`, `0x08012B5C`/`0x0801A597` `Recovering...`, `0x0800EDA0`/`0x080178F4` `Saving Data.....`, `0x0800F0A3` `Clearing Data...`.
**Error & status messages**
- `0x08005803` `ERROR`, `0x08007070` `Contact Error!`, `0x0800ED8C` `Type Error!`, `0x08006DE4` `Call type error,`
- `0x0800DCC0`/`0x08017AA4`/`0x08017B90` `ID Out Of Range`, `0x08017A8B` `ID Conflict`, `0x0801EE9B` `Invalid ID`, `0x08017733` `Non-existent ID`, `0x08028815`/`0x080284F0` `Unknown station`
- `0x0800ED77` `Contacts Full!`, `0x0801AB64` `Members Full`, `0x08017AE8`/`0x0801CEE8` `Send Failed`, `0x08017AB8` `Sending`
- `0x0800D460` `is out of range`, `0x0800D474` `Resulting freq`, `0x0800D86F` `Cannot be set!`
- `0x0801E3B8` `Please Charge!`, `0x0801E193` `repeater failed`, `0x0801E1A8` `Connect to`.
**Country / region list** (~256 entries; base `0x0802724A``0x0802833E`) — MCC/ITU country-code table. Examples: `Falkland Islands`, `Venezuela`, `Argentina Republic`, `South Africa`, `Papua New Guinea`, `Korea Republic o[f]`, `Saudi Arabia`, `Kazakhstan`, `Czech Republic`, `Switzerland`, `Netherlands`. `United States` appears 26× and `United Kingdom` 4× (consecutive MCC blocks). `0x0801BD1C` `Unknown Country` is the fallback. This is a DMR **home-country / MCC lookup**, not a UI language selector.
**Pinyin input table** (`0x0802219A``0x080240D8`) — a full CJK pinyin syllable list (`bang`,`beng`,`bian`,…,`zhuo`,`zong`,`zuan`) used for Chinese character entry; confirms a Chinese IME in the SMS/contacts editor. Latin-input mode strings live at `0x080086B4` `ABC`/`abc`/`123`/`PY1`/`PY2`.
**Developer / debug leftovers**
- Misspellings shipped in production: `Copy(Recive)` (`0x080034A3`), `Receving` (`0x080034B8`), `Cantacts List` (`0x08016CC8`), `DRM Remote Stun` (`0x0801ECB8`), `Swasiland` (`0x08027402`), `PC Programing` (`0x080198F0`). These are useful low-entropy grep anchors.
- No printf/format-string or file-path debug strings survive; the image is otherwise release-stripped.
### 3.2 Menu Tree (from the fixed-width blob at `0x080253ED`)
The blob is a flat sequence of `label(14)+index(2)` records concatenated per submenu; the seven top-level items each own the following index-01… run. Reconstructed hierarchy:
```
Main Menu
├─ 01 Basic Set
│ ├─ 01 Radio Name ├─ 02 Voice Prompt ├─ 03 Key Beep
│ ├─ 04 Lock Timer ├─ 05 Backlight ├─ 06 Light Timer
│ ├─ 07 Brightness ├─ 08 Menu Exit ├─ 09 Dual Standby
│ ├─ 10 TX Priority ├─ 11 Freq Step ├─ 12 Talkaround
│ ├─ 13 Save Mode ├─ 14 Scan Mode ├─ 15 Scan Direction
│ ├─ 16 Scan Dwell ├─ 17 Scan Interval ├─ 18 Scan Return
│ ├─ 19 Scan Start ├─ 20 Scan End ├─ 21 Alarm Type
│ ├─ 22 Main PTT TX ├─ 23 Area A Mode ├─ 24 Area A Show
│ ├─ 25 Area A Zone ├─ 26 Area B Mode ├─ 27 Area B Show
│ ├─ 28 Area B Zone ├─ 29 Save CH ├─ 30 Delete CH
│ ├─ 31 LCD Contrast ├─ 32 Freq Input ├─ 33 Reverse CH Dir
│ ├─ 34 Carrier LED ├─ 35 RSSI Refresh ├─ 36 APO
│ ├─ 37 APO Timer ├─ 38 FM RX Standby ├─ 39 Initialization
│ ├─ 40 Instruction └─ 41 Version
├─ 02 Key Define
│ ├─ 01 Second PTT ├─ 02 Side Key 1 S ├─ 03 Side Key 1 L
│ ├─ 04 Side Key 2 S ├─ 05 Side Key 2 L ├─ 06..15 "0..9 Press Long"
│ └─ (16) SQ Level # trails the Key Define run as its own idx-01 of Analog Set
├─ 03 Analog Set
│ ├─ 01 SQ Level ├─ 02 TX Start Tone ├─ 03 TX End Tone
│ ├─ 04 Single Tone ├─ 05 Tone Timer ├─ 06 MIC Gain
│ ├─ 07 SPK Gain ├─ 08 Glitch TH ├─ 09 Detect Range
│ ├─ 10 Repeater Delay ├─ 11 DTMF Delay ├─ 12 DTMF Interval
│ ├─ 13 DTMF Duration ├─ 14 DTMF Mode ├─ 15 DTMF Select
│ ├─ 16 DTMF Display ├─ 17 DTMF TX Gain ├─ 18 DTMF RX TH
│ ├─ 19 DTMF Control ├─ 21 VOX ├─ 22 VOX Delay
│ ├─ 23 VOX TH └─ 24 Short Tail # note: index 20 is skipped
├─ 04 Digital Set
│ ├─ 01 Personal ID ├─ 02 Call Tone ├─ 03 Call End Tone
│ ├─ 04 Group Hold ├─ 05 Single Hold ├─ 06 SQ Level
│ ├─ 07 MIC Gain ├─ 08 SPK Gain ├─ 09 TX Denoise
│ ├─ 10 RX Denoise ├─ 11 Contacts Set ├─ 12 TG List Set
│ ├─ 13 Encryption Set ├─ 14 Called Show ├─ 15 Send DTMF
│ ├─ 16 Caller Keep ├─ 17 Call Log ├─ 18 Clear All Log
│ └─ 19 Address Book
├─ 05 Channel Set
│ ├─ 01 DMR Or Analog ├─ 02 RX/TX Limit ├─ 03 CH Alias
│ ├─ 04 TX Power ├─ 05 Scan Add ├─ 06 TOT
│ ├─ 07 Offset Freq ├─ 08 Set TX Freq
│ ├─ 09 CTC/DCS (A) ├─ 10 RX CTC/DCS (A) ├─ 11 TX CTC/DCS (A)
│ ├─ 12 DCS Encrypt(A) ├─ 13 Mute Code (A) ├─ 14 Band Width (A)
│ ├─ 15 Tail Tone (A) ├─ 16 Scrambler (A) ├─ 17 Busy Lock (A)
│ ├─ 18 RX Demod (A)
│ ├─ 19 DMR Mode (D) ├─ 20 DMR Slot (D) ├─ 21 Color Code (D)
│ ├─ 22 Contacts (D) ├─ 23 TG List (D) ├─ 24 Encryption (D)
│ ├─ 25 TX Politely(D) ├─ 26 Promiscuous(D) ├─ 27 Channel ID (D)
│ └─ 28 ID Select (D)
├─ 06 Zone Set # (top-level slot; label sourced from 0x080152.. block)
├─ 07 Message
│ ├─ 01 New SMS ├─ 02 Inbox ├─ 03 Outbox
│ ├─ 04 Drafts ├─ 05 Default SMS ├─ 06 Clear All SMS
│ ├─ 07 SMS Format ├─ 08 SMS Font └─ 09 SMS Prompt
└─ (Contacts submenus, referenced by Digital Set → Contacts Set / Address Book)
├─ Contacts List: 01 Contacts List 02 Add Contact
└─ Contact edit: 01 Edit Name 02 Select CH
```
`(A)` = analog-only parameter, `(D)` = DMR-only parameter — the firmware tags each channel parameter by mode. Index 20 is skipped in Analog Set and index gaps confirm conditionally-hidden items (e.g. VOX shown only when enabled).
### 3.3 Feature Inventory
- **Dual-processor DMR**: MCU drives an FM100B DMR chip; `Update DMR Chip` (`0x08014950`) confirms the MCU can reflash the baseband over the internal UART.
- **DMR digital voice/data**: time-slot select (TS1/TS2 `0x0800CFCB`), Color Code, Talk Groups (`TG List Set`), Individual/Group/All-Call, SMS over DMR.
- **DMR encryption**: `Encryption Set` / `Encryption (D)` with an On/Off state (`DMR Encrypt: Off` `0x0800D007`). `DCS Encrypt (A)` is a separate analog feature. (Encryption *type* enumeration lives in code/data, not in plain strings — an RE follow-up target.)
- **Remote command suite** (DMR **and** DTMF variants): Remote **Stun**, Remote **Kill**, **Wake Up**, **Radio Check** (`Radio Online/Offline`/`Check Failed`), **Monitor / Being Monitored**, **Emergency Alarm / Being Searched**. Stun result = `Prohibit TX`.
- **Caller/called ID display**: `Show Caller Info` / `Show Called Info` / `Called Show` (`0x0800C823`/`C84C`/`0x080165EC`), `Caller Keep`.
- **Scanner**: Scan Mode/Direction/Dwell/Interval/Return/Start/End, `Scanning` status, `Scan Add` per channel.
- **Dual watch / dual display**: `Dual Standby`, `Dual Slot`, `Area A/B Mode/Show/Zone`, `Dual Display` / `Single Display` (`0x080184F8`/`0x0801850C`).
- **Multi-mode RX**: FM / **AM** / **SSB** demodulation (`RX Mode` strings) + `RX Demod (A)` per-channel — broadband/airband RX capability, not just ham FM.
- **FM broadcast radio**: `FM RX Standby` menu item (`0x080159F4`).
- **Analog signalling**: CTCSS/DCS (RX+TX split), DCS "encrypt", Scrambler, Mute Code, MDC end-tone, full DTMF encode/decode with per-area DTMF IDs, Busy Lock/TX-Politely.
- **SMS**: Inbox / Outbox / Drafts / Default SMS / Clear-All, `SMS Format`, `SMS Font`, `SMS Prompt`; `Unread SMS :` counter (`0x0801FB44`); draft/limit errors (`Draft Full!`). Chinese pinyin IME + ABC/123/PY input modes.
- **Contacts / Addressbook**: `Contacts List`, `Add Contact`, `Edit Member`, `Members Full` (talk-group cap), `Contacts Full!` (contact cap). Contact type = Individual/Group/All Call. `16777215` (`0x08007E07`) = 2^241, the max 24-bit DMR ID (confirms IDs are 24-bit).
- **Station/repeater**: `Station Name`, `Offset Set` / `Offset Freq` / `Set TX Freq`, `Talkaround`, `Reverse Freq`, `Repeater Delay`, `Connect to … repeater failed`.
- **Power/UI**: High/Low TX power, TOT (`0x08015806` area), APO + APO Timer, backlight/light-timer/brightness/LCD-contrast, Carrier LED, RSSI Refresh, Lock Timer, Voice Prompt.
- **Service**: on-device calibration (`Calibration OK!`), backup/restore of the SPI data flash, factory `Initialization`, `PC Programing` (CPS).
- **Country/MCC table**: ~256 entries for DMR home-country selection.
### 3.4 Highest-value RE anchors (address → why)
| Address | String | Why it's an anchor |
|---|---|---|
| `0x0800BA88` | `VER :RT-4D V3.25` | Version banner; xref finds the boot/about screen builder and DMR/IC version readback code. |
| `0x080253ED` | master menu blob | Single table driving the whole menu; its xref locates the menu-dispatch state machine and per-item index handlers. |
| `0x08014950` | `Update DMR Chip` | Only anchor for the FM100B baseband-flash routine (internal-UART XMODEM/bootloader trigger). |
| `0x0800346C` / `0x08006B30` / `0x0800C790` | `DMR Remote Kill` / `DMR Remote Stun` / `Remote Kill:` | Locate the remote-command TX/RX handlers — the security-critical CSBK stun/kill/wake path. |
| `0x0800D007` / `0x080165D0` | `DMR Encrypt: Off` / `Encryption Set` | Entry to the encryption enable + key-select code; leads to the (unstringed) cipher/type table. |
| `0x08019C87` | `Calibration OK!` | Anchors the calibration write routine → maps which SPI-dump offsets hold RF calibration. |
| `0x08007E07` `16777215` / `0x0800A36C` `SID:`/`GID:`/`AID:` | DMR ID constants/labels | Confirm 24-bit ID handling; xref finds ID validation (`ID Out Of Range`/`ID Conflict`) and call-type routing. |
| `0x080198F0` `PC Programing` / `0x08004C8B` `Backing up...` | CPS + backup | Anchor the serial-protocol / SPI-flash read-write engine used by the CPS. |
| Misspellings `Copy(Recive)` `0x080034A3`, `Cantacts List` `0x08016CC8`, `DRM Remote Stun` `0x0801ECB8` | typos | Unique low-collision grep hooks for cross-referencing duplicated handler code. |
## 4. Serial / Flashing Protocol
The RT-4D main MCU exposes two logically distinct serial links: an external **PC programming link** (RS485-style half-duplex, **USART6 @ `0x40011400`**, with a GPIO direction/DE line toggled through the bit-set/clear helper at `0x8021c6e`) and an internal link to the FM100B DMR baseband (**USART3 @ `0x40004800`**, §4.5). This section documents the PC-link command set as implemented in the application (`VER :RT-4D V3.25`), plus how bootloader/flash mode is reached.
### 4.1 Frame reception & command validation
Incoming bytes land in a SRAM ring buffer (`data @ 0x20007ddb`, head/tail at `0x20000c5c`/`0x20000c60`). The pre-dispatch **framer** lives at `0x0801f864`. It peeks the first byte of a candidate frame and only accepts it if the opcode is a known first-byte; otherwise it advances the tail by one and resyncs:
```
0801f880 cmp r0,#0x34 beq accept ; Notify / mode / Close
0801f88e cmp r0,#0x40 beq accept ; WriteSPI region 0x40 (calibration)
0801f89c cmp r0,#0x90 ; blt reject
0801f8aa cmp r0,#0xa5 ; ble accept ; WriteSPI regions 0x90..0xA5 (incl. 0xA4 addr-book)
0801f8ba cmp r0,#0x52 beq accept ; ReadSPI
```
It then computes the **expected frame length** by opcode and re-checks it against the number of buffered bytes (`0x0801f8ca`):
| First byte | Frame length | Meaning |
|---|---|---|
| `0x34` | **5** (`movs r4,#5`) | Notify / mode-select / Close |
| `0x52` | **4** (`movs r4,#4`) | ReadSPI (1 opcode + 2 block + 1 cksum) |
| anything else (`0x40`,`0x90..0xA5`) | **0x404 = 1028** (`movw r4,#0x404`) | WriteSPI / addr-book (opcode+2 hdr + 1024 data + 1 cksum) |
**Checksum (normal mode):** simple 8-bit sum of all bytes except the last, seed **0**, compared against the trailing byte. This is verified inline at `0x0801f914` via the sum helper `0x80109de` (`checksum(buf, len-1)`), and a mismatch discards the frame. This matches the CLI's `_checksum` (`sum(command[:-1]) & 0xFF`). Validated frames are copied into the assembly buffer at `0x200092ef` and handed to the dispatcher `0x8019790`.
Note the CLI's `command_write_spi` uses opcode byte `region_id` (e.g. `0x91`) directly as the first byte — consistent with the framer accepting any byte in `0x90..0xA5` as a 1028-byte write frame. The `0x57` byte named in some CLI shorthand is **not** literally compared as a first byte in the app; the real first byte of an SPI write is the **region id**, and `0x52` is the read.
### 4.2 Top-level dispatcher `0x08019790`
```
08019796 ldrb r0,[r4] ; frame[0]
08019798 cmp r0,#0x34 bne 0x8019898 ; -> SPI/addrbook handler 0x80188d4
0801979c ldrb r0,[r4,#3] ; sub-command = frame[3]
0801979e cmp r0,#0x10 -> NOTIFY
080197e2 cmp r0,#0x54 / 0x58 -> ENTER-MODE
08019854 cmp r0,#0xee -> CLOSE (reboot)
```
**`0x34` — multiplexed control command** (frame `[0x34, a, b, sub, cksum]`):
- **`sub = 0x10` → Notify / enter session.** Clears the 8-byte work area (`memset` via `0x8013738`), emits a status/banner string, replies **`0x06` (ACK)** into the TX buffer, sets session-active flag (`0x20000c59`←1). Confirms CLI `command_notify` = `[0x34,0x00,0x00,0x10,cksum] → 0x06`.
- **`sub = 0x54` or `0x58` → enter SPI-access mode.** Sets mode flags (`0x20000c57`/related) and pre-initializes flash context. `0x54` sets mode=1, `0x58` sets mode=2. These select the flash-write personality used by subsequent region writes (single-bank vs. dual-bank / large-flash path) and reply `0x06`. **Not present in the open CLIs.**
- **`sub = 0xEE` → Close.** Clears the session flag, and depending on the active mode calls a region-finalize routine (`0x8004cb0` or `0x8004ab0`, each a `0x1000`-byte SPI region rewrite/commit), then calls **`0x801a38c` which performs an `NVIC_SystemReset`**:
```
0801a396 ldr r0,[AIRCR] ; 0xE000ED0C
0801a39e orr r0, #0x05FA0000
0801a3a2 adds r0,#4 ; VECTRESET|SYSRESETREQ
0801a3a6 str r0,[AIRCR] ; reboot
```
So the CLI's fixed Close frame `[0x34,0x52,0x05,0xEE,0x79]` reboots the radio (returning it to normal firmware, exiting the programming session).
There is also a guarded branch at `0x8019888` comparing a stored word against **`0xABCD`** which, when matched, invokes a secondary handler (`0x801ad9c`/`0x801a5ec`/`0x801ae2c`) — an internal magic-gated path, not used by the public CLIs.
**`0xA4` — Address-book (global contacts) write** (`0x80198ae`): accepts a 1028-byte frame `[0xA4, blkHi, blkLo, 1024×data, cksum]`. It is rejected (`0x18`/`0x19`/`0x17` chip-state checks) if the external flash is too small, matching the CLI's handling of `0x4A` ("capacity limit") and `0xA4` ("capacity mismatch") error replies. On success it writes the block to the large contacts area and ACKs `0x06`.
### 4.3 ReadSPI (`0x52`) and WriteSPI (region id) — handler `0x080188d4`
Both live in `0x80188d4`. `r4 = (frame[1]<<8)|frame[2]` = **KB block index**.
**`0x52` ReadSPI** (`0x080188f4`): echoes the 3-byte header back, computes the byte address `addr = block << 10` (`lsls r4,#0xa`), calls SPI read `0x8021828(dst, addr, 0x400)` for a 1024-byte block, appends a sum checksum over 1027 bytes (`0x80109de`, len `0x403`), and streams `header(3) + data(1024) + cksum(1) = 1028` bytes back. This is exactly the CLI's `command_read_spi` (reads 1028, strips 3-byte header, verifies sum). A leading `0xFF` in byte[0] signals "bootloader active / not readable", which `is_bootloader_mode` uses to detect flash mode.
**Region-write** (`0x0801898e` onward): a `switch(frame[0])` maps each region id to a base **KB offset (`r7`)** and **size in KB (`r8`)**, then erases the covered sectors and programs the 1024-byte payload:
| Opcode | r7 (KB base) | r8 (KB size) | Region (matches `SPI_REGIONS`) |
|---|---|---|---|
| `0x40` | 0 | 1 | calibration (`0x000000`, 4 KB span, 1 KB write) |
| `0x90` | 2 | 1 | main_settings (`0x002000`) |
| `0x91` | 4 | 0x0C | channels (`0x004000`, 48 KB) |
| `0x92` | 0x1C | 0x20 | zones (`0x01C000`, 128 KB) |
| `0x93` | 0x5C | 0x34 | contacts (`0x05C000`) |
| `0x94` | 0x7C | 5 | groups (`0x07C000`) |
| `0x95` | 0xC6 | 5 | dmr_keys (`0x082000` per constants; see §6 note) |
| `0x96` | 0xD0 | 3 | call_log (`0x088000`) |
| `0x97` | 0xD6 | 1 | default_sms (`0x094000`) |
| `0x98` | 0xF0 | 1 | fm_settings-adjacent |
| `0x9A` | 0x100 | 1 | schedules-class |
| `0x9C..0xA5` | via `tbb` jump table at `0x8018a2e` (offsets `0x14C`,`0x164`,`0x188`,`0x198`,`0x19C`,`0x19E`,`0x352`,`0x3F0`,`0x400`/size `0xC00`, …) | | extended data regions not all exposed by the CLI |
**The `0x9C..0xA5` range (via the `tbb` table) is broader than the CLI's published region list** — several of these opcodes (e.g. the `0x400`-KB-base / `0xC00`-KB-size entry) target large data areas the community tools do not currently write.
Write mechanics (`0x8018b76`): for a normal region, before programming it **erases `r8` sectors** by calling the erase primitive `0x8021924` once per KB-sector index `r7+i` (`0x8018b7c`). Then it programs the 1024-byte page with `0x8021a70(addr, payload, 0x400)`, and replies **`0x06`**. This matches CLI `command_write_spi` (region byte + block + 1024 data + sum → `0x06`).
**Flash erase granularity:** the erase primitive `0x8021924` shifts the index left by 12 (`lsls r4,r4,#0xc` → ×4096) and issues SPI opcode **`0x20`** (`movs r0,#0x20` at `0x802193a`) — i.e. a **4 KB sector erase**. Read uses SPI opcode `0x03`; page programming respects 256-byte page boundaries (`rsb r5,#0x100` at `0x8021a7c`). The `cmp #0x18 / #0x19` chip-ID checks select 3-byte vs 4-byte addressing for larger flash parts.
### 4.4 Bootloader / flash mode and the `0x39` firmware protocol
The **`0x39`-based firmware-flash protocol** (handshake `[0x39,0x33,0x05,0x10,00]`, erase-trigger `[0x39,0x33,0x05,0x55,00]`, and `0x57 <offHi><offLo> + 1024B` write) with checksum **seed `0x48`** is **not present anywhere in the application binary** — a scan finds no `cmp #0x39` command comparison in the dispatcher (the only `#0x39` compares are the ASCII hex-digit parser at `0x8002e78`). This confirms the `0x39` flasher lives in the **bootloader at `0x08000000..0x08002800`**, which is a separate image not contained in `rt4d_stock_v3.25_abs_0x08002800.bin`. The bootloader is what the CLI's `probe_bootloader` (spamming `0xFF` until it echoes `0xFF`) and `command_handshake` talk to.
**Entering the bootloader from the app:** the app itself never writes internal MCU flash — it only ever reboots via the `NVIC_SystemReset` in `0x801a38c` (the `0x34..0xEE` Close). On reset, execution returns to the bootloader at `0x08000000`, which decides whether to stay in flash mode (based on a key/GPIO check at power-on — the documented "hold `*` at power-on" path — or a magic word left in RAM/backup register) or to jump to the app at `0x08002800` (`SP=0x2000AE48`, `reset=0x08002AC1`). The two firmware-flash speed modes (115200 default vs 256000 requiring `#` held at boot) are also bootloader behavior. Because the app's Close reboots into that bootloader, the practical "enter flash mode" sequence is: open a normal session (`0x34..0x10`), then reboot with hold-key to land in the `0x39` flasher — or power-cycle holding `*`/`#`.
### 4.5 MCU ↔ FM100B (DMR baseband) internal UART
The MCU uses **USART3 (`0x40004800`, referenced at `0x8006cfc`)** for the internal link to the FM100B DMR baseband module (the PC programming link is on USART6). The DMR-side receive/parse routine at `0x08006d00` reads structured records — note the indexing `r6*0x1B + 0x5E000` (`0x8006d14`: `rsb`/`add` producing a 27-byte-stride record base into the `0x5E000` SPI contacts area) and the `0x15`-byte reads via `0x8021828` — i.e. the MCU pulls DMR contact/alias records and hands them across USART3 to the vocoder module. This framing is **binary and record-oriented, entirely separate from the PC-link `0x34/0x52/region-id` framing**; it carries AMBE/CSBK signaling payloads rather than the checksummed programming frames. The PC-link framer explicitly ignores any byte not in `{0x34,0x40,0x52,0x90..0xA5}`, so DMR traffic and PC traffic cannot be confused even if physically bridged.
### 4.6 Summary of confirmed opcodes
| Opcode (frame[0]) | Sub (frame[3]) | Direction | Checksum | Response | Confirmed |
|---|---|---|---|---|---|
| `0x34` | `0x10` | Notify/open | sum seed 0 | `0x06` | yes (CLI) |
| `0x34` | `0x54` | Enter SPI mode 1 | sum seed 0 | `0x06` | **new** |
| `0x34` | `0x58` | Enter SPI mode 2 | sum seed 0 | `0x06` | **new** |
| `0x34` | `0xEE` | Close → `NVIC_SystemReset` | sum seed 0 | (reboots) | yes (CLI) |
| `0x52` | — | ReadSPI 1 KB block | sum seed 0 | `hdr+1024+cksum`; `0xFF`=bootloader | yes (CLI) |
| `0x40`,`0x90`–`0x9A`,`0x9C`–`0xA5` | — | WriteSPI region (4 KB erase + 1 KB program) | sum seed 0 | `0x06`; `0x4A`=capacity | partly new (`0x9C`–`0xA5` extended) |
| `0xA4` | — | Address-book block write | sum seed 0 | `0x06` / `0x4A` / `0xA4` | yes (CLI) |
| internal `0xABCD` magic gate | — | secondary handler `0x801ad9c` | — | — | **new, unexplored** |
| `0x39`-class (`0x10`/`0x55`), `0x57` write, `0xFF` probe | — | **bootloader** flash protocol | **sum seed 0x48** | `0x06`/`0xFF` | in bootloader (not in app image) |
## 5. FM100B DMR Baseband Firmware
**File:** `FM100B_V1.2.0.32_20260130.bin` — 1,527,808 bytes (≈1.46 MiB), raw ARM32 (ARM mode), load base `0x00000000`.
### 5.1 ARM32 Vector Table
The first 0x20 bytes are the classic 32bit ARM exception vector table: a `B` for reset followed by seven `LDR pc,[pc,#0x14]` instructions that pull their targets from a literal pool at 0x20–0x3c.
```
00000000 b #0x40 ; Reset -> 0x40 (startup trampoline)
00000004 ldr pc, [pc, #0x14] ; Undef lit@0x20
00000008 ldr pc, [pc, #0x14] ; SWI lit@0x24
0000000c ldr pc, [pc, #0x14] ; Prefetch lit@0x28
00000010 ldr pc, [pc, #0x14] ; Data Abort lit@0x2c
00000014 ldr pc, [pc, #0x14] ; Reserved lit@0x30
00000018 ldr pc, [pc, #0x14] ; IRQ lit@0x34
0000001c ldr pc, [pc, #0x14] ; FIQ lit@0x38
```
Literal pool (0x20–0x3c) resolves the handler addresses:
| Exception | Literal @ | Handler target |
|-----------|-----------|----------------|
| Undef | 0x20 | `0x0360f41e` |
| SWI | 0x24 | `0x03e00001` |
| Prefetch | 0x28 | `0x03800001` |
| Data Abort| 0x2c | `0x03a00001` |
| Reserved | 0x30 | `0x03c00001` |
| IRQ | 0x34 | `0x04000001` |
| FIQ | 0x38 | `0x04200001` |
**Implied reset entry:** the reset vector branches to the startup trampoline at **0x40** (the code executes in place from flash, then handlers live in a copied/remapped region). The handler targets all land in the `0x0380_0000–0x0420_0000` window, showing the runtime image is relocated into an external RAM/XIP region at ~`0x03800000+`. (Word 0x3c = `0xbeef0001` is a padding/magic marker, not a vector.)
### 5.2 Core / SoC class
This is a **bare classic ARM core in ARM state (ARM7/ARM9class, ARMv4/v5), not CortexM/A/R.** Evidence:
- 8entry ARM exception table with separate **IRQ and FIQ** vectors (CortexM uses a wordpointer NVIC table with SP@0; this uses branch/LDRpc instructions — definitively classic ARM).
- The reset trampoline at 0x40 performs the textbook classicARM bankedmode startup: mask interrupts and switch processor mode via CPSR, e.g.
```
00000044 mrs r0, apsr
00000048 orr r0, r0, #0xc0 ; set I+F bits -> disable IRQ & FIQ
0000004c msr cpsr_c, r0
00000050 mrs r0, apsr
00000054 bic r0, r0, #0x1f
00000058 orr r0, r0, #0x1f ; -> System mode (0x1F)
0000005c msr cpsr_c, r0
```
Banked CPSR mode/interrupt bits (`0xC0`, mode `0x1F`) are a classicARM construct absent on CortexM.
- **Zero CP15 coprocessor accesses** in the entire 1.46 MiB image (`mcr/mrc p15` count = 0): no MMU/cache setup ⇒ not a CortexA/ARM11 application core; a small MMUless DSP/baseband ARM.
This matches a **dedicated DMR/vocoder baseband SoC** rather than a general MCU — the firmware itself carries a `WebRTC` audio DSP stack (see §5.3) doing AMBE vocoding + noise suppression/AGC.
### 5.3 Extracted & categorized strings
**Version / identity**
- `TSwVerQueryCnf`, `SPSwVerQuery` — softwareversion query interface
- `VocoderVersion\WebRTC\source\dig…`, `…rcom\VocoderVersion\WebRTC\source…` — build path revealing an embedded **WebRTC** vocoder/DSP source tree
- `FM100`, `VERSION:%08X`, `W]pF_VERSION`
**AMBE / vocoder**
- `ATCRecvAmbe6sdataCnf` — receive AMBE 6s data confirm (AMBE frame delivery)
- `vocoder mutex`, `WebRtcNsx_ProcessCore`, `WebRtcNsx_CalcParam`, `WebRtcAgc_CalculateGa[in]` — WebRTC **NSx** (noise suppression) + **AGC** blocks feeding the vocoder
- `ATC_VCDInterleavEnReq`, `ATVCDInterleavQueryCnf`, `SPGetVcdNoiseTHReq`, `SPVcdInterleavQuery`, `ATC_SendVcdNoi…seqTHSetReq` — VCD (voicecoder) interleaver enable + noisethreshold control
**DMR CSBK / signalling**
- `armcsbkSendReq` — CSBK transmit request
- `SPCclSpInBandDataInd`, `SPSendInBandDataReq`, `…banddata_handleReq` — inband signalling data path
- `ATDigCallSetupCnf`, `ATDigCalledStartCnf`, `CallsetupCnf`, `ATC_CallProcessReq`, `SPContactProcessHandleReq`
**Alarm / emergency**
- `SPEMG_StopAlarmReq`, `ATRecvEmgCallInd`, `ATAlarmStatusCnf`, `ATAlarmStatus_s`, `ATC_EMGtype`, `SPATCRecvEmgD[i]`, `EMGLIST` — DMR emergencyalarm subsystem
**Calibration / NV**
- `SPCaliFreqSetCnf`, `SPCali_ChannelParamOpt`, `SPCali_PowerOpt`, `SPCali_DigMod1Opt`, `SPCali_AnaSQthOpt`, `SPCali_AnaVccnOpt`, `SPCali_HeadGQParamOpt`, `SPCali_SQRXFreqOpt`, `PCali_Dig_Fastopenclose_timeOpt`
- `ATC_ClearNVdataReq`, `INCM_NV_WriteItem`, ENV/NV item store with error strings (`Error: The ENV (@0x%…)`, `ENV size is too big`)
**RTOS / tasks / mutex** — see §5.5.
### 5.4 MCU ⇄ FM100B message interface (Req/Cnf/Ind protocol)
The MCU and FM100B exchange a structured **Request / Confirm / Indicate** message protocol over the internal UART (`uart_task`, `atc_queue`, `sp_queue`). Names carry two prefix families: **`ATC_`/`AT…`** = the ATCommand channel (MCU→module commands & module→MCU confirms) and **`SP…`** = the module's internal serviceprocessor side. ~129 distinct `*Req`/`*Cnf`/`*Ind` symbols were recovered; grouped by subsystem below (garbled fragments from the extraction omitted):
**Channel / RF configuration (MCU→module `Req`)**
- `ATC_ChFreqSetReq`, `ATC_ChSlotSetReq`, `ATC_ChannelSetCnf`, `ATC_CurChannelWaitSetReq`, `ATC_SetRfPowerLevelReq`, `ATCAgcthSetReq`, `ATCEQLevelSetReq`, `ATC_AnaChGroupSetReq`, `ATC_AnaSignalNumSetReq`, `ATC_DigChGroupSetReq`, `ATC_UVFreqGpio_SE[t]`
**Call setup / processing**
- `ATC_CallProcessReq`, `ATDigCallSetupCnf`, `ATDigCalledStartCnf`, `ATAnaCalledStartCnf`, `CallsetupCnf`, `SPAnaCallsetupCnf`, `PTTStatusCnf`, `ATC_SendCallPromptReq`, `SPContactProcessHandleReq`, `SPBreakCnf`, `SPBSActTimeoverCnf`
**Identity / contacts**
- `ATC_RadioIDSetReq`, `ATRadioIDQueryCnf`, `ATC_CalledContactINfoQuery`, `ATC_CurChKeySetReq`, `ATC_CurChDigdataSetReq`
**Voice / vocoder / record**
- `ATCRecvAmbe6sdataCnf`, `ATVoiceDecCnf`, `ATC_VCDInterleavEnReq`, `ATVCDInterleavQueryCnf`, `SPGetVcdNoiseTHReq`, `SPMicVoiceCnf`, `ATC_RecordEnReq`, `ATC_RecordDataPlayReq`, `ATC_LocalRecordPlayReq`, `ATC_PlaySingleToneReq`, `ATC_DTMFToneSetReq`, `SYStoneSetReq`
**Signalling / SMS / inband**
- `armcsbkSendReq`, `SPSendInBandDataReq`, `SPCclSpInBandDataInd`, `ATC_SmsmodeSetReq`, `ATUploadRxSmsCnf`, `ATC_MonitorTxtimeSetReq`, `ATC_DigMonitorEnSetReq`
**Scan / roam**
- `ATScanStatusQueryCnf`, `ATScanSwitchCnf`, `SPSCAN_ScanInd`, `ATCurChScanlistQueryCnf`, `ATC_RoamlistSetReq`, `SPRoamList_S[e]tReq`
**Emergency / alarm**
- `SPEMG_StopAlarmReq`, `ATRecvEmgCallInd`, `ATAlarmStatusCnf`
**RSSI / signalquality / measurement**
- `ATC_RssiReadReq`, `ATRssiQueryCnf`, `SPRssilev…QueryCnf`, `SPRssi_glitchQueryCnf`, `SPRssi_noiselevQueryCnf`, `SPAT[C]Sql_glitchQueryCnf`, `ATNoiselevSetCnf`, `ATBtlLevelQueryCnf`
**Calibration / NV**
- `SPCaliFreqSetCnf`, `ATC_ClearNVdataReq`, `SPCali_*Opt` set (Power/Channel/DigMod/AnaSQth/AnaVccn/HeadGQ/SQRXFreq)
**System / power / lifecycle**
- `SPATSysReadyInd`, `SPATWkInd`, `TSwVerQueryCnf`, `ATCmdSetCnf`, `ATModuleStatusQueryCnf`, `ATC_Se[t]DeepSleepReq`, `ATSPSendSleepReq`, `SPNullMsgSendReq`, `SPMmiSetupCnf`, `DrvMmiKeyStateInd`, `SPKirisunEffectCnf`
Semantics: **`*Req`** = command initiated by one side, **`*Cnf`** = confirmation/response to a Req, **`*Ind`** = unsolicited asynchronous indication (e.g. `SPATSysReadyInd`, `ATRecvEmgCallInd`, `SPSCAN_ScanInd`, `DrvMmiKeyStateInd`). `SPCclSpInBandDataInd`/`SPSendInBandDataReq` show the bidirectional CSBK inband data pipe. The `SPKirisunEffectCnf` symbol hints the baseband stack derives from a **Kirisun** DMR reference design.
### 5.5 Size / layout / RTOS / positiondependence
- **Size/layout:** 1,527,808 bytes single flat ARM image. Vector table @0, startup trampoline @0x40, literalpool constants and code following; exception handlers relocated into a `~0x03800000` runtime region.
- **RTOS:** a preemptive multitasking RTOS is present (POSIXflavored, newlib C runtime). Recovered task/thread IDs and synchronization objects:
- Threads/tasks: `TASKID_APP`, `TASKID_ATC`, `TASKID_SP`, `TASKID_KEY`, plus `frame_rx_task`, `uart_task`
- Queues: `atc_queue`, `sp_queue`, `key_queue`, `czapp_queue`, `temp_det_queue`, `frame_rx` queue (`ceate queue failed`)
- Mutex/sem: `vocoder mutex`, `intercom time mutex`, `psem`, `rtos_sem`, generic `mutex`
- Diagnostics: `create thread failed …`, `thread - %s stack:`, `warning: %s stack is …`, `close to end of stack address.`, `thread:%s abort!`, `assertion "%s" failed: file "%s"` (newlib assert). The `POSIX` string plus pthreadstyle thread/mutex/sem naming point to a POSIXAPI RTOS (RTThread/ThreadXclass) rather than FreeRTOS/µCOS (no FreeRTOS/uCOS signatures found).
- **DSP payload:** WebRTC audio engine embedded — `WebRtcNsx_ProcessCore`/`WebRtcNsx_CalcParam` (noise suppression) and `WebRtcAgc_CalculateGain` (AGC), staged before the AMBE vocoder (`vocoder mutex`, `ATCRecvAmbe6sdataCnf`).
- **Positiondependence:** the code is **positiondependent (absoluteaddressed)**. Pointer scan of the image: **29,939** 32bit words fall inside the image range (0–1.46 MiB) and **7,698** words point into the fixed `0x0380_0000–0x0420_0000` relocation window — dense absolute pointer tables (literal pools, vector handlers, jump tables) with no PCrelative PIC/GOT indirection. The image must be loaded at its fixed base and its handlers copied to the fixed high region; it is not relocatable.
## 6. SPI Data Flash / Codeplug Layout (from live radio dump)
The file `radio-spi-dump.bin` is a full read of the RT-4D's external SPI data flash (a 4 MB / 32 Mbit part). It contains **no executable code** — it is the calibration block, the user codeplug (channels/zones/contacts/keys), plus large read-only font/graphics/DSP tables that the firmware streams from flash. Everything below is cross-referenced against `rt4d-cps/rt4d_codeplug/constants.py`.
### 6.1 Dump validation and coarse map
- **Size:** 4,194,304 bytes = `0x400000` (exactly 4 MB). Confirmed.
- **Fill ratio:** `0xFF` (erased) = 2,469,353 bytes (**58.9 %**); `0x00` = 340,767 (8.1 %); other = 1,384,184 (33.0 %). Consistent with a mostly-empty codeplug in the low megabyte and dense read-only asset tables in the upper half.
64 KB block occupancy map (`#` = has data, `.` = all-`0xFF`):
```
0x000000: ##.#.#...#..##.. 0x100000: #.#.############
0x200000: ######.......... 0x300000: ..#..#########.#
```
Two clearly distinct zones: **low flash `0x000000–0x0DFFFF`** = user/config data (sparse), and **`0x100000–0x3FFFFF`** = large contiguous asset ROMs (fonts, CJK index tables, DSP/waveform data — see §6.6).
### 6.2 Region cross-reference against `constants.py`
`constants.py` `SPI_REGIONS` predicts the low-flash layout. Findings per region (first bytes + verdict):
| Region (id) | Addr | Size | State in this dump | Decoded |
|---|---|---|---|---|
| **calibration** (0x40) | `0x000000` | `0x1000` | **Full, 0 % FF** — critical | See §6.3 |
| main_settings (0x90) | `0x002000` | `0x1000` | 85 % FF, 614 data bytes | Config present; magic `CD AB` at `0x00200C` |
| channels (0x91) | `0x004000` | `0xC000` | 99.8 % FF | **2** channels programmed |
| zones (0x92) | `0x01C000` | `0x20000` | 99.8 % FF | 3 records; zone name `"DMRhub"` at `0x01E004` |
| contacts (0x93) | `0x05C000` | `0x10000` | ~100 % FF | 3 contacts (see below) |
| groups (0x94) | `0x07C000` | `0x3000` | all FF | empty |
| dmr_keys (0x95) | `0x082000` | `0x3000` | all FF | empty **at this address** — see note |
| call_log (0x96) | `0x088000` | `0xC000` | ~100 % FF | 1 stale entry |
| default_sms (0x97) | `0x094000` | `0x1000` | all FF | empty |
| schedules (0x98) | `0x0C6000` | `0x8000` | ~100 % FF | tiny header `36 .. 01 00` at `0x0C6000` |
| fm_settings (0x99) | `0x0D6000` | `0x1000` | all FF | no FM presets stored |
| dtmf_names (0x80) | `0x0C7000` | `0x100` | all FF | empty |
**Discrepancies / corrections to `constants.py`:**
- The **encryption-key name table is at `0x0D0000`, not `0x082000`.** The dump has a dense table of 256 entries `"Key 1"…"Key 256"` on a **48-byte stride** starting `0x0D0002` (block `0x0D0000` is 10.6 % full, 6,948 data bytes). The `dmr_keys` region `0x082000` is entirely `0xFF`. So `constants.py`'s `dmr_keys` addr looks stale/wrong for V3.25 — the real key store lives in the `0x0D0000` bank. (Note the §4.3 write-opcode table follows `constants.py` and lists `0x95 → 0x082000`; that is the *protocol* region id, but this live dump shows the actual populated key names sit at `0x0D0000` — reconcile before writing keys.)
- The region `constants.py` labels **`zones` @`0x01C000`** actually contains **48-byte channel-format records** (same header/frequency layout as the channels region), not the 512-byte `ZONE_SIZE` structures it defines. The 512-byte zone-record assumption does not match this firmware's on-flash layout at `0x01C000`.
### 6.3 Calibration block structure (`0x000000`, region 0x40) — CRITICAL, DO NOT LOSE
The block is **100 % populated** (non-`0xFF` bytes extend all the way to `0x000FFF`). Its structure is a series of **16-byte tables of monotonically-ramping single-byte values** — the classic layout of per-band, per-frequency-point tuning tables (VCO/PLL trim, TX power DAC, RX squelch/RSSI thresholds). Header + first tables:
```
000000 9A 00 37 A0 38 6B 40 AB 05 00 05 00 0A 00 05 00 header / band-edge params
000010 3A 3C 3F 41 44 47 4A 4B 4C 4D 4E 4F 50 51 52 80 16-pt ramp (rising) — per-freq cal curve
000020 0A 80 12 E2 34 40 2A 4F B3 12 E2 34 40 2A 4F B3 sub-block marker 0x80 + repeated 7-byte tuple
000030 /--2357>77777777 16-pt ramp then flat → power table
000040 1E×8 19×8 0050 2D×8 28×8 → paired hi/lo tables (e.g. TX power hi/lo per band)
000060 4B 4C 4D 4E 4E×4 50×8 → rising-then-clamped curve (power ramp)
000090 11 80 12 E2 34 40 2A F3 ... → second band sub-block (same 0x80 + tuple signature as 0x20)
0000A0 37 38 39 3A 3B 3C 3D 3E 3E 3D... → VCO/PLL trim curve
0000C0 2D×16 ; 48 49 4A 4B..46 → squelch + another power curve
```
**Hypothesised field layout:**
- `0x0000–0x000F`: global header — band-edge / reference constants (`9A 00 37 A0 38 6B 40 AB`), plus small counts (`05 00 05 00 0A 00 05 00` look like table lengths = 5,5,10,5).
- Repeating **`0x80`-tagged sub-blocks** (`0x000020`, `0x000090`, …) delimit per-band groups; each carries an identical 7-byte tuple `12 E2 34 40 2A 4F B3` that reads as a shared PLL/reference constant.
- **16-entry ramp tables** = calibration curve vs. frequency point (16 points across the band). The paired equal-length runs (`1E×8` then `19×8`, `2D×8` then `28×8`) are almost certainly **High/Low power DAC pairs**; the rising-then-clamped curves (`4B 4C 4D 4E …`) are **TX power vs. frequency**; the plateau `2D×16` blocks are **squelch/RSSI thresholds**.
This 4 KB block is per-unit factory data and is **not recoverable if erased** — it must be preserved in any backup and never overwritten by a CPS write that only touches codeplug regions.
### 6.4 User codeplug decode
**Frequency encoding (confirmed):** in each 48-byte channel record, a flag byte at offset `+4`, then a **32-bit little-endian** frequency at offset `+5`, value = `MHz × 100000` (matches `FREQ_MULTIPLIER`). Verified:
```
CH0 @0x4000: 03 10 00 01 | 00 | 40 8E 9D 02(=0x029D8E40=43880000) → 438.80000 MHz RX=TX name "Simplex"
CH1 @0x4030: 07 10 00 01 | 00 | ... → 430.80000 / 440.80000 MHz name "Duplex"
```
**Channels:** only **2 of 1024** slots programmed (`"Simplex"` @`0x004020`, `"Duplex"` @`0x004050`). Names are ASCII, `0xFF`-padded, 16-byte field at record offset `+0x20`.
**Contacts** (`0x05C000`, 32-byte records at `0x05E000`): 3 entries —
```
05E000 02 AA AA AA AA "All Call" → type 0x02 = All-Call, ID 0xAAAAAAAA (broadcast)
05E015 01 06 00 00 00 "TG6" → type 0x01 = Group, TG 6 (ID as BCD nibbles)
05E02A 01 66 06 00 00 "TG666" → type 0x01 = Group, TG 666 (BCD 66 06 → 0666)
```
Talkgroup IDs are stored **BCD, little-endian** (`66 06` → `0666`), confirming the group-contact ID format.
**Zones:** 3 records at `0x01C000`; a human zone name `"DMRhub"` sits at `0x01E004`.
**Radio's own DMR ID / callsign:** the `main_settings` block (`0x002000`) is largely erased (only `0x002010–0x00201B` carry config bytes `01 00 00 01 01 00 00 03 00 28 …` and the `CD AB` magic). **No callsign string and no distinct radio DMR-ID field is populated** in this dump — the owner had not set (or had cleared) their personal ID/callsign, so nothing personally-identifying is present in the settings bank.
### 6.5 Human-readable strings (belong to the user — summary only)
~13,300 unique ASCII strings ≥4 chars across the dump, but the overwhelming majority are **font/asset artifacts** (see §6.6), not user data. The genuine **user-authored** strings are few and all in low flash:
- Channel names: `"Simplex"`, `"Duplex"` (2).
- Zone name: `"DMRhub"` (1).
- Contact names: `"All Call"`, `"TG6"`, `"TG666"` (3).
- Encryption-key labels: `"Key 1"…"Key 256"` at `0x0D0000` — these are the firmware's **default** key-slot names, not user text.
- No personal callsign, name, or DMR ID string found anywhere in the dump.
### 6.6 Upper flash `0x100000–0x3FFFFF` — read-only asset ROMs (not codeplug)
These dense blocks are firmware assets, not user data, and should be treated as read-only:
- `0x100000` onward and `0x150000–0x24FFFF`: glyph bitmap / font data (byte-ramp grayscale patterns).
- `0x164000`: a **pinyin romanization table** (`"kao shang xia … jiu ho yin hu …"`) — Chinese input-method / font index.
- `0x350000–0x3DFFFF`: `0x80`-filled and low-amplitude byte-ramp tables → DSP / audio-waveform / additional glyph data.
- `0x3F0000`: a **big-endian Unicode CJK index table** (`4E 02 4E 04 4E 05 … U+4E02, U+4E04…`) mapping codepoints into the font ROM.
### 6.7 Annotated SPI flash offset map
| Offset | End | Size | Contents | Populated |
|---|---|---|---|---|
| `0x000000` | `0x000FFF` | 4 KB | **Calibration** (VCO/power/squelch tables) — CRITICAL | 100 % |
| `0x001000` | `0x001FFF` | 4 KB | reserved / erased | 0 % |
| `0x002000` | `0x002FFF` | 4 KB | main_settings bank0 (magic `CD AB` @`0x200C`) | 15 % |
| `0x003000` | `0x003FFF` | 4 KB | settings bank1 (beta) | 0 % |
| `0x004000` | `0x00FFFF` | 48 KB | **Channels** (48-byte recs) — 2 programmed | <1 % |
| `0x01C000` | `0x03BFFF` | 128 KB | **Zones** (48-byte chan-format recs, name "DMRhub") | <1 % |
| `0x05C000` | `0x06BFFF` | 64 KB | **Contacts** (32-byte recs) — 3 programmed | <1 % |
| `0x07C000` | `0x07EFFF` | 12 KB | Groups / RX group lists | empty |
| `0x082000` | `0x084FFF` | 12 KB | dmr_keys (per constants.py) — **empty here** | empty |
| `0x088000` | `0x093FFF` | 48 KB | Call log | ~empty |
| `0x094000` | `0x0C5FFF` | — | SMS presets/drafts/inbox/outbox area | empty |
| `0x0C6000` | `0x0CDFFF` | 32 KB | Schedules (small header only) | <1 % |
| `0x0C7000` | `0x0C70FF` | 256 B | DTMF names | empty |
| `0x0D0000` | `0x0D2FFF` | ~12 KB | **Encryption-key name table** ("Key 1…256", 48-B stride) | 11 % |
| `0x0D6000` | `0x0D6FFF` | 4 KB | FM broadcast presets | empty |
| `0x100000` | `0x24FFFF` | ~1.3 MB | Font / glyph bitmap ROM + pinyin table (`0x164000`) | dense |
| `0x250000` | `0x31FFFF` | — | mostly erased | ~0 % |
| `0x350000` | `0x3DFFFF` | ~0.5 MB | DSP/waveform + glyph asset tables (`0x80`-filled) | dense |
| `0x3F0000` | `0x3FFFFF` | 64 KB | Unicode CJK codepoint index (big-endian, U+4E00…) | dense |
**Bottom line for backup/restore:** the irreplaceable per-unit data is the **4 KB calibration block at `0x000000`**. User codeplug lives entirely in `0x002000–0x0D6FFF` (settings, channels @`0x004000`, zones @`0x01C000`, contacts @`0x05C000`, key names @`0x0D0000`). Everything at `0x100000+` is stock firmware assets identical across radios and safe to regenerate from the vendor image.
## Recommended RE toolchain & setup
### MCU application (Ghidra / IDA)
- **Language / processor:** `ARM Cortex` variant, **little-endian, Thumb** (`ARM:LE:32:Cortex` in Ghidra). The image is pure Thumb (every vector is an odd address).
- **Two equivalent import routes:**
1. **Load the Intel-HEX** `rt4d_stock_v3.25.ihex` — it carries absolute addresses (base `0x08000000`), so Ghidra/IDA places the app at `0x08002800` automatically. Preferred.
2. **Load the raw bin** `rt4d_stock_v3.25_abs_0x08002800.bin` with **load/image base = `0x08002800`** (not `0x08000000` — the bootloader is not in this file).
- **Memory blocks to define manually** (the bin/ihex only covers flash): create RAM `0x20000000` size `0x20000` (128 KB SRAM, RW); map the SCS/peripheral ranges as needed for the SVD.
- **SVD:** load an **STM32F407** SVD as the baseline (register-map-compatible) — it correctly labels RCC `0x40023800`, GPIO `0x40020000`, USART1/2/3/6, SPI2, ADC1, DAC, TIM, DMA, FLASH-IF, PWR, CRC. Then **manually annotate the three non-ST extended RCC registers** (`RCC+0x68 = 0x40023868`, `RCC+0xA0 = 0x400238A0`, `RCC+0xA4 = 0x400238A4`), which the F407 SVD marks Reserved. If you can confirm the die is **Artery AT32F407/AT32F403A** on-target (recommended — read IDCODE over SWD), switch to the Artery **CRM** SVD, which names those registers natively.
- **Entry points / vector table:**
- Vector table at **`0x08002800`**: word0 = initial SP `0x2000AE48`, word1 = Reset `0x08002AC1`.
- Force-disassemble the reset handler at **`0x08002AC0`** (Thumb, clear bit 0), then follow `SystemInit @ 0x0801DA2C` and `main @ 0x080029E0`.
- Define the exception/IRQ table entries from §2 (SVCall `0x0801A845`, PendSV `0x08018C1D`, and the active ISRs: ADC `0x08002D1D`, TIM1 `0x0801DBB1`, TIM2 `0x0801DDCD`, USART3 `0x080205B1`, USART6 `0x0802061D`). Set VTOR = `0x08000000` mentally, but note the *app* table is used post-boot.
- **Bootloader/app split:** the bootloader (`0x08000000–0x080027FF`) is **not** in these files. Treat `0x08002800` as the app entry; the `0x39`/`0x57`/`0xFF` bootloader flash protocol (checksum seed `0x48`) lives only in that missing image — dump it separately over SWD if you need it.
- **High-value starting xrefs:** the string anchors in §3.4 (menu blob `0x080253ED`, `Update DMR Chip` `0x08014950`, remote kill/stun strings, `Calibration OK!` `0x08019C87`) and the serial dispatcher `0x08019790` / framer `0x0801F864`.
### FM100B baseband
- **Language / processor:** **ARM little-endian, ARM mode** (`ARM:LE:32:v5t` or `v4t` — classic ARM7/9-class, *not* Cortex). Load `FM100B_V1.2.0.32_20260130.bin` at **base `0x00000000`**.
- **Vector table** at `0x0` (B reset + 7× `LDR pc,[pc,#0x14]`); reset trampoline at **`0x40`**. Define the literal-pool handler pointers at `0x20–0x38`.
- **Relocation region:** create a second memory block at **`0x03800000`** (the handler/relocation window `0x03800000–0x04200000`) so the ~7,700 absolute pointers resolve. The image is position-dependent; do not rebase.
- **No SVD** applies (custom baseband SoC); reverse peripherals from the driver code. Anchor on the `ATC_/SP…` Req/Cnf/Ind symbol strings (§5.4) to name the UART message handlers.
### SPI data flash
- Not code — open `radio-spi-dump.bin` in a hex editor / the CPS. Use the §6.7 offset map and `rt4d-cps/rt4d_codeplug/constants.py` (with the two corrections in §6.2) to parse regions.
## Prioritized next steps
1. **Back up the radio first (see safety note).** Read all of SPI flash — especially the **4 KB calibration block at `0x000000`** — before touching anything.
2. **Confirm the MCU die on-target.** Connect SWD, read the DBGMCU/IDCODE and the UID region; this resolves the AT32-vs-GD32-vs-APM32 ambiguity from §1.5 and lets you pick the correct SVD. Also dump the **bootloader** `0x08000000–0x08002800` while you have SWD.
3. **Map the serial engine.** Xref the framer `0x0801F864` and dispatcher `0x08019790`; fully enumerate the region-write `tbb` table at `0x8018A2E` to document the undocumented `0x9C..0xA5` write opcodes and the `0xABCD` magic-gated handler (`0x801AD9C`) — these are unexplored by the community CPS.
4. **Trace the `0x34/0x54/0x58` mode-select flags** (`0x20000c57`) to understand the single-bank vs dual-bank flash-write personalities before writing any region from a custom tool.
5. **Decode the calibration block** (§6.3): correlate the `Calibration OK!` writer (`0x08019C87`) with the 16-byte ramp tables to label each per-band curve (VCO/PLL trim, TX power hi/lo DAC, squelch/RSSI). This is the highest-value RF-modding target.
6. **Reverse the DMR remote-command path** (stun/kill/wake/monitor) from the string anchors in §3.4 → find the CSBK RX handler and the `Prohibit TX` enforcement; assess whether stun/kill can be disabled or spoofed.
7. **Reverse the encryption implementation** from `Encryption Set` `0x080165D0` → locate the cipher/type table (not stringed) and the key store; reconcile the key-name table location discrepancy (`0x0D0000` live vs `0x082000` in constants).
8. **Map the FM100B message interface** (§5.4): pair each `ATC_*Req` the MCU sends over USART3 with its `*Cnf`; this documents the full MCU↔baseband API and is the path to custom DMR features and to understanding the `Update DMR Chip` (`0x08014950`) reflash.
9. **Fix/extend the CPS constants** (§6.2 corrections: zone record size, key-name table address) so community tooling round-trips correctly against V3.25.
## Safety note — calibration backup before any flashing
The **4 KB calibration block at SPI offset `0x000000`** is **per-unit factory RF data** (VCO/PLL trim, TX-power DAC curves, squelch/RSSI thresholds) and is **NOT recoverable if erased or overwritten** — there is no copy in the firmware image, and a wrong value will mis-tune the transmitter (out-of-spec power/deviation, potential PA damage or spurious emissions). Before any write/flash operation:
1. **Read and archive the full 4 MB SPI dump** (`radio-spi-dump.bin` is one such capture) and separately verify the first `0x1000` bytes are non-`0xFF` (a valid calibration block is 100% populated per §6.3).
2. **Never issue a full-chip erase** or a bulk write that spans `0x000000`. The `0x40` region write erases a 4 KB sector at offset 0 — treat it as off-limits unless you are deliberately restoring a verified backup.
3. When modding the **codeplug only**, restrict writes to `0x002000–0x0D6FFF` (settings/channels/zones/contacts/keys). Everything at `0x100000+` is stock, regenerable firmware assets.
4. For **MCU or FM100B firmware** flashing, keep the stock vendor images (`rt4d_stock_v3.25*`, `FM100B_V1.2.0.32_20260130.bin`) on hand for rollback, and confirm you can reach the bootloader (hold `*` at power-on, `0xFF` probe echoes `0xFF`) *before* erasing, so a failed flash is recoverable.
+151
Просмотреть файл
@@ -0,0 +1,151 @@
## RT-4D Menu Russification — Feasibility & Plan
**Verdict: YES (in-place) — every Russian letter already has a shipped glyph and the stock GBK double-byte render path draws it with zero firmware modification; the only real work is rewriting labels in place and abbreviating any that exceed 7 Cyrillic chars.**
## Why (the three gating facts)
1. **Cyrillic glyphs present? YES.** All 66 modern-Russian letters (33+33, incl. Ё/ё) exist in the shipped SPI font ROM, addressed as GB2312 row A7 (`0xA7A1..0xA7F1`). Confirmed two ways: the Unicode codepoint index at SPI `0x3F0000` lists `U+0410..U+044F` + `U+0401/U+0451` contiguously (slots 9825–9905), and feeding the raw GBK bytes through the firmware's own glyph-address math lands on populated bitmaps (`А`=`A7A7``0x1D00D8`, 20 nonzero bytes).
2. **Render supports double-byte? YES.** `draw_string @0x08008A50` classifies any byte `≥0x80` as a GBK lead byte, consumes the trail byte, forms `(lead<<8)|trail`, and blits a 14-px cell — this is the same classic GBK path the stock Chinese mode uses. No modification needed.
3. **Fields fit? PARTIALLY — but workably.** Records are a hard 16 bytes with the last 2 reserved for the item id, leaving **14 usable bytes = 7 Cyrillic chars** (2 bytes each). Short labels (`Зоны`, `Меню`, `Каналы`, `Имя`) fit natively; labels needing ≥8 Cyrillic chars must be abbreviated (`Настр.`, `Основ.`) — standard practice for Russian ham-radio UIs and not a blocker.
The single most important constraint: records are reached by `base + 16·index` and via 80 hard literal-pool pointers, so **every patch must stay exactly 16 bytes — no resize, no reorder.**
---
## A. Menu / UI string tables & the language system
**The `0x080253ED` table is the entire on-screen UI text pool**, a contiguous array of fixed 16-byte records spanning `0x080253ED → 0x0802723D` (7,760 bytes, 480 records). It starts cleanly after code/literal-pool at `0x080252CD..0x080253EC` and ends where the country/MCC table begins (`0x0802724A`). Verified head:
```
0x80253ed 42 61 73 69 63 20 53 65 74 20 20 20 20 20 30 31 |Basic Set 01|
0x80253fd 4b 65 79 20 44 65 66 69 6e 65 20 20 20 20 30 32 |Key Define 02|
0x802544d 4d 65 73 73 61 67 65 20 20 20 20 20 20 20 30 37 |Message 07|
0x802545d 52 61 64 69 6f 20 4e 61 6d 65 20 20 20 20 30 31 |Radio Name 01| <- submenu restarts at 01
```
It interleaves two 16-byte record kinds, both translatable:
| Kind | Count | Unique | Examples | Suffix |
|---|---|---|---|---|
| Menu items (label + `NN` ordinal) | 181 | 157 | `Basic Set␠␠␠␠␠01`, `Scan Direction15`, `Personal ID␠␠␠01` | 2 ASCII digits |
| Option / enum-value / field labels | 299 | 282 | `Off`, `High`, `Slot 1`, `FM`/`AM`/`SSB`, `Show Caller Info`, `Callsign :`, `Unicode`/`GBK`/`None` | none |
**String model — pointer-indexed, not a packed blob.** A menu-descriptor table at `0x08015214`+ holds 80 literal-pool pointers, each targeting a 16-byte record at `base + 16·index` (all 16-aligned): `lit@0x0801521c → 0x080253ED` (rec #0), `lit@0x08015238 → 0x0802545D` (rec #7), etc. Each descriptor is `[RAM state word 0x2000xxxx][pointer to fixed-16 title record][NUL-terminated inline ASCII copy of the parent label]`. Consequences:
- A record's text = its 16 bytes; its identity = its index `(vaddr 0x080253ED)/16`. **Records must stay exactly 16 bytes** or every pointer/index breaks.
- The trailing `NN` is a parsed ordinal (display ordering/bounds, restarts per submenu) — **not drawn** (renderer emits only the 14-col label) and **must be preserved byte-exact**.
- A full translation must patch **both** the fixed-16 record **and** the inline descriptor copy, or the breadcrumb parent-title stays English.
**No runtime language switch, no parallel Chinese table.** The image contains zero occurrences of `Language`/`English`/`Chinese`/`中文`/`语言` (ASCII or GBK). A full-app scan for coherent GBK CJK runs found exactly one — `0x08019920 = "正在进行数字调试"` (a service/debug status line), not a menu. The SPI dump below the font region has only a sequential codepoint index at `0x14C000`, not menu text. `language=Chinese` lives in the **PC upgrade tool's** `data.ini`, configuring that Windows app, not the radio. **Conclusion: Chinese mode renders CJK by pulling glyphs from the SPI font by codepoint/GBK index at draw time — there is no Chinese table to overwrite. Russification = rewriting the English strings in place.**
Excluded from russification (not UI language): country/MCC table `0x0802724A..0x0802833E` (~256 entries), pinyin IME syllable table `0x0802219A..0x080240D8`.
**Scope:** ~157 menu labels + ~282 option/field labels (both in the fixed-16 table) + ~80 inline descriptor copies (mostly duplicates) + ~150–250 NUL-terminated rodata prompt/status strings (`Please Wait...` @`0x08014964`, `Update DMR Chip` @`0x08014950`, `Unread SMS :` @`0x0801FB44`, `FM RX Standby` @`0x080159F4`, `PC Programing`, `Draft Full!`, `Calibration OK!`, …). **Total ~600–700 distinct strings**, all edited in place at ≤ original byte width.
## B. Font glyph coverage — Cyrillic exists (make-or-break: GO)
**All 66 Russian letters are present in the shipped font ROM.** The glyphs are served from external SPI data-flash, not MCU flash — the app image contains no standalone ASCII font table, and the firmware references SPI font-band literals (`0x00200000` @`0x0801469E`).
**Wide-font Unicode index at SPI `0x3F0000`:** a sorted table of 32,256 big-endian `u16` codepoints (`4E 02 4E 04 …`), `0xFFFF`-terminated at `0x3FFC00`. Slots 0–8288 are CJK; slots 8289+ are the GB2312 A1–A9 symbol/letter rows in Unicode order (Greek `U+0391` at slot 9569, then Cyrillic). ASCII proper returns no slot (served by the separate narrow bank).
**The key test — Cyrillic in the index (all 66, GB2312 A7-row order):**
```
slot 9825:0410(А) 9826:0411(Б) ... 9831:0401(Ё) ... 9857:042f(Я) <- 33 upper incl. Ё
slot 9873:0430(а) 9874:0431(б) ... 9879:0451(ё) ... 9905:044f(я) <- 33 lower incl. ё
```
Note Ё=U+0401 inserted right after Е and ё right after е — GB2312 collation, confirming this is the A7 row (`А=0xA7A1 … Я=0xA7C1, а=0xA7D1 … я=0xA7F1, Ё=0xA7A7, ё=0xA7D7`).
**Bitmap ROM format confirmed:** glyphs live in the dense SPI band `0x150000–0x240000`. Cell format is 16×16, 2 bytes/row, little-endian, bit 0 = leftmost pixel (32 B/glyph for the wide bank). Proof — rendering `U+4E00 一` (single horizontal stroke) yields a clean 14-px bar (`FF 3F` = 8+6 bits, LSB-first), nailing bit order and cell size. Cyrillic-block windows (near `0x1B9880`) show recognizable "А" and "Ж" letterforms.
**We do NOT need to add glyphs.** Russification requires **no font editing** — labels just need to be authored as GB2312 A7-row double-byte codes, a path the stock Chinese mode already exercises. One honest caveat: the exact slot→byte-address arithmetic across the *full* 32k table is banked/sparse (not a clean single-base `FONT_BASE + slot·32`), but this is irrelevant to russification because Section C proves the *menu* path uses a direct GBK plane computation with a known base, and that lands on populated Cyrillic cells.
## C. String rendering / draw path — GBK double-byte, zero firmware change
**Primitives.** `SPI_flash_read(dst,src,len)` @`0x08021826` (thunk `0x08021828`, ~120 callers) issues opcode `0x03`, clocks the address big-endian, streams bytes. Two blitters compute `font_base + glyph_index × cell_size`:
- **ASCII blitter `0x08007FB8`** — 7 px wide, 14-byte cell, base **`0x19C000`**: `index = char 0x20`, `addr = base + index*14`.
- **CJK/GBK blitter `0x08008454`** — 14 px wide, 28-byte cell (`0x1C`), base **`0x19E000`**:
```
row = lead 0x81 ; col = trail 0x40
index = col + row*190 ( 1 when trail > 0x7F, skipping the 0x7F gap)
addr = 0x19E000 + index*28
```
**The decisive routine — `draw_string(x,y,str,len) @0x08008A50`** (~110 callers, incl. menu renderers `0x08013818–0x08014680`) iterates byte-by-byte and dispatches on a lead-byte test:
- byte `0x01–0x7F` → ASCII blitter, cursor +7 px.
- byte `0x80–0xFE`**lead byte**: consumes the next byte, forms `(lead<<8)|trail`, calls CJK blitter, cursor +14 px, `i += 2`.
- `0xFF`/`0x00` → whitespace/terminator.
This is a **classic GBK renderer**: any high-bit byte is a double-byte lead. A sibling width-measurer at `0x0800553C` uses the identical `0x80` classification, confirming the convention project-wide.
**Crucially, the menu path uses direct GBK plane math, NOT the `0x3F0000` Unicode index.** The Unicode table is a separate path (SMS/contact rendering that stores UTF-16). Menu labels reach glyphs by their raw GBK bytes. Feeding GB2312 A7 Cyrillic bytes through the exact firmware math lands on populated cells:
| Char | GBK bytes | computed SPI offset | nonzero bytes in cell |
|---|---|---|---|
| `А` | `A7 A7` | `0x1D00D8` | 20 — legible Cyrillic-A |
| `Я` | `A7 C0` | `0x1D0394` | 21 |
| `а` | `A7 D1` | `0x1D0570` | 14 |
**Exact recipe: to draw `А`, the string must contain raw bytes `0xA7 0xA7`** (`0xA7 0xC0` for `Я`, `0xA7 0xD1` for `а`, …). `draw_string` sees `0xA7 ≥ 0x80`, takes the double-byte branch, and blitter `0x08008454` fetches the already-present Cyrillic cell — **zero firmware modification**. Cost: 2 bytes / 14 px per Cyrillic char.
(Note: the A7-row byte values differ slightly between the two analyses — Section B/D derive `А=0xA7A7` from the Unicode-order slot position, while Section D also cites `А=0xA7A1` from a direct `gb18030` round-trip. This exact byte mapping must be pinned on-target during the PoC — see the PoC verify step — but both agree Cyrillic lives in row A7 and the cells are populated.)
## D. Practical encoding, field-width constraints & effort
**Field-width math.** 16-byte records, last 2 bytes = item id ⇒ **14 usable bytes = max 7 Cyrillic chars** at 2 bytes each. Mixing 1-byte ASCII punctuation (`.`) is legal GBK and saves a byte.
| English | Russian | GBK bytes | Fits 14? |
|---|---|---|---|
| Zone Set | Зоны | 8 | Yes |
| Menu | Меню | 8 | Yes |
| Channel Set | Каналы | 12 | Yes |
| Radio Name | Имя | 6 | Yes |
| Basic Set | Настр. | 11 | Yes |
| Basic Set | Настройки | 18 | **No → abbreviate** |
| Basic Set | Основные | 16 | **No → Основ. (11B)** |
Every label ≥8 Cyrillic chars must be abbreviated (Настр., Каналы, Зоны, Сообщ., Скан, Аналог, Цифра) — normal for Russian ham UIs.
**Approach comparison.** (a) Overwrite English table in place, keep `language=English` — smallest change, firmware-only, ids preserved. (b) Find and overwrite a Chinese GBK table, set `language=Chinese` — but per Section A **no such table exists**, so this is not available. (c) Font-mod: overwrite ASCII cells `0x19C000` with 1-byte Cyrillic — gives 14 chars/label but touches font flash and sacrifices Latin. **Winner: (a).** Section C proves the menu `draw_string` is codepage-agnostic (dispatches on the `0x80` bit regardless of `language` setting) and already resolves A7 Cyrillic to populated glyphs — so overwriting the English table in place with GBK A7 bytes works directly, no config change, no font flash.
## Recommended approach
**Strategy (a): overwrite the English fixed-16 UI table in place with GB2312 A7-row (GBK double-byte) Russian, no config or font-flash changes.** Justification: the menu renderer `draw_string @0x08008A50` classifies bytes purely by the high bit and routes `0x80+` through the GBK blitter `0x08008454`, which computes a direct plane address landing on the Cyrillic cells that already ship in the SPI font ROM. This is a **single-artifact change** (patch the app `.bin`, reflash MCU app) — no dependency on a nonexistent Chinese table, no SPI font rewrite, no `data.ini` edit. Length-preserving patches mean no pointer/index/relocation fix-ups.
## Proof-of-concept — one label
Target the shortest high-value item: **"Zone Set" → "Зоны"** at vaddr `0x0802543D` (file offset `0x0802543D 0x08002800 = 0x22C3D`).
Original 16 bytes:
```
5A 6F 6E 65 20 53 65 74 20 20 20 20 20 20 30 36 "Zone Set 06"
```
Replacement (GBK "Зоны" + space-pad + preserve id `06`, stays exactly 16 bytes):
```
A7 A9 A7 E0 A7 DF A7 ED 20 20 20 20 20 20 30 36
└─── Зоны ────────────┘ └── pad ───┘ └"06"┘
```
**Write & flash:** this is a **firmware (MCU app) patch**, not an SPI codeplug write. At file offset `0x22C3D` in `rt4d_stock_v3.25_abs_0x08002800.bin` overwrite bytes `[0..7]` = `A7 A9 A7 E0 A7 DF A7 ED`, `[8..13]` = `0x20`, leave `[14..15]` = `30 36`. Re-wrap the app at load base `0x08002800` (flash to `0x08000000`+`0x2800`), recompute any top-level image CRC if the loader checks one, and flash the MCU app via the normal firmware-upgrade path.
**Verify on target:** open the menu and confirm the "Zone Set" slot now reads **Зоны** in Cyrillic (not tofu/`?`). This single test simultaneously resolves the two residual unknowns: (1) that the menu draw path renders A7 double-byte bytes as glyphs, and (2) the **exact A7 byte→glyph mapping** — if "Зоны" renders but with wrong letters, re-derive the A7 trail bytes on-target (try both the `gb18030` round-trip values `А=0xA7A1…` and the Unicode-slot-order values `А=0xA7A7…`) and re-flash. If it renders correctly, the encoding is pinned and full russification is mechanical.
## Full-russification plan
1. **Pin the A7 encoding on-target** via the PoC above. Lock the definitive Cyrillic-char → GBK-byte table before batch work.
2. **Build the patch-table generator** (~40–60 lines Python): read the `0x080253ED..0x0802723D` table in 16-byte strides; for each record parse `label`+`id`; look up a translation dict; encode Russian to the pinned GBK bytes; space-pad to 14 bytes; re-append the 2-byte id; **assert `len == 16`**. Emit `(file_offset, old16, new16)` tuples. Because every patch is length-preserving, no address fix-ups.
3. **Author the translation dictionary** (~450 fixed-width labels + abbreviations that fit the 7-char ceiling and stay unambiguous). Reuse across duplicate labels.
4. **Patch inline descriptor copies** at `0x08015214`+ (NUL-terminated, ≤ original length) so breadcrumb parent-titles are Russian too.
5. **Patch the loose rodata prompt/status strings** (~150–250) — NUL-terminated, each edited at ≤ its original allocation (do not overrun into the next string). These are one-byte-encoding GBK too; abbreviate where Russian is longer.
6. **Recompute image CRC** if present; re-wrap at base `0x08002800`; flash MCU app.
7. **On-radio verification pass** — walk the full menu tree, checking abbreviations render, cursor advances correctly on mixed Russian(2B)+id(1B) fields, and no field overflows/overlaps.
**Tooling to build:** GBK encoder (trivial, `label.encode('gb18030')`), the length-preserving patch generator, an optional CRC recomputer, and a menu-tree walker checklist. **Effort:** ~half-day for tooling + PoC flash; ~1–2 days for the full translation table + on-radio verification.
**Risks:**
- **A7 byte mapping ambiguity** — the two analyses disagree on exact A7 trail bytes; the PoC resolves this before any batch work. Do not skip step 1.
- **A7 glyph slots pruned?** Section C shows the computed A7 offsets hit populated cells (`А`→20 nonzero bytes), so this appears fine, but confirm on-screen during the PoC — if any A7 slot is blank the letter renders as a gap.
- **Cursor/mixed-width** — Russian(14 px) + id digits(7 px) must not overlap; the mixed PoC field is the acid test.
- **Fixed-16 geometry** — never resize/reorder a record or change the trailing `NN`; pointer and index arithmetic will break menu dispatch.
- **Image integrity** — a top-level firmware CRC will reject naive patches; confirm/recompute before flashing.
**Safety note (calibration / app recovery):** all patches target the **MCU application region only** (`0x08002800`+). Do **not** touch the SPI data-flash — it holds the font ROM *and* the radio's calibration/codeplug; a bad SPI write can destroy factory RF calibration. Before flashing, dump and archive the current MCU app and the full 4 MB SPI (`radio-spi-dump.bin` already serves as the SPI baseline) so you can restore. Keep the stock app image on hand to reflash via the normal upgrade path if a patched app fails to boot. Since strategy (a) never rewrites the font or codeplug, the blast radius is limited to the app image, which is recoverable through the standard firmware-upgrade flow.
+328
Просмотреть файл
@@ -0,0 +1,328 @@
# 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=(TP)`; `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)
+229
Просмотреть файл
@@ -0,0 +1,229 @@
# RT-4D Menu Selection → Inverse-Video Highlight — RE Findings & Patch Spec
Firmware: `rt4d_stock_v3.25_abs_0x08002800.bin` (ARM Cortex-M4F Thumb, load base `0x08002800`, size 155740 = 0x2605C).
All addresses are **virtual** (vaddr). File offset = vaddr 0x08002800.
---
## 1. Render pipeline (as reverse-engineered)
### 1.1 Text / glyph layer (confirmed, trusted)
- `draw_string` **@0x08008A50**. Signature: `(r0=y_page, r1=x_pixel, r2=char*, r3=len, [sp,#0x28]=mode)`.
The `mode` word at `[sp,#0x28]` is loaded (`ldr r3,[sp,#0x28]`) and passed as `r3`/`r7`
to the glyph blitters. Advance is +7px per ASCII char (`adds r0,r6,#7`).
- ASCII glyph blitter **@0x08007FB8**, CJK blitter **@0x08008454**. Both copy a 14-byte
(7 cols × 2 pages, **column-major**, 8 vertical px/byte) glyph into a stack buffer via
`0x08021828`, then transform by **mode (r7)** before blitting:
- **mode 0** → glyph copied as-is → **normal** (black text on clear background).
- **mode 1** → `mvns` every byte (full invert) **+ edge masks**: even byte `&=0xFE`,
odd byte `&=0x7F`. → **INVERSE VIDEO**: the whole 7×16 cell becomes a white bar with the
glyph punched black, leaving a 1px gap top+bottom for clean row separation.
*Verified by simulation:* `mode1(empty cell)=FE 7F FE 7F…` (solid bar w/ 1px gaps);
`mode1(solid px)=00` (black). This is exactly a modern "highlight bar with readable text".
- **mode 2** → odd bytes `|=0x80` → sets the bottom pixel of the top page → a thin
**underline / bottom rule** (used for title bars / section headers), NOT a full highlight.
- mode ≥3 → falls through to normal (mode 3/4 seen in the home screen = plain text).
- LCD framebuffer primitives (128×64 mono, ST7565/UC1701 class, page-addressed over SPI):
- `lcd_set_addr(r0=x, r1=y)` **@0x08014A7C** — maps `x → (0xB7x)`, sets page/column.
- `lcd_write_col(r0=byte)` **@0x08014B28** — writes one 8-px vertical column, auto-advances.
- `lcd_flush()` **@0x08014CB8** — DMA/SPI blit of the composed frame (calls SPI `0x08004CE0`).
- Line geometry: 7px pitch → **18 chars per 128px line**; y is a **page index 0..7**
(menu text is drawn at page/`y=4`).
### 1.2 Menu system architecture (retained-mode, staged buffers)
The settings menu (`Basic Set`, `Key Define`, `Analog Set`, `Digital Set`, `Channel Set`,
`Zone Set`, `Message`, `Device Name`, … full descriptor/label table at **0x0801521C–0x08015440**,
`[submenu/handler ptr][16-byte fixed label]` records) is **not** drawn by a single visible
row loop. Instead it is retained-mode:
- **Widget-setup** helpers stage a menu descriptor into RAM state struct **@0x20000CE8**
(fields: `+1`=widget type, `+4/5`=item count, `+2`=selected index, `+6`=sel, `+7`=flags)
and label/value buffers **@0x20000A83** (`+0x15`=current-item text, `+0x27`=next-item text,
`+0x17`=inline-edit buffer, `+0x2a`=split/cursor position). Setup entry points:
`0x08009C58` (generic selectable list), `0x0800B0B4` (numeric value), `0x0800B100`,
`0x0800B8D8`, `0x08009D3C`.
- **List refresh** `0x0801CB10` computes `sel` and `(sel+1)%count`, copies the selected
item into the "current" slot (`+0x15`) and the following item into the "next" slot (`+0x27`)
— i.e. a 2-line window with the **selected item always in the top ("current") slot**.
- **Screen paint / blit** happens in the home/menu render dispatcher `0x080142C0`, which
`tbb`-dispatches (`@0x08014348`, on mode byte `[struct-1 +0x16]`, cases 0–6) to 7 small
widget painters, all drawing at `y=4`:
- case 0 → **`0x08014074`** (inline field / list-item painter) — see §2.
- cases 1–6 → `0x080143A0 / 0x080143E6 / 0x08014442 / 0x080144DE / 0x08014514 / 0x0801435A`
(inline value editors: split a value into segments, draw the **edited segment with mode 1**
and the rest with mode 0).
- The big per-item value screen `0x08014E20` (called from `0x0800ABD0` / `0x0800B054`) is the
submenu value/edit dispatch; it feeds text through `0x08009C58` (×8) rather than drawing
directly.
**Menu-list RENDER routine answer (task item 1):** the visible menu row/field is drawn by
**`0x08014074`** (dispatched from the render loop `0x080142C0` via the `tbb` @0x08014348).
`0x08014074` is the *only* function in the image that both references the menu text buffer
(`0x20000A83`) **and** calls a glyph blitter — it is the concrete draw site to patch.
---
## 2. How the current selection is drawn (task item 2)
`0x08014074` (reads state struct `0x20000A83`; `L = [+0x2a]` = split/caret position 0..0x10;
`buf = +0x17` = 17-char item text buffer). All draws at `y=4`. Decoded:
```
if [+0x2a] >= 0x11: ; buffer full — no caret
draw_string(y=4, x=1, buf, len=0x11, mode=0) ; whole line, normal
else:
draw_string(y=4, x=1, buf, len=L, mode=0) ; text BEFORE the cursor (normal)
draw_string(y=4, x=L*7+1, buf+L, len=1, mode=1) ; the SELECTED char (INVERSE) ← cursor
draw_string(y=4, x=L*7+8, buf+L+1, len=0x10-L, mode=0) ; text AFTER the cursor (normal)
```
So today the "cursor / selection indicator" is **a single character rendered in mode-1
inverse video** (a 1-char-wide highlight caret), positioned at column `L`. The special
treatment of the selected index is the middle `draw_string` call with **`len=1, mode=1`**
(instruction sequence: `movs r0,#1 ; str r0,[sp]` sets mode=1; `movs r3,#1` sets len=1).
The inline value-editor widgets (cases 1–6 of `0x080142C0`) work the same way, inverting the
*segment* currently being edited.
There is **no `>`/triangle glyph and no separate arrow bitmap** — the "arrow/left-cursor"
the UI shows is this inverse caret block. (The only bitmap-cursor-like helper, `0x08008224`,
is the battery/RSSI icon drawer, unrelated.) So "remove the arrow" = "stop drawing the
1-char inverse caret and instead inverse the *entire* selected line".
---
## 3. Cleanest way to a full-width inverse highlight (task item 3)
Two mechanisms exist; mode-1 is the right one (mode 2 is only an underline):
- **(a) Draw the selected row's full text with mode 1** and **pad the string to the full 18-col
line width** so the highlight bar spans edge-to-edge. Because mode-1 inverts each *cell*
(including the space glyph → solid bar with 1px gaps), a right-padded string already yields
a full-width readable highlight bar — **no separate rectangle-fill routine is required.**
- **(b) Fill/invert-rect helper:** the image has **no general "invert rectangle" routine**;
the only rect-ish primitive is the icon column-writer `0x08008300`/`0x08008224` (fixed
14-col templates). Re-purposing it is more invasive than (a). So **approach (a) is chosen.**
For the settings-list specifically the item text is staged into buffers padded with spaces
already (buffers are `memset`-filled to 0x10 with `0x20`/blanks by `0x080062EC` before the
label copy), so a mode-1 draw of the current-slot buffer paints the whole row as a bar.
---
## 4. Concrete patch (task item 4)
### Approach: minimal, in-place, length-safe — switch the selected row's whole draw to mode 1
The selected line is the **top / "current" slot** of `0x08014074`. Replace the 3-segment
(normal | inverse-caret | normal) draw with **one full-width mode-1 draw of the whole buffer**.
This makes the *entire selected line* an inverse highlight bar and eliminates the 1-char caret.
`0x08014074` prologue+body bytes (for reference, from offset 0x11874):
```
0x08014074: 38 b5 22 48 90 f8 2a 00 11 28 09 db 00 20 11 23 ; push; ldr r0,=struct; ldrb r0,[r0,#0x2a]; cmp #0x11; blt; movs r0,#0; movs r3,#0x11
0x08014084: 1e 4a 17 32 01 21 00 90 04 20 f4 f7 df fc 34 e0 ; ldr r2,=struct; adds r2,#0x17; movs r1,#1; str r0,[sp]; movs r0,#4; bl draw_string; b .+
0x08014094: 00 20 00 90 19 48 90 f8 2a 30 00 f1 17 02 01 21 ; movs r0,#0; str r0,[sp](mode=0); ...; movs r1,#1
0x080140a4: 04 20 f4 f7 d3 fc 01 20 00 90 14 48 90 f8 2a 30 ; movs r0,#4; bl draw_string; movs r0,#1; str r0,[sp](mode=1) ← caret
...
```
The `blt` at `0x0801407E` (`11 28 09 db`: `cmp r0,#0x11 / blt`) already selects between the
"full buffer" branch (`0x08014080`, draws the whole 17-char buffer at `x=1,y=4`) and the
"3-segment caret" branch (`0x08014094`). **The simplest robust change is: make the whole-buffer
branch use mode 1, and force execution down that branch always** (skip the caret path). That
gives a full-line inverse highlight for the item and removes the caret entirely.
**Patch — 2 sites, 4 bytes total, no code cave, no length change.**
The full-buffer branch at `0x08014080` draws `draw_string(y=4, x=1, buf, len=0x11, mode=[sp])`.
We (P1) force that branch to always run and (P2) make its mode = 1.
**P1 — force the full-buffer / highlight branch.** Remove the `blt` that would otherwise divert
to the 3-segment caret path, so the full-buffer draw at `0x08014080` always executes:
- vaddr **`0x0801407E`**: original `09 DB` (`blt #0x08014094`) → new **`00 BF`** (`nop`). (2 bytes)
**P2 — make that draw inverse.** The branch sets its mode via `movs r0,#0 ; str r0,[sp]`:
- vaddr **`0x08014080`**: original `00 20` (`movs r0,#0`) → new **`01 20`** (`movs r0,#1`). (2 bytes)
Verified patched disassembly:
`cmp r0,#0x11 ; nop ; movs r0,#1 ; movs r3,#0x11 ; ldr r2,=buf ; adds r2,#0x17 ; movs r1,#1 ; str r0,[sp] ; movs r0,#4 ; bl draw_string`
→ draws the full 17-char (space-padded) buffer at x=1,y=4 in **mode 1**; the caret path at
`0x08014094` is now dead code.
Result: whenever this widget paints, it draws the full 17-char (space-padded to 18-col line)
buffer at `x=1, y=4` in **mode 1 = full-width inverse-video highlight bar**, and the old
single-char inverse caret path (`0x08014094…`) is never reached → **arrow/caret removed**.
**Final patch list — (vaddr, file_offset, original_bytes, new_bytes):**
```
0x0801407E (off 0x1187E) : 09 DB -> 00 BF ; blt 0x8014094 -> nop
0x08014080 (off 0x11880) : 00 20 -> 01 20 ; movs r0,#0 -> movs r0,#1 (mode 0 -> 1)
```
Bytes are shown in stored (file) order. Total change: 4 bytes, in place, no length change.
### Optional wider fix (cases 1–6 / other menus)
The same 1-char-inverse→full-line-inverse idea applies to the inline value-editor widgets
`0x080143A0…0x08014514` and the list refresh `0x0801CB10`. Those are **out of scope for a
minimal, low-risk patch** (each edits distinct value fields where a per-segment caret is
actually desirable). Recommend shipping only the `0x08014074` change first, verify on-radio,
then decide whether the value-editors should also flip.
### Alternative (code-cave) approach, if per-row control is wanted
If you later want the highlight on a scrolling **multi-row** list (rather than the single
current-item slot), a code cave is available:
- 0xFF-erased cave: **`0x08024AD2`, 320 bytes free**.
- 0x00 cave: **`0x08024778`, 336 bytes free**.
A small Thumb helper could loop rows, calling `draw_string(y=row_page, x=1, row_text, 18,
mode = (row==sel)?1:0)`, then hook it in place of the `bl 0x08014074`. Not needed for the
minimal fix above.
---
## 5. Residual risks & on-radio verification
**Risks**
1. **Buffer padding**: the full-line branch draws `len` = the buffer count. If the item text
isn't space-padded to the full 18 columns in *every* menu that reaches `0x08014074`, the
highlight bar will only span the text, not the whole line. Mitigation: the setup helpers
`memset` the buffers to blanks (0x20) to width 0x10 before copying the label, so padding is
generally present; confirm visually. If a bar is short, extend the draw len to 18 and ensure
trailing spaces.
2. **Shared painter**: `0x08014074` (case 0) may also render non-list inline fields (e.g. a
name/DTMF entry field) where the single-char caret was intentional. Forcing full-line
inverse there removes the per-char caret — acceptable for a "selected line" look but check
text-entry screens remain usable (you lose the char-position caret). If that regresses a
text-entry screen, gate the change on the widget-type byte instead of nop-ing the `blt`.
3. **1px row gaps**: mode-1 edge masks leave 1px clear at top and bottom of the cell — this is
desirable (separates rows) and matches modern radios; no action needed.
4. **Checksum/signature**: if the loader validates a firmware CRC/signature, patched bytes must
be re-CRC'd. v3.25 is an absolute image at 0x08002800 — verify whether the bootloader checks
an appended checksum before flashing.
**Verification on-radio**
1. Flash patched image. Enter **Menu**. The currently-highlighted item should show as a solid
inverse bar (white background, black text) spanning the line width; the old 1-char
arrow/inverse caret should be gone.
2. Scroll up/down: the highlight bar must follow the selection (top "current" slot) and text
stays readable at every position.
3. Enter a submenu with a numeric value (e.g. **Backlight / Light Timer**) — confirm value
screens still render (those go through `0x08014E20`, unaffected).
4. Open a **text-entry** screen (Device Name / Message) — confirm it's still operable
(risk #2). If the editing caret is needed there, switch to the type-gated variant.
5. Watch for any garbled top line at boot/home screen (shared render dispatcher `0x080142C0`)
— the patch only alters case-0 widget, home layout should be unchanged.
---
## Address quick-reference
| what | vaddr |
|---|---|
| draw_string | 0x08008A50 |
| ASCII glyph blitter (mode in r7) | 0x08007FB8 |
| CJK glyph blitter | 0x08008454 |
| lcd_set_addr / lcd_write_col / lcd_flush | 0x08014A7C / 0x08014B28 / 0x08014CB8 |
| menu descriptor/label table | 0x0801521C–0x08015440 |
| list-widget setup (generic) | 0x08009C58 |
| list refresh (current/next slot) | 0x0801CB10 |
| screen render dispatcher (tbb) | 0x080142C0 (tbb @0x08014348) |
| **selected-row painter (PATCH SITE)** | **0x08014074** |
| menu state struct / text buffers (RAM) | 0x20000CE8 / 0x20000A83 |
| code caves | 0x08024AD2 (320B, 0xFF) / 0x08024778 (336B, 0x00) |
+221
Просмотреть файл
@@ -0,0 +1,221 @@
# RT-4D — Codeplug & CPS-Compatibility Boundary (key: `codeplug`)
**Scope.** This file defines the *compatibility boundary* for a UI rewrite: the on-SPI codeplug
format and the PC/CPS serial protocol that MUST stay byte-identical, and the firmware functions a
rewritten UI MUST reuse so it never re-implements (and never diverges from) the stock format. All
vaddrs are absolute in the MCU application image (Thumb, load base `0x08002800`,
`rt4d_stock_v3.25_abs_0x08002800.bin`). Cross-checked against `rt4d-cps/rt4d_codeplug/constants.py`
+ `models.py` + `parser.py`, and against the live `radio-spi-dump.bin`.
> TL;DR for the rewrite: **do not touch** the SPI region layout, the record structs, the settings
> dual-bank/`0xABCD` magic, or the USART6 `0x34/0x52/region-id` serial dispatcher. **Do call** the
> firmware's SPI primitives and the per-record read/save wrappers listed in §5. If you keep those
> two invariants, the stock Radtel CPS keeps working unchanged.
---
## 1. Low-level SPI flash primitives (the mandatory bottom layer — REUSE)
These four are the only functions that actually touch the external SPI data-flash. Everything the
UI or CPS does goes through them. A rewritten UI should call the *record wrappers* (§5) rather than
these directly, but they are documented because the wrappers and the CPS dispatcher both depend on
them and they define the on-flash addressing.
| vaddr | signature | what it does | evidence |
|---|---|---|---|
| `0x08021828` | `void spi_flash_read(void *dst, uint32 addr, uint32 len)` | CS low; sends opcode `0x03`; sends 24-bit addr (32-bit if chip-id byte==`0x18`/`0x19`); streams `len` bytes to `dst`; CS high. | opcode `movs r0,#3`@`0x08021838`; addr-width branch on `#0x18`/`#0x19`; byte-loop via `0x8021538`/`0x8021580`. |
| `0x08021924` | `void spi_flash_erase4k(uint32 sector_idx)` | `addr = sector_idx << 12`; WREN; opcode `0x20` (4 KB sector erase); waits WIP. | `lsls r4,#0xc`@`0x08021928`; `movs r0,#0x20`@`0x0802193a`; poll `0x8021984`. |
| `0x08021a70` | `void spi_flash_program(uint32 addr, const void *src, uint32 len)` | Page-program respecting 256-byte page boundaries; loops calling `spi_page_program(0x080217b8)` per page. | `rsb r5,r0,#0x100` page-split@`0x08021a7c`; page loop. |
| `0x080217b8` | `void spi_page_program(uint32 addr, const void*src, uint16 len)` | WREN + opcode `0x02` single page write (≤256 B). | called only from `0x08021a70`. |
Supporting helpers used alongside them (reuse verbatim):
| vaddr | signature | role |
|---|---|---|
| `0x080109de` | `uint8 checksum(const void *buf, uint32 len)` | 8-bit sum, seed 0. Used by CPS read/write frames and codeplug. |
| `0x08010540` | `void flash_write_guard_enter(void)` | Sets "flash busy" RAM flag, quiesces competing access before a region rewrite. Call **before** any erase/program batch. |
| `0x0801058c` | `void flash_write_guard_exit(void)` | Clears the busy flag. Call **after**. |
| `0x08013738` | `void memset8(void*dst,int val,uint len, ...)` | zero/fill helper used to clear work areas before assembling a record. |
| `0x080060ac` | `bool memcmp_eq(const void*a,const void*b,uint16 len)` | equality compare (used for magic/marker checks). |
**Chip-ID gate.** A RAM byte holds the SPI JEDEC id; `==0x18`(16 MB)/`0x19`(32 MB) switches to
4-byte addressing and enables the `0xA4` addressbook / large regions. A UI rewrite must not assume a
fixed flash size — always go through `spi_flash_read/erase/program`, which handle this.
---
## 2. SPI codeplug region map — DO NOT CHANGE (format is CPS-visible)
Region base addresses and record sizes as they appear **in the firmware's own read/write code**
(the authoritative on-flash layout). The CPS `region_id` column is the byte the CPS sends as
`frame[0]` for a WriteSPI; the firmware's serial dispatcher (§4) maps it to the same base.
| Region | SPI base | record | stride | count | CPS region_id | firmware access site |
|---|---|---|---|---|---|---|
| **calibration** (per-unit, NEVER write) | `0x000000` | 4 KB blob | — | 1 | `0x40` | read `0x08004cbe` |
| **main_settings** bank0 | `0x002000` | struct (4 KB page) | — | 1 | `0x90` | save `0x08004cb0`; magic `0xABCD` @`+0x0C` |
| settings bank1 (shadow) | `0x003000` | struct (4 KB page) | — | 1 | (via `0x90`) | commit `0x08004b0e` |
| **channels** | `0x004000` | Channel | **48** (`0x30`) | 1024 | `0x91` | read `0x08004f2a`/`0x080055a0`; addr `= 0x4000 + idx*48` |
| **zones** | `0x01C000` | Zone (48-B chan-format recs here, see note) | 48 | 256 | `0x92` | batch `0x08004b6a` |
| **contacts** | `0x05C000` | Contact | **27** (`0x1B`) core, `0x15` read | ~2048 | `0x93` | find `0x08005810`; addr `= 0x5E000 + idx*27` |
| **groups** (CPS view) | `0x07C000` | — | — | — | `0x94` | *empty in dump; runtime uses 0xC6000, see note* |
| **dmr_keys** (CPS view) | `0x082000` | — | — | — | `0x95` | *empty in dump; key names live at 0x0D0000, see note* |
| **call_log** | `0x088000` | — | — | — | `0x96` | — |
| **default_sms/msg** | `0x094000` | Message | 256 | — | `0x97` | text read `0x08009498` |
| **RX group lists (runtime)** | `0x0C6000` | GroupList | **80** (`0x50`) | — | (see note) | read `0x08007426`; addr `= 0xC6000 + idx*80` |
| **enc-key names (runtime)** | `0x0D0000` | key-slot name | 48 | 256 | — | dump-confirmed |
| **global addressbook** | `0x126000` | contact | **32** (`0x20`) | — | `0xA4` | read `0x08009ba2`; addr `= 0x126000 + slot*32` |
| **fm_settings** | `0x0D6000` | FMSettings | 4 KB | 1 | `0x99` | batch `0x08004c48` |
| **dtmf_names** | `0x0C7000` | 16×16 | 16 | 16 | `0x80` | — |
### Record structs (must be emitted byte-identically — from `models.py`/`parser.py`, confirmed by firmware access)
- **Channel (48 B @ `0x004000 + idx*48`)**: `+0x00` common flags (rx_tx bits4-5); `+0x03` busy-lock;
`+0x04` mode/modulation/bandwidth bits (bit? = DIGITAL 0x00 / ANALOG 0x01); `+0x05` **u32 LE rx_freq**
(`MHz×100000`); tx_freq follows; `+0x0E` promiscuous; `+0x12` tot/ctdcs_select; `+0x14` mute_code;
`+0x20` 16-byte ASCII name (`0xFF`-padded). Firmware reads exactly `0x30` bytes.
*Selector*: `read_channel(idx)` copies to the live band struct at RAM `0x20002DEA + band*48` (§3).
- **Contact (27-B stride @ `0x05E000 + idx*27`)**: `+0x00` type (`0=Private,1=Group,2=AllCall`);
`+0x01` **u32 LE DMR id** (group IDs stored BCD-LE); `+0x05` name. Firmware reads `0x15` bytes.
The lookup at `0x08005810` matches on `[+1..4]==id && [+0]==type`.
- **GroupList (80-B @ `0x0C6000 + idx*80`)**: header + up to 32 member **u16 contact indices**;
member value `>= 0x2710`(10000) = empty. Members resolve into the 27-B contact table.
- **Global addressbook contact (32-B @ `0x126000 + slot*32`)**: name(16)+id, used for caller-alias
resolution of *incoming* DMR calls that aren't in the small contacts table.
> **NOTE — three layout discrepancies vs `constants.py` (reconcile before writing these regions):**
> 1. **RX group lists**: the running firmware reads them at **`0x0C6000`, 80-byte stride** (`0x08007426`),
> not `0x07C000`. `0x07C000` is `0xFF` in the live dump. `constants.py` `groups@0x07C000` (region
> `0x94`) is the CPS *write* region id; the firmware's live group-list store is `0xC6000`. If the
> rewrite manages RX groups, use the firmware wrapper (§5) rather than a hard-coded address.
> 2. **Encryption key names** live at **`0x0D0000`** (48-B stride, "Key 1".."Key 256"), not `0x082000`.
> 3. **Zones @`0x01C000`** hold 48-byte channel-format records in this firmware, not the 512-byte
> `ZONE_SIZE` structs `constants.py` assumes. Match the firmware's on-flash layout.
>
> These are firmware-vs-CPS-constant mismatches that already exist in stock; **preserve stock behaviour** —
> a rewrite must reproduce whatever the stock firmware reads/writes, so the CPS round-trips unchanged.
---
## 3. In-RAM state the UI reads/writes, and the save-to-SPI path
The UI never re-reads SPI on every draw; it keeps a small set of "live" structs in SRAM and lazily
commits them. A rewritten UI must use these same RAM structures so the save path stays correct.
| RAM addr | contents | notes |
|---|---|---|
| `0x20002014` | **main_settings working copy** (the RadioSettings struct, ~4 KB image of `0x002000`) | Field offsets = `models.py` `RadioSettings` offsets (e.g. `+0x60` LED on/off, `+0x85` display mode A, `+0x184` remote_control). Magic `0xABCD` sits at `+0x0C` when valid. |
| `0x20002120` | freq-lock / channel-limit sub-block of settings | referenced with `0x20002014` at boot (`0x08005f1e`). |
| `0x20002DBB` | **VFO/UI state** — byte `+0x01` = **active band A/B** (0/1), drives which live channel struct is used | read at every channel access (`0x08004f30`, `0x080055a4`). |
| `0x20002DEA` | **live Channel struct, band A** (48 B); band B at `0x20002E1A` (`+band*48`) | filled by `read_channel(idx)`; the standby screen renders from here. |
| `0x200029bb` / `0x200029d7` | additional settings mirror fields (channel name/id area) | e.g. `0x08004f52` reads `+0x67`. |
| `0x20000ce8` | contacts/messaging cursor block (record count `+0x04`, type `+0x01`, band `+0x06`) | used by contact/SMS browsers. |
| `0x20002ebe` | **4 KB SPI staging buffer** — every region read/program passes through here | shared by codeplug save (`0x08004cb0`), batch copy (`0x08004ab0`), contacts compact (`0x080061e0`) **and** the CPS ReadSPI/WriteSPI dispatcher (`0x080188d4`). |
| `0x2000a5c5` / `0x2000a7c5` / `0x20006f86` | per-op scratch (channel-edit, addressbook, SMS text) | transient. |
| `0x200092ef` | **CPS TX assembly buffer**; `0x20000c64` = its write index | do not repurpose. |
### Save-to-SPI functions (REUSE — these keep the format + dual-bank correct)
| vaddr | signature | what it commits |
|---|---|---|
| `0x08004cb0` | `void settings_save(void)` | guard-enter → read `0x2000` into `0x20002ebe` → erase sector 1 → program `0x2000` (4 KB) → guard-exit. Commits `main_settings`. (A prior copy from `0x20002014``0x20002ebe` is done by the caller.) |
| `0x08004ab0` | `void codeplug_backup_to_shadow(void)` | Reads each live region (settings `0x2000`, channels `0x4000+`, zones `0x1C000+`, contacts `0x5E000+`, schedules `0xC6000+`, fm `0xD6000+`, …) and re-programs it to a **+offset shadow copy** (settings→`0x3000`, channels→`0x10000+`, zones→`0x1D000/0x3E000+`, contacts→`0x92000+`). This is the "Backing up…" path. |
| `0x080061e0` | `void contacts_compact(uint idx)` | Deletes contact `idx` by reading `0x1015` bytes, shifting records down 21 B (27-B stride preserved), erasing + reprogramming the affected 4 KB pages of the `0x5E000` region. Format-preserving contact delete. |
| `0x08006000` | `void <region>_erase8(void)` | erases 8 sectors from base (`idx+0x126`) — bulk clear used before a full-region rewrite. |
**Dual-bank / `0xABCD` magic.** `main_settings` uses bank0 `0x2000` (live) with a shadow at `0x3000`.
Validity is gated by the **`0xABCD` magic word at settings offset `0x0C`** (byte pattern `CD AB` @
`0x00200C`, matches the live dump). `constants.py` additionally references a `DTCN`
marker at `+0xFFC` for beta41+ A/B-bank selection; **no `DTCN` string exists in v3.25 firmware code**
— that marker is a CPS-side/beta convention. For v3.25 the rewrite must (a) keep writing `0xABCD` at
`+0x0C`, (b) keep the bank0/`0x2000` primary + `0x3000` shadow arrangement, and (c) go through
`settings_save`/`codeplug_backup_to_shadow` rather than hand-rolling the bank logic. **Do not change
the magic value or its offset** or the CPS/stock loader will treat settings as invalid.
---
## 4. CPS serial protocol handler (USART6) — DO NOT CHANGE, MUST STAY REACHABLE
The stock Radtel CPS talks to `main`'s USART6 link. A UI rewrite must leave this entire path intact
and reachable (i.e. keep entering "PC Programming" mode and keep the ISR + framer + dispatcher wired).
| stage | vaddr | role |
|---|---|---|
| USART6 RX ISR | `0x0802061c` | pushes bytes into ring buffer (`data 0x20007ddb`, head/tail `0x20000c5c`/`0x20000c60`). |
| Frame framer | `0x0801f864` | accepts first byte only if in `{0x34, 0x40, 0x52, 0x90..0xA5}`; computes length (`0x34`→5, `0x52`→4, writes→`0x404`=1028); verifies trailing sum checksum (`0x80109de`, seed 0); copies validated frame to `0x200092ef`; hands to dispatcher. |
| Top dispatcher | `0x08019790` | on `0x34`: sub `frame[3]` = `0x10` Notify(→`0x06`) / `0x54`,`0x58` enter-SPI-mode / `0xEE` Close→`NVIC_SystemReset` (`0x801a38c`). `0x34` guard compares a word vs **`0xABCD`** (`0x08019888`) → secondary handler `0x801ad9c`. else → region handler. |
| Region R/W handler | `0x080188d4` | **`0x52` ReadSPI**: `block=(f[1]<<8)|f[2]`; `spi_flash_read(0x20002ebe, block<<10, 0x400)`; append `checksum(len 0x403)`; stream `hdr(3)+1024+cksum` from `0x200092ef`. **region-id write** (`0x40/0x90..0xA5`): map id→(KB base,KB size), erase covered sectors, `spi_flash_program`, reply `0x06`. **`0xA4`** addressbook write with `0x4A` capacity reject. |
Confirmed opcodes (must remain byte-for-byte): `0x34/0x10` notify→`0x06`; `0x34/0x54`,`0x34/0x58`
enter SPI mode; `0x34/0xEE` close→reboot; `0x52` read 1 KB; `0x40`,`0x90``0x9A`,`0x9C``0xA5` region
writes (4 KB erase + 1 KB program); `0xA4` addressbook. Checksum = 8-bit sum seed 0 over all-but-last
byte. **The rewrite must not alter `0x080188d4`, `0x0801f864`, `0x08019790`, the USART6 ISR, or the
buffers `0x20002ebe`/`0x200092ef`/`0x20000c5c..64`.** Since the CPS reads/writes raw SPI blocks, as
long as the on-flash *format* (§2) is preserved, the CPS is automatically compatible.
---
## 5. Per-record READ/SAVE wrappers a rewritten UI should call (REUSE list)
These are the format-safe entry points. Signatures are inferred from register usage; RAM
destinations are where stock leaves the decoded record for the UI to render/edit.
| vaddr | inferred signature | behaviour |
|---|---|---|
| `0x08004f20` (in-fn) / `0x080055a0` (in-fn) | `void read_channel(uint16 idx)` | `spi_flash_read(&liveChan[band], 0x4000 + idx*48, 48)`; `band = *(u8*)0x20002DBC`. Dest `0x20002DEA + band*48`. |
| `0x08005810` | `bool find_contact_by_id(uint32 id, uint8 type, char *out_name16)` | scans `0x5E000` (27-B stride); on `[+1..4]==id && [+0]==type` copies 16-B name to `out`. Returns found. |
| `0x08009b90` (in-fn) | `void read_addressbook_contact(uint slot, ...)` | `spi_flash_read(0x2000a7c5, 0x126000 + slot*32, 32)`. Global addressbook (32-B). |
| `0x08007404` | `void read_group_list(uint16 idx)` | `spi_flash_read(sp+0xA4, 0xC6000 + idx*80, 80)`; then resolves each u16 member (<0x2710) into a contact via `0x5E000` stride. |
| `0x08009480` (in-fn) | `void read_message(...)` | reads `0xC8`(200) bytes of message text into `0x20006f86`. |
| `0x08004cb0` | `void settings_save(void)` | commit `main_settings` (see §3). |
| `0x080061e0` | `void contacts_compact(uint idx)` | format-safe contact delete (see §3). |
| `0x08004ab0` | `void codeplug_backup_to_shadow(void)` | full backup path (see §3). |
| `0x08003254` (in-fn) | channel-edit read: `spi_flash_read(0x2000a5c5+0x39, 0x4000+idx*48, 0x91)` | used by the channel editor; note it reads 0x91 (>48) into a wider edit scratch. |
*(“in-fn” = the read/save is an inline sequence inside a larger UI handler; the wrapper boundary is
the containing functions entry. When rewriting, either call the containing handler or replicate the
exact `spi_flash_*` calls with the addresses/strides above.)*
---
## 6. DO-NOT-CHANGE vs REUSE — the CPS-safe contract
### DO NOT CHANGE (format & protocol — CPS-visible)
1. **SPI region bases & record strides** in §2 (channels `0x4000`/48, contacts `0x5E000`/27,
zones `0x1C000`/48, settings `0x2000`, group-lists `0xC6000`/80, addressbook `0x126000`/32,
msgs `0x94000`, fm `0xD6000`, dtmf-names `0xC7000`, enc-key-names `0xD0000`).
2. **Record field layouts** (freq = u32 LE `MHz×100000` @ channel `+0x05`; contact type@`+0`,
id@`+1` LE; 16-B `0xFF`-padded names; contact-type enum 0/1/2; power/mode/scan enums).
3. **Settings dual-bank + `0xABCD` magic @ offset `0x0C`** (bank0 `0x2000` / shadow `0x3000`).
4. **Calibration block `0x000000` (4 KB)** — read-only, per-unit, never erase/write.
5. **USART6 CPS protocol**: framer `0x0801f864`, dispatcher `0x08019790`, region handler
`0x080188d4`, ISR `0x0802061c`; opcodes `0x34(/0x10/0x54/0x58/0xEE)`, `0x52`, `0x40/0x90..0xA5`,
`0xA4`; sum-seed-0 checksum; buffers `0x20002ebe`/`0x200092ef`/`0x20000c5c..64`.
### REUSE (call these; don't re-implement)
- SPI primitives: `spi_flash_read 0x08021828`, `spi_flash_erase4k 0x08021924`,
`spi_flash_program 0x08021a70`, `spi_page_program 0x080217b8`, `checksum 0x080109de`,
`flash_write_guard_enter 0x08010540` / `_exit 0x0801058c`.
- Record wrappers (§5): `read_channel`, `find_contact_by_id 0x08005810`, `read_group_list 0x08007404`,
`read_addressbook_contact`, `read_message`, `settings_save 0x08004cb0`,
`contacts_compact 0x080061e0`, `codeplug_backup_to_shadow 0x08004ab0`.
- Live RAM structs (§3): `settings 0x20002014`, `vfo/band 0x20002DBB`, `live channel 0x20002DEA(+band*48)`.
**Contract:** if the rewritten UI (a) mutates only the §3 RAM structs and calls the §5 save wrappers,
and (b) leaves the §4 serial path untouched, then the on-SPI bytes remain exactly what stock produces,
and the stock Radtel CPS reads/writes the radio identically. That is the CPS-safe boundary.
---
## 7. Confidence / open items
- **High confidence**: SPI primitives (§1) — fully disassembled, opcodes/addr-width proven.
Channel (48@`0x4000`), contact (27@`0x5E000`) strides — proven from live read sites and dump.
CPS dispatcher `0x080188d4` `0x52`/`0xA4` paths — fully decoded and match `constants.py`.
Settings staging/save via `0x20002ebe``0x2000` — proven.
- **Medium confidence**: exact `settings_save` caller that copies `0x20002014``0x20002ebe`
(the copy is in the menu handler, not shown here); `codeplug_backup_to_shadow` full region list
(decoded through contacts; tail regions inferred from the loop bases).
- **To verify before writing group-lists / keys**: the `0xC6000` group-list vs `0x07C000` CPS region
and `0xD0000` key-names vs `0x082000` — stock firmware and `constants.py` disagree; a round-trip
test with the stock CPS is the safest confirmation. Reuse the firmware wrappers to stay stock-correct.
+277
Просмотреть файл
@@ -0,0 +1,277 @@
# RT-4D Display Subsystem — API Reference (`display` key)
Reverse-engineered drawing toolkit for the Radtel RT-4D stock firmware
(`rt4d_stock_v3.25_abs_0x08002800.bin`, ARM Cortex-M4F Thumb, load base `0x08002800`).
This is the low-level graphics API to **reuse** when rewriting the UI. All addresses are
absolute vaddrs. Signatures use ARM Thumb AAPCS (r0..r3 = args, extra args on stack).
> **TL;DR for the UI rewrite:** the LCD is a **monochrome page-addressed panel, 128×64
> px = 8 pages × 128 columns**, driven by **bit-banged serial (GPIO), with NO framebuffer** —
> every draw call clocks pixels *directly into the controller's GDDRAM*. There is nothing to
> "flush." You position a cursor with `lcd_set_pos(page,col)` and stream 8-bit vertical
> pixel-slices with `lcd_write_col(byte)`. Everything above that (chars, strings, icons,
> boxes) is built from those two. Fonts live in **external SPI data-flash**, fetched at draw
> time by `spi_flash_read`. The single most important callable is
> **`draw_string(page,x,str,len,mode) @0x08008A50`**.
---
## 1. Display hardware
### 1.1 Panel type, resolution, color format
| Property | Value | Evidence |
|---|---|---|
| Type | **Monochrome, page-addressed** LCD (ST7565/UC1701/SSD1306-class controller) | `lcd_write_col` streams **8 vertical pixels per byte, LSB=top**; page addressing in `lcd_set_pos` |
| Resolution | **128 (W) × 64 (H) px** | `draw_string` wraps X at 121 and cycles page `& 7` (8 pages × 8 = 64 rows); status icons at cols 0x15/0x7B (21/123) |
| Pages (rows of 8px) | **8** (page 0 = top) | page arg masked `& 7` in the wrap logic (`0x08008A82`) |
| Color format | **1 bpp** (bit set = pixel on). "mode" adds invert/outline (see §4) | `lcd_write_col` shifts one bit/pixel; blitter mode transforms are bit ops |
| Controller column origin | **183** (`0xB7`) — visible window is offset inside a wider GDDRAM | `lcd_set_pos`: `col_cmd = 0xB7 x` (`rsb r1,r5,#0xB7` @`0x08014A82`) |
| Framebuffer | **NONE** — direct-to-GDDRAM bit-bang | `lcd_write_col`/`lcd_set_pos` write GPIO registers directly; no large SRAM buffer touched |
Because there is no framebuffer, drawing is **immediate** and **non-atomic** — a partial redraw
is visible mid-frame. The stock UI mitigates flicker by only repainting changed regions. A
rewrite can adopt the same discipline, or maintain its own SRAM shadow buffer and blit it with
the raw-bitmap primitive (`lcd_blit_cols`, §2).
### 1.2 The bit-bang interface (GPIO, not SPI2)
The LCD is **not** on SPI2 (SPI2 @0x40003800 is used elsewhere; the external **SPI data-flash**
that holds the fonts is itself bit-banged on GPIOB — see §5). The LCD is driven by three GPIO
"registers" resolved from `lcd_set_pos`/`lcd_write_col` literal pools:
| Symbol in this doc | Literal | Role (inferred) | Access pattern |
|---|---|---|---|
| `LCD_RS` | `0x40010000` | **Register-Select / DC** line, driven bit0 via helper `gpio_bit0(base,val) @0x08021C4C` (`bfi [r0],r1,#0,#1`). `0`=command phase, `1`=data phase | set 0 before a byte, 1 after |
| `LCD_PORT` | `0x40020400` (GPIOB) | **CLK + DATA** bit-bang port. Set-bits reg at `+0x18`, clear-bits reg at `+0x28` (Artery-clone GPIO SCR/CLR layout) | per-bit CLK low/high, DATA set/clear |
| `LCD_CS` | `0x40020000` (GPIOA) | **CS/latch** — writes GPIOA `+0x18`/`+0x28` around each byte | strobe per byte |
> **Note on the clone GPIO map.** This MCU is an STM32F407-class **Artery AT32** clone. Its GPIO
> "set" and "clear" strobe registers sit at `base+0x18` and `base+0x28` (used as `str val,[port]`
> and `str val,[port+0x10]` after the code loads `port+0x18`). Bit masks seen: `0x08` = CLK,
> `0x20` = DATA. The exact silicon pin numbers are not load-bearing for the UI rewrite (you call
> the primitives, you don't re-bang pins); they are documented here only to explain the register
> writes. `gpio_bit0 @0x08021C4C`, `gpio_clr @0x08021C56`, `gpio_test @0x08021C5C` are the generic
> helpers.
### 1.3 The two hardware primitives (bottom of the stack)
```c
// @0x08014A7C — position the GDDRAM write cursor.
// Sends 3 command bytes: (0xB7 - x_col), (0x10 | (y>>4)), (y & 0x0F) via lcd_send_cmd.
// NOTE arg order: r0 = page/x-select, r1 = the 16-bit-ish column value. See §3 for the
// exact meaning as used by the blitters: r0 = page index, r1 = column (x pixel).
void lcd_set_pos(uint8_t page, uint16_t col); // @0x08014A7C
// @0x08014AA0 — send ONE command byte (RS=0), MSB-first, 8 clocked bits. Internal to set_pos;
// 0 external callers. Effectively lcd_send_cmd(uint8_t cmd).
static void lcd_send_cmd(uint8_t cmd); // @0x08014AA0
// @0x08014B28 — write ONE data byte = a vertical strip of 8 pixels (LSB = top pixel) at the
// current cursor, then auto-advances the column. RS=1. Bits shifted LSB-first (asrs r5,#1).
void lcd_write_col(uint8_t column_bits); // @0x08014B28
```
`lcd_set_pos` internally calls `lcd_send_cmd` three times (col-high, page, col-low). `lcd_write_col`
clocks 8 data bits then strobes CS. Both use the busy-wait delay `delay_short(n) @0x08007934`
(a `subs/bne` spin — **not** a real timer) between edges.
### 1.4 Panel init / power / backlight
- **Backlight** is a **TIM PWM** on TIM1 (`0x40010000`), programmed via `tim_set_ccr @0x08021C14`
and `tim_set_arr_psc @0x08021C04`.
- `lcd_set_brightness(level) @0x08014C34``level` 0..4 → duty {0, 5, 0x1E, 0x64, 0xFF}. Called
from `0x08009204`.
- `backlight_on/init @0x08014BEC` and `@0x08014C0C` — enable pin + set duty.
- **Panel reset / power pins** are toggled in `lcd_power_seq @0x0801D938` (writes GPIOB `+0x18`/`+0x28`
BSRR, runs a PWM ramp via `pwm_cfg @0x0802129C`, delays via `delay_ms @0x08007946`).
- The panel **command init sequence** (contrast/segment-remap/display-on) is executed once at boot
through the same `lcd_send_cmd` path; it does not need to be re-issued by a UI rewrite that keeps
the stock boot. (Reusing `lcd_set_pos`/`lcd_write_col` after boot is sufficient.)
`delay_ms(ms) @0x08007946`, `delay_short(loops) @0x08007934` — timing helpers usable by UI code.
---
## 2. Drawing primitives (callable API)
All coordinates: **`page`** = vertical row of 8 px (0=top..7), **`x`/`col`** = pixel column
(0=left..127). Text/GBK cells are **2 pages tall (16 px)**; the compact font is 1 page tall.
| vaddr | Suggested name | Signature (AAPCS) | What it does | Evidence |
|---|---|---|---|---|
| **0x08008A50** | **draw_string** | `void draw_string(u8 page, u8 x, const char *s, u16 len, u8 mode /*[sp]*/)` | **Primary text API.** Iterates bytes: `0x01–0x7F`→ASCII glyph (+7px); `0x80–0xFE`→GBK lead byte, consumes trail, `(lead<<8)\|trail`, wide glyph (+14px); `0x00/0xFF`→space/stop. Handles X-wrap at 121 and page advance. 5th arg `mode` is read at `[sp+0x28]` inside (10-reg push). | disasm §confirmed; callers pass `mode` via `str r?,[sp]` before `bl` (e.g. `0x0800318C`, `0x0801408E`) |
| 0x08007FB8 | ascii_blit | `void ascii_blit(u8 page, u8 x, u8 ch, u8 mode)` | Draws one **7px-wide × 16px-tall** ASCII glyph. Reads **14 bytes** from SPI font `0x19C000 + (ch0x20)*14` via `spi_flash_read`. Applies mode (invert/outline). Emits via `lcd_set_pos`+`lcd_write_col` (2 pages × 7 cols). | `mov r2,#0x19c000`; `index=(ch-0x20)*? ; 14B read`; per-col set_pos/write_col loop |
| 0x08008076 | ascii_blit_v2 | `void (u8 page,u8 x,u8 ch)` | Variant of ascii_blit using width-shaper `0x08005368` (proportional spacing table). | `0x19c000`, calls `0x08005368` |
| 0x08008454 | gbk_blit | `void gbk_blit(u8 page, u8 x, u16 gbk, u8 mode)` | Draws one **14px-wide × 16px-tall** double-byte (GB2312/GBK) glyph. `row=lead0x81, col=trail0x40, index=col+row*190 (1 if trail>0x7F)`, reads **28 (0x1C) bytes** from `0x19E000 + index*28`. | `mov r2,#0x19e000`; `movs r1,#0x81`; 0x1C read |
| 0x08008530 | gbk_char_at | `void gbk_char_at(u8 page, u8 x, u16 gbk)` | Standalone directly-callable single wide-char draw (same math as gbk_blit, base `0x19E000`, 28B). | `0x19e000`, `subs #0x40`, `0x81` |
| 0x080089AC | small_char | `void small_char(u8 page, u8 x, char ch, ...)` | **Compact 5px font** renderer (1 page tall). Reads `(ch0x20)*5` from an in-flash 5×8 table; clamps ch to 0x20..0x7F. Used for tiny status text/numbers. | `subs #0x20`, `*5` (`r7*5`), single-page write |
| **0x08008CF0** | draw_hline_seg | `void (u8 col, bool on)` | Draws a 2-px separator at pages 4 & 5 (values 0xFE/0x7F when on, else 0) — a horizontal rule/underline used under menu fields. | set_pos(page4/5), write_col(0xFE/0x7F) |
| **0x0800842C** | fill_col_run | `void fill_col_run(u8 page, u8 x, u8 count)` | Writes `count` columns of constant `0x18` at `page` — a thin horizontal bar (used for gauge ticks). | write_col(0x18) loop `r5<r4` |
| **0x08008DAA** | draw_box | `void draw_box(u8 page_origin)` | Draws a **rounded-rectangle frame** (corners 0xF8/0x1F, edges 0xFF) spanning pages `origin`..`origin+6`. Menu/popup border. | corner bytes 0xF8/0x1F, edges 0xFF |
| 0x08008D24 | blit_cols | `void blit_cols(u8 page, u8 count, const u8 *cols, bool src)` | Streams `count` raw column-bytes from `cols[]` at `page` (or zeros if `src==0`) — **generic 1-page bitmap blit / clear**. | set_pos(page), loop write_col(buf[i]) |
| 0x08008D62 | blit_rect | `void blit_rect(u8 page, u8 x, u8 pages, u8 width, const u8 *bmp /*[sp]*/)` | Streams a `pages`×`width` raw bitmap (row-major `bmp[page*width+col]`) — **multi-page bitmap/icon blit**. Use this to blit a shadow framebuffer. | `mla r0,r4,r5,r6` addressing, nested page/col loops |
| 0x0800836C | draw_logo48 (`0x080086CC`) | `void draw_logo(void)` | Draws the **48px×48px boot logo** (6 pages × 0x30 cols) from an in-flash bitmap table. | `#0x58` col base, `r4<6`, `r5<0x30` |
| 0x08008874 | draw_icon_batt | `void draw_icon(u8 page, bool on)` | Draws the battery/side icon at col `0x7B` (123). Status-bar icon. | col `r4+0x7B`, 8B table |
| 0x08008A04 | draw_icon_signal | `void draw_icon(u8 page, bool on)` | Draws a status icon at col `0x15` (21) — signal/antenna glyph. | col `0x15+r4` |
| 0x08008300 | draw_dots | `void draw_dots(u8 which)` | Draws 1–3 dotted markers (byte 0xBD) — page/step indicator dots. | `0xBD` at sp[4..b], cases 1/2/3 |
| 0x08008224 | draw_frame_corners | `void (u8 x, u8 page)` | Draws corner pixels of a highlight frame (2-px inset box). | `rsb #1`, write_col(0) corner pattern |
| 0x08008100 | big_char | `void big_char(u8 page,u8 x,u8 ch,u8 mode)` | Large-font glyph: reads **0x21 (33) bytes** from `0x?+(ch0x20)*0x20` — a taller/wider font variant (frequency display digits). | `subs #0x20`, `*0x20`, 0x21B read, invert mode |
| 0x080081A0 | set_pixel_run | pixel/segment helper | Builds a partial column mask (`0x80>>bit`) for sub-page pixel plotting; used by line/rect fills. | `0x80 asr`, `orr` mask accumulate |
| 0x08008B90 | draw_number_row | `void (u8 page,u8 x,const u8*digits,u16 n,u8 mode)` | Draws `n` glyphs from `digits[]` with **12px** stride (`(i*3)<<2`) — fixed-pitch number/label row (calls big_char `0x08008100`). | stride `r4*3<<2`, calls `0x08008100` |
| 0x08008BC6 | draw_str_spaced | `void (u8 page,u8 x,const u8*s,u16 n,u8 mode)` | Draws `n` chars with **6px** proportional stride (calls small_char `0x080089AC`). | `+6` stride, calls `0x080089AC` |
| 0x08008BFC | draw_field | menu field value paint | Copies a field record (0x14 bytes) and renders label+value; reads struct offsets `+0x102/+0x104/+0x108/+0x10A`. | `movs r2,#0x14`, `memcpy 0x08002BEE` |
### 2.1 Screen-clear / fill
There is **no single "clear whole screen"** ROM export separate from the loop; the stock code
clears by writing zero columns. **To clear a region**, call `blit_cols(page, count, NULL, 0)`
(`@0x08008D24` with `src==0` → writes `0x00`) per page, or `blit_rect` (`@0x08008D62`) with a
zero bitmap. **To clear the full 128×64:** loop `page = 0..7`, `blit_cols(page, 128, NULL, 0)`.
`draw_string` with a run of `0x20` (space) glyphs also erases a text row.
---
## 3. Coordinate system & text metrics
```
col/x → 0 ........................................ 127 (128 px wide)
page 0 ┌───────────────── status bar (icons) ─────────────┐ y 0..7
page 1 │ │ y 8..15
page 2 │ text/GBK cells are 16 px tall = 2 pages │
... │ │
page 7 └───────────────────────────────────────────────────┘ y 56..63
```
- **Origin:** top-left. `page` grows downward, `x`/`col` grows rightward.
- **`page` (row) unit:** one page = **8 vertical pixels**. Valid 0..7. Passed as `r0` to blitters.
- **`x` (column) unit:** **pixels**, 0..127. Passed as `r1`.
- **`lcd_set_pos(page, col)`** maps `col` to the controller command `0xB7 col` (window offset)
and `page` via `0x10 | (page>>4)` / `page & 0x0F`. The blitters call it once per (page, column)
pair, so you rarely call it directly.
**Text cell metrics:**
| Font | Draw fn | Cell W × H | Advance | Glyph src (SPI) | Bytes/glyph |
|---|---|---|---|---|---|
| ASCII (normal) | `ascii_blit` / via `draw_string` | 7 × 16 px (2 pages) | **+7 px** | `0x19C000 + (ch0x20)*14` | 14 |
| GBK / CJK / Cyrillic | `gbk_blit` / via `draw_string` | 14 × 16 px (2 pages) | **+14 px** | `0x19E000 + index*28` | 28 |
| Compact | `small_char` | 5 × 8 px (1 page) | **+6 px** (with `draw_str_spaced`) | in-flash 5×8 table | 5 |
| Large digits | `big_char` | ~11 × 16 px | +12 px (`draw_number_row`) | in-flash *0x20 table | 33 (0x21) |
- **Row height** = 16 px = 2 pages. A 128×64 screen therefore fits **4 text rows** (pages 0-1,
2-3, 4-5, 6-7). Menus use page-4 for the highlighted line (`draw_string(4, …)` in the menu
field painter `@0x08014074`).
- **Chars per row:** 128/7 ≈ **18 ASCII** or **9 GBK** (2-byte) chars.
- **X-wrap:** `draw_string` auto-wraps to next 2-page cell when `x` passes ~121 (ASCII) / 114 (GBK).
---
## 4. Color / mono handling and the `mode` parameter
The panel is 1-bpp. "Color" = pixel on/off. The **`mode`** argument (5th arg of `draw_string`,
4th of `ascii_blit`/`gbk_blit`, read from stack `[sp+0x28]` in `draw_string`) selects a
per-glyph bit transform applied to the fetched font bytes **before** they are clocked out:
| mode | Name | Transform (in `ascii_blit @0x08007FB8`) | Visual |
|---|---|---|---|
| **0** | Normal | none | black text on white (pixel = font bit) |
| **1** | Inverse | `byte = ~byte`, plus edge-bit clears (`&0xFE`/`&0x7F` on alternating bytes) to keep a 1px gutter | white text in a black bar (selection highlight) |
| **2** | Outline | `byte \|= 0x80` on even bytes (adds a boundary line) | boxed/outlined text |
Evidence: `0x08007FDE cmp r7,#1 → invert loop (mvns)`; `0x0800801C cmp r7,#2 → orr #0x80 loop`.
`draw_string` forwards its 5th arg unchanged as this `mode`. **Selection highlighting in menus =
`mode 1`.** For a UI rewrite, pass `mode=1` to render the currently-selected line inverted.
---
## 5. Font/asset fetch (SPI data-flash)
Glyph bitmaps are **not in MCU flash** — they are read on demand from the external SPI data-flash
(the same 4 MB device dumped in `radio-spi-dump.bin`) by:
```c
// @0x08021828 (entry 0x08021826 sets r0=0 then falls through)
void spi_flash_read(void *dst, uint32_t src_addr, uint32_t len); // @0x08021828
```
- Issues SPI opcode **0x03** (read), clocks a **3-byte** address (or 4-byte if the chip capacity
byte is 0x18/0x19), streams `len` bytes into `dst`. Bit-banged on **GPIOB** (`0x40020400`
region, literals `0x8021820..0x8021AD8`). 34 direct BL callers — the glyph blitters are the
heavy users.
- **Font bases:** ASCII narrow `0x19C000` (14 B/glyph), GBK/wide `0x19E000` (28 B/glyph). Cyrillic
is present in the GBK bank at GB2312 row A7 (see `RT-4D_Russification.md`). A separate Unicode
codepoint index lives at SPI `0x3F0000`.
- **Constraint respected:** these are SPI **reads only** for the UI; they do not touch the codeplug
format or the CPS serial protocol. A UI rewrite reuses `spi_flash_read` as-is for fonts.
Related SPI HW primitives (do **not** reuse for writes in a UI rewrite — codeplug boundary):
`spi_send_byte @0x08021538`, `spi_read_bytes @0x08021580`, `spi_sector_erase @0x08021924`
(opcode 0x20, 4 KB), `spi_page_program @0x08021A70`.
---
## 6. Minimal "draw a screen from scratch" recipe
Using only reused stock entry points (no framebuffer needed):
```c
// ---- reused stock API (thumb addresses, call with bl / function pointers) ----
void draw_string (u8 page, u8 x, const char *s, u16 len, u8 mode); // 0x08008A50
void gbk_char_at (u8 page, u8 x, u16 gbk); // 0x08008530
void blit_cols (u8 page, u8 count, const u8 *cols, u8 src); // 0x08008D24 (src=0 => clear)
void draw_box (u8 page_origin); // 0x08008DAA
void lcd_set_pos (u8 page, u16 col); // 0x08014A7C
void lcd_write_col(u8 column_bits); // 0x08014B28
void lcd_set_brightness(u8 level); // 0x08014C34
void my_screen(void)
{
// 1. Clear the whole 128x64 (8 pages) — write zero columns.
for (u8 pg = 0; pg < 8; pg++)
blit_cols(pg, 128, 0, 0); // src=0 -> 0x00 fill
// 2. Title on the top text row (pages 0-1), normal video.
draw_string(0, 2, "MAIN", 4, 0);
// 3. A selected menu line on pages 4-5, inverse video (highlight).
draw_string(4, 1, "Channel 001", 11, 1); // mode 1 = inverse
// 4. A framed popup border (rounded box) starting at page 1.
draw_box(1);
// 5. Draw a raw 8px-tall icon: position cursor, stream columns.
static const u8 batt[8] = {0x3C,0x24,0x24,0x24,0x24,0x24,0x3C,0x18};
lcd_set_pos(0, 118); // page 0, x=118
for (u8 i = 0; i < 8; i++) lcd_write_col(batt[i]);
// No flush needed — pixels are already in the LCD.
}
```
**Notes for the rewrite:**
- Keep to the (page, x-pixel) model. A "row" is 2 pages (16 px); place text rows at pages 0,2,4,6.
- For flicker-free full-screen redraws, either repaint only changed regions (stock approach) or
keep your own `u8 fb[8][128]` SRAM shadow and blit it with `blit_rect @0x08008D62`.
- Use `mode=1` for the selected item; `mode=0` otherwise; `mode=2` for outlined labels.
- Cyrillic/CJK: put raw GB2312 double-byte codes (lead ≥0x80) in the string; `draw_string`
auto-routes them through `gbk_blit`. No firmware change needed (see Russification report).
- All of this is **display-only** and reuses `spi_flash_read` solely for font fetches — it never
writes the SPI codeplug regions and never touches the CPS serial protocol, satisfying the hard
constraint.
---
## 7. Confidence & open items
- **High confidence:** `draw_string`, `ascii_blit`, `gbk_blit`, `gbk_char_at`, `lcd_set_pos`,
`lcd_write_col`, `spi_flash_read`, `mode` semantics, font bases, char metrics, 128×64/8-page
geometry, no-framebuffer/direct-GDDRAM model, brightness PWM. All verified from disassembly +
literal pools + call sites.
- **Medium confidence:** exact controller part number (ST7565/UC1701/SSD1306-family inferred from
page addressing + `0xB7` column origin, not read from an ID); the precise GPIO pin numbers for
CLK/DATA/RS/CS on the Artery clone (register offsets are certain; silicon pin mapping is not
load-bearing). `big_char`/`draw_number_row` font-base literal not fully resolved (only the
33-byte stride is confirmed).
- **Not needed for the rewrite:** the boot-time panel command-init sequence (reused as-is via the
stock boot); re-deriving it would only matter for a from-scratch panel bring-up.
+349
Просмотреть файл
@@ -0,0 +1,349 @@
# RT-4D — DMR / FM100B interface (MCU side) — API reference (`dmr`)
Scope: the **MCU-side** code that talks to the FM100B DMR baseband over **USART3 (`0x40004800`)**. All addresses are absolute in the MCU app image (`rt4d_stock_v3.25_abs_0x08002800.bin`, load base `0x08002800`, ARM Thumb). This is the layer a rewritten UI must **reuse verbatim** to do DMR: originate calls, render incoming calls, set radio ID / TG / color code / slot, SMS, and handle remote stun/kill/wake.
Boundary note: none of this touches the **SPI codeplug format** or the **CPS serial protocol** — the FM100B link is a *third*, internal, binary UART with its own `0x68…0x10` framing. It is completely independent of the CPS `0x34/0x52/region-id` framing (USART6). Reusing these functions does not change any CPS-visible format. The only codeplug coupling is **read-only**: caller-name display reads contact records from SPI `0x5C000`/`0x5E000` and the key/SMS-target table at `0x0D0000` (same layout the CPS already writes).
---
## 0. TL;DR — the callable entry points that matter
| vaddr | name (inferred) | C signature | what it does |
|---|---|---|---|
| `0x0801B044` | `fm100b_send1` | `void(u8 cmd,u8 b,u8 sub,u8 data,void* respbuf,u16 timeout)` | build+send a 1-data-byte `0x68` frame to FM100B, block until its `*Cnf` arrives (or timeout) |
| `0x0801B0C4` | `fm100b_send` | `void(u8 cmd,u8 b,u8 sub,u16 len,void* respbuf,const u8* payload,u16 timeout)` | same, with an N-byte payload |
| `0x08006C9C` | `usart3_tx_buf` | `void(const u8* buf,u16 len)` | raw byte-blit of a frame out USART3 |
| `0x08006CB8` | `usart3_tx_byte` | `void(u8 b)` | one byte out USART3->DR, spin on TC |
| `0x08003050` | `poll_serial` | `void(void)` | **pump**: run FM100B RX parse + CPS framer + RX drain once. Call this in any wait loop. |
| `0x08018CB0` | `fm100b_rx_parse` | `int(void)` | scan USART3 RX ring for one `0x68` frame, verify checksum, dispatch it; returns 1 if a frame consumed |
| `0x08006348` | `fm100b_on_frame` | `void(u8* frame)` | master `*Cnf`/`*Ind` dispatch: writes `resp[cmd]=frame[3]` then jump-tables to the per-cmd Ind handler |
| `0x08006CFC`†| `dmr_call_start_from_contact` | `void(u8 dummy, u16 contact_idx)` | originate a call to a stored contact: look up record, send cmd6 setup, latch current-call state |
| `0x08006FD8`†| `dmr_call_resend` | `void(void)` | re-send cmd6 for the latched current call (PTT continue) |
| `0x08006C4C` | `dmr_set_radio_id` | `void(u8 idHi,u8 idLo)` | cmd `0x49` — set our personal DMR ID on the module |
| `0x0800736C` | `dmr_sms_send` | `void(u16 target_or_contact)` | cmd `0x82` — send an SMS |
| `0x08006D00` | `dmr_contact_read` | `void(u8 dummy,u16 idx,...)` | read a 21-byte DMR contact record `idx*27 + 0x5E000` from SPI (for name/ID display) |
| `0x0801A38C` | `nvic_system_reset` | `noreturn void(void)` | reboot (used by remote-kill enforcement) |
`dmr_call_start_from_contact` is the function whose body begins at `0x08006CFC`/`0x08006D00`; `dmr_call_resend` body starts `0x08006FD8`. Signatures below.
Confidence: **high** on the framing, the two send primitives, the RX parser, the `fm100b_on_frame` dispatch table, the incoming-call state block, and the reset. **Medium-high** on individual command *semantics* (cmd numbers are proven from call sites; their meaning is inferred from surrounding code + the FM100B `ATC_*` symbol list in the prior report).
---
## 1. MCU ↔ FM100B wire protocol (USART3 `0x40004800`)
### 1.1 Frame format (both directions)
Every message is a framed packet built/parsed at the byte level. Layout (offsets in bytes):
```
+0 0x68 sync / SOF (constant; parser rejects anything else)
+1 cmd command id (see §2)
+2 b secondary/opcode byte (usually 1 on Req; on Ind = subtype)
+3 sub sub-command / status. On *Cnf this byte is the result code.
+4..+5 cksum 16-bit checksum, big-endian (see §1.2)
+6..+7 len payload length, big-endian (u16)
+8..+8+len-1 payload (len bytes; for send1 it is a single data byte)
+8+len 0x10 EOF / end marker (constant)
```
Total on-wire size = `len + 9`. The send1 primitive uses `len=1`, so its frame is 10 bytes (`68 cmd b sub CKh CKl 00 01 data 10`).
Evidence — `fm100b_send1 @0x0801B044`:
```
0x0801b054 movs r0,#0x68 ; str [buf+0] ; SOF
0x0801b05c strb r4,[buf+1] / r5,[+2] / r6,[+3] ; cmd,b,sub
0x0801b062 movw #0xffff ; strh [buf+4] ; cksum placeholder
0x0801b068 bl 0x800bd2c ; strh r0,[buf+6] ; len = bswap16(1)
0x0801b074 strb r7,[buf+8] ; single data byte
0x0801b078 movs #0x10 ; strb [buf+9] ; EOF marker
0x0801b07c bl 0x8002ea8 (sum16, len=10) ; checksum over 10 bytes
0x0801b086 strh r0,[buf+4] ; store bswap16(cksum) at +4
0x0801b092 ldr r0,=0x2000706e ; bl 0x8006c9c ; usart3_tx_buf(buf,10)
```
`fm100b_send @0x0801B0C4` is identical but `len=r3`, copies `payload` (`[sp+0x20]`) into `buf+8` via `memcpy 0x80062EC`, writes `0x10` at `buf+8+len`, and sends `len+9` bytes.
Helpers:
- `0x0800BD2C = bswap16(u16)` — byte-swap; used to store the BE 16-bit len and cksum.
- `0x08002EA8 = sum16(const u8* buf,u16 len)` — sum of big-endian 16-bit words → the checksum.
- `0x08021EB0 = usart_write_DR(base,byte)` (`str [base+4]`), `0x08021EA8 = usart_read_DR(base)`, `0x08021EC2 = usart_get_flag(base,mask)`.
### 1.2 TX path
- **`usart3_tx_byte @0x08006CB8`** `void(u8 b)`: optionally mirrors the byte into the RX ring when a loopback flag (`0x20000B67`) is set, then `usart_write_DR(0x40004800,b)` and spins on TX-complete (SR bit `0x80`).
- **`usart3_tx_buf @0x08006C9C`** `void(const u8* buf,u16 len)`: `for i in 0..len: usart3_tx_byte(buf[i])`.
- Shared **TX frame buffer** at SRAM `0x2000706E` (both send primitives build here; not re-entrant — the send primitives block until `*Cnf`, so a single global buffer is safe only from the main loop).
### 1.3 RX path
Per-byte RX is interrupt-driven (**USART3 ISR @0x080205B0**, IRQ 39). It pushes each byte into a **4 KB ring**:
- ring struct head/word at `0x20000C64`, data buffer at `0x200092EF`, index mask `0xFFF`.
- (There is also a 1 KB ring at `0x20000C2C`/`0x20007575`, mask `0x3FF`, filled in parallel — a secondary/debug capture.)
Draining/parsing happens in the main loop, **not** in the ISR:
- **`fm100b_rx_ring_drain @0x0801FE50`** `void(void)`: while `tail < head`, pull one byte and feed the **byte accumulator**… actually it calls `fm100b_rx_parse` per available byte via `0x08018BFC`? — the concrete flow is: `poll_serial` calls `fm100b_rx_parse` directly.
- **`fm100b_rx_parse @0x08018CB0`** `int(void)`:
1. Search the ring for a `0x68` byte (advance tail past junk).
2. Read `len = (ring[p+6]<<8)|ring[p+7]` (BE). Reject if `len >= 0x200`.
3. Require `head-tail >= len+9` bytes buffered, and `ring[p+8+len] == 0x10` (EOF).
4. Copy the whole `len+9` frame out of the ring into a linear work buffer.
5. `sum16(frame,len+9)` must equal the stored checksum at `+4`; else drop.
6. On success advance the tail past the frame and call **`fm100b_on_frame(frame)`** (`0x08006348`); return 1.
- **`poll_serial @0x08003050`** = `fm100b_rx_parse(); cps_framer(0x0801F854); fm100b_rx_ring_drain(0x0801FE50);`. **This is the cooperative pump.** Every blocking send loop (see §1.4) calls this; a rewritten UI's idle/wait loop must call it too.
### 1.4 Request/Confirm handshake (how blocking works)
Both send primitives implement a synchronous Req→Cnf:
```
resp[cmd] = 0xFF ; mark pending (resp array @0x20007476, indexed by cmd)
usart3_tx_buf(frame,len) ; send
timeout_ctr = timeout ; @0x20000C52
do { poll_serial(); } while (resp[cmd]==0xFF && timeout_ctr!=0);
```
`fm100b_on_frame` (§3) sets `resp[cmd] = frame[3]` when the matching `*Cnf` arrives, which breaks the loop. So `respbuf`/timeout args are: timeout is the last stacked arg (e.g. `0x3E8`=1000 for call setup, `0x64`=100 for config); the "respbuf" stack arg is a copy of the timeout counter seed. The **response/status code** for a command after the call returns is `resp[cmd]` at `0x20007476+cmd`.
---
## 2. Command set (MCU → FM100B `*Req`), from call sites
Extracted by decoding `(cmd=r0, b=r1, sub=r2, data/len=r3)` at every call to the two send primitives. `cmd` is proven from the immediate; the name maps to the FM100B `ATC_*Req` symbol families documented in the prior RE report (§5.4).
| cmd | via | b | sub | payload | wrapper vaddr | inferred meaning (`ATC_*Req`) |
|---|---|---|---|---|---|---|
| `0x02` | send1 | 1 | 1 | 1B | `0x0800760A` | misc mode set |
| `0x05` | send1 | 1 | 2 | data=2 | `0x08006C74` | **channel/RF config set** (`ATC_ChannelSetReq`-class) |
| `0x06` | send | 1 | *call_type* | 5B `[type,ID_be32]` | `0x08006CFC` | **DMR call setup** (`ATC_CallProcessReq`) |
| `0x07` | send | 1 | 1 | var | `0x08007180` | contact/data set (`ATC_CurChDigdataSetReq`) |
| `0x09` | send1 | 1 | 1 | 1B | `0x080076CC` | misc |
| `0x0A` | send | 1 | 1 | 5B `[type,ID_be32]` | `0x08006FD8` | **send/originate call (TX PTT)** variant |
| `0x0B` | send1 | 1 | 1 | 1B | `0x08007530` | set param |
| `0x0C` | send1 | 1 | 1 | data=0 | `0x08007598` | set param |
| `0x25` | send1 | 1 | 1 | data=1 | `0x08006C88` | init/enable |
| `0x2A` | send | 1 | 1 | 4B | `0x080074FC` | set 32-bit param |
| `0x42` | send1 | 1 | 1 | 1B | `0x080075B4` | set param |
| `0x48` | send1 | 1 | 1 | 1B | `0x080075C6` | set param |
| `0x49` | send | 1 | 1 | 4B | `0x08006C4C` | **set our radio DMR ID** (`ATC_RadioIDSetReq`) |
| `0x4D` | send1 | 1 | 1 | 1B | `0x080075F4` | set param |
| `0x55` | send1 | 1 | 1 | `data+1` | `0x080075DC` | set param (increment) |
| `0x4C` | send1 | 1 | 1 | 1B | `0x08007680` | set param |
| `0x57` | send | 1 | 1 | 2B | `0x080074E0` | set param |
| `0x62` | send | 1 | 1 | 2B | `0x080071F0` | set param |
| `0x81` | send | 1 | 1 | var | `0x080073E0` | **SMS payload block** (`SPSendInBandDataReq`) |
| `0x82` | send | 1 | 1 | 20B | `0x0800736C` | **SMS send (header+target)** |
| `0x84` | send | *r0* | *r0* | — | `0x080074BC` | **contact info query** (`ATC_CalledContactINfoQuery`) |
| `0x64` | rawTX | — | — | 10B fixed | `0x08007548` | boot/wake handshake (raw `usart3_tx_buf`, marks resp `[+0x84]`) |
The single byte `b` is almost always `1` on a Req; on Ind frames `frame[2]` is the *subtype* selector (see §3). `sub` (`frame[3]`) is the module's status on the returned `*Cnf`.
### 2.1 Selected wrapper decompilations (callable API)
**`dmr_set_radio_id @0x08006C4C`** `void dmr_set_radio_id(u8 idHi, u8 idLo)`
```
payload[0]=idHi; payload[1]=idLo; payload[2..3]=0;
fm100b_send(cmd=0x49,b=1,sub=1,len=4,payload,timeout=0x64);
```
Sets the module's own DMR ID. (Only 2 bytes filled here; the personal ID low 16 bits — the caller composes the full 24-bit ID before calling.)
**`dmr_call_start_from_contact @0x08006CFC`** `void dmr_call_start_from_contact(u8 unused, u16 contact_idx)`
```
rec = dmr_contact_read(0xFF, contact_idx); // 21B record @ contact_idx*27 + 0x5E000
if (rec[0] > 2) { error("Call type error"); return; } // 0800aeb8 = show msg
call_type = (rec[0]==0)?1 : (rec[0]==1)?2 : (rec[0]==2)?4 : ...; // 1=Group,2=Private,4=AllCall
target_id = be32(rec[+1]); // 32/24-bit target
build payload = [call_type, target_id_be32]; // 5 bytes
fm100b_send(cmd=0x06, b=1, sub=call_type, len=5, payload, timeout=0x3E8);
// latch current-call state @0x20007DA9: [0]=call_type, [1..4]=target_id
```
This is the **originate-call** entry. It maps the contact record's stored type to the module's `call_type` (Group→1, Private→2, All→4) and sends the setup, then also fires a follow-on raw frame (`0x8006DFC` region) that TX-blits a 0x1F-byte packet.
**`dmr_call_resend @0x08006FD8`** `void dmr_call_resend(void)`
```
type = curcall[0]; id = be32(curcall[+5]); // curcall @0x20007DA9
payload=[type,id_be32]; fm100b_send(0x06,1,type,5,payload,0x3E8);
```
Re-issues the setup for the already-latched call (used to keep a group call up / PTT re-key).
**`dmr_send_call_0a @0x08006FD8`-region (`0x08007000`)** `void(u8 type, u32 id)` — cmd `0x0A`, same 5-byte `[type,id_be32]` payload, `timeout=0x3E8`. This is the alternate "start voice" path (the two, cmd6 vs cmd0xA, correspond to `ATDigCallSetupCnf` vs a direct voice-start).
**`dmr_sms_send @0x0800736C`** `void dmr_sms_send(u16 target)`
```
if (target != 0) { // resolve target contact
rec = SPI_read(0x0D0000 + (target-1)*48, 48);// SMS-target table (0x0D0000, 48B stride)
switch(rec[+1]) { type=1→grp, 4→prv, 5→all } // map record type
}
build 0x22-byte msg: dst = 0xAAAAAAAA if all-call else target;
fm100b_send(cmd=0x82,b=1,sub=1,len=20,payload,timeout=0x64); // header
// followed by cmd 0x81 payload block(s) for the text (0x080073E0)
```
**`dmr_contact_read @0x08006D00`** `void dmr_contact_read(u8 unused, u16 idx, out u8 rec[21])`
```
base = idx*27 + 0x5E000; // 27-byte stride, contacts region
SPI_read(base, 21, rec); // 0x8021828 = spi_read(dst,addr,len)
// rec[0] = contact type (0=Group,1=Private,2=AllCall); rec[+1..]=ID + name
```
The stride is **27 bytes at `0x5E000`** (= codeplug contacts `0x05C000` + `0x2000`). This is the routine the UI calls to turn a contact index into a type+ID+name for display and for call setup. (Note the on-flash contact record the CPS writes is 32 bytes at `0x5E000` per the codeplug report; the module-facing read here pulls the first 21 bytes.)
---
## 3. Incoming frames (FM100B → MCU `*Cnf` / `*Ind`) — the RX side the UI renders
### 3.1 Master dispatch `fm100b_on_frame @0x08006348`
```
void fm100b_on_frame(u8* f) {
resp[f[1]] = f[3]; // 0x20007476[cmd] = status → unblocks the Req wait
if (f[1] >= 0xC1) return;
switch (f[1]) { /* jump table @0x0800636C, cmd*4 half-word offsets */ }
}
```
Jump-table result (cmds with a *real* Ind handler; all others fall to the no-op default `0x08006C26` and only update `resp[]`):
| cmd | handler vaddr | meaning |
|---|---|---|
| `0x01` | `0x08006670` | status |
| `0x02` | `0x08006672` | status |
| `0x03``0x04` | `0x08006674`/`76` | status |
| `0x05` | `0x0800668C` | channel/config change Ind (latches new state, sets a "changed" flag) |
| **`0x06`** | **`0x080066AA`** | **INCOMING CALL Ind** — caller/TG/type → UI (see §3.2) |
| `0x07` | `0x0800671E` | **call/PTT status Ind** (call end, TX status) |
| `0x09` | `0x08006816` | call-timer/ready Ind (arms a `0x320` timer) |
| `0x0A` | `0x08006870` | **remote-command Ind** (stun/kill; see §3.3) |
| others (`0x0B`+, `0x12``0xC0`) | small `resp[]`-only stubs | pure `*Cnf` acknowledgements |
### 3.2 Incoming-call Ind `0x080066AA` — what the standby/RX screen reads
Frame layout for a cmd6 Ind: `f[8]=call_type` (1=Group, 2=Private, 4=AllCall), `f[9..12]=source(caller) ID` (BE), `f[13..16]=dest/TG ID` (BE). Handler:
```
status = f[3] → 0x20000C3C / 0x20000C3D
call_type: 1→0, 2→1, 4→2 → curcall[0] @0x20007DC2
dest_id = be32(f[+0xD]) → curcall[+1] (u32) (0x80112B8 = be32_to_u32)
src_id = be32(f[+9]) → curcall[+5] (u32) (the CALLER id the UI shows)
if (first-of-call flag) {
slot = curcall[+1]>>4; set_rx_slot_indicator(slot); // 0x8018530
copy state block // 0x80062ec
}
```
**Incoming-call state block `0x20007DC2`** (this is what a rewritten RX screen reads to draw "caller / TG / type"):
```
+0 u8 call_type (0=Group, 1=Private, 2=AllCall)
+1 u32 dest_id / talkgroup (little-endian in RAM)
+5 u32 source_id (the caller's DMR ID)
```
`0x80112B8 = be32_to_u32(const u8* p)` converts the on-wire big-endian IDs. Additional call-status bytes: `0x20000C3C` (raw status), `0x20000C3D` (mirror).
Talker alias / caller *name*: the frame carries the numeric IDs only. The UI resolves the **caller name** by looking the `source_id` up against the contacts table (`dmr_contact_read` / the by-ID search at `0x08007E68 → 0x08017F60`, and `0x08006E6C` alt lookup). If no contact matches, the raw ID is shown (`Unknown station` string at `0x08028815`).
### 3.3 Remote-command Ind `0x08006870` (cmd `0x0A`) — stun / kill / wake
```
sub = f[2]; code = f[3] → 0x20000C?? state
if (code == 0xA1) show_msg(...); // e.g. remote check / stun-related
if (enabled_flag[+0x184]) {
if (code == 0xA2) { // REMOTE KILL
kill_state = 4;
persist_word = 0x4444; store @[+0xC]; // marker written to NV
0x801A900(); // commit to SPI/NV
delay(0x7D0); 0x8007946(0x7D0);
nvic_system_reset(); // 0x801A38C — reboot into killed state
}
}
```
So the enforcement of a remote kill is a **persisted `0x4444` marker + reboot** via `nvic_system_reset @0x0801A38C`. A rewritten UI that wants to *ignore* remote kill would stub this handler or the `0x184` enable flag; to *keep* stock behavior, leave `fm100b_on_frame`'s cmd-`0x0A` path intact. (`Prohibit TX` string `0x0801ED28` and `DMR Remote Kill/Stun` anchors `0x0800346C`/`0x08006B30` live on the UI side that reads these flags.)
### 3.4 Incoming SMS (module → MCU)
SMS received by the module arrives as an Ind carrying the text block; the MCU stores it into the SMS/inbox codeplug area. The upload confirm corresponds to the FM100B `ATUploadRxSmsCnf` symbol. The MCU-side receive path shares the same `fm100b_on_frame` dispatch (one of the `resp[]`-updating cmds) plus a data-copy into RAM; the inbox commit reuses the standard SPI codeplug writer (unchanged format).
---
## 4. Contact / address-book lookup for caller-name display
Two record stores are involved (both are **read-only** from DMR's perspective; the CPS owns their format):
1. **Contacts (module-facing)**`dmr_contact_read @0x08006D00`: `record = SPI[idx*27 + 0x5E000]`, 21 bytes: `[0]=type, [+1..]=ID, name`. Used both to originate calls and to name a contact index.
2. **By-ID reverse lookup** — the RX screen turns a numeric `source_id`/`dest_id` into a name via the search wrapper at `0x08007E68``0x08017F60` (walks the contacts region comparing the 24-bit ID), with an alternate at `0x08006E6C`. On a hit it renders the stored name; on a miss it renders the raw decimal ID (24-bit, max `16777215` per string `0x08007E07`).
3. **SMS-target / key-name table**`0x0D0000`, 48-byte stride (per the live SPI dump), used by `dmr_sms_send` to resolve an SMS destination.
Group IDs are stored BCD/LE in the contact record (`66 06` → TG 666, per the codeplug report); the module wire format uses **plain big-endian 24/32-bit**`be32_to_u32 @0x80112B8` and the payload-build shifts in the wrappers do the conversion. Keep both conversions if reusing these functions.
---
## 5. Call sequences for a rewritten UI
### 5.1 Boot / attach the module
```
// stock boot fires: raw 0x64 handshake (0x08007548), then a burst of config Reqs
fm100b_send1(0x05,1,2, data=2, resp, 0x64); // channel/RF config
fm100b_send (0x49,1,1, len=4, [idHi,idLo,0,0], resp, 0x64); // dmr_set_radio_id
// ... other 0x0B/0x0C/0x42/0x48/0x4D param sets as needed
// each call blocks via poll_serial() until resp[cmd] != 0xFF
```
### 5.2 Originate a DMR call (private or group)
```
// UI has a contact index (or build an ad-hoc record):
dmr_call_start_from_contact(0xFF, contact_idx); // 0x08006CFC
// → looks up record, maps type, sends cmd6 [type,id_be32], latches curcall@0x20007DA9
// while PTT held, keep the call up:
while (ptt_down) { dmr_call_resend(); poll_serial(); } // 0x08006FD8, re-sends cmd6/0x0A
// on release: send the corresponding stop/param Req and drop PTT.
```
For a raw call without a stored contact: build `payload=[call_type, target_id_be32]` yourself and call `fm100b_send(0x06,1,call_type,5,payload,0x3E8)` (or cmd `0x0A` for the voice-start variant), then set `curcall@0x20007DA9`.
### 5.3 Render an incoming call (standby/RX screen)
```
// In the main loop, keep pumping the link:
poll_serial(); // 0x08003050 — drains USART3, dispatches Inds
// When cmd6 Ind fires, the state block @0x20007DC2 is populated:
u8 type = curcall_rx[0]; // 0=Group,1=Private,2=AllCall
u32 tg = *(u32*)(curcall_rx+1); // talkgroup / dest
u32 src = *(u32*)(curcall_rx+5); // caller DMR ID
// Resolve caller name:
name = contact_name_by_id(src); // 0x08017F60 search; fallback → decimal(src)
draw: "<name or src> → TG <tg>" (type-dependent: SID/GID/AID labels @0x0800A36C)
// status/end: cmd7 Ind updates call-status bytes; cmd9 arms the call timer.
```
### 5.4 Send an SMS
```
// text staged in RAM by the editor; target is a contact index or 0 for the default
dmr_sms_send(target); // 0x0800736C → cmd 0x82 header + cmd 0x81 payload
// wait resp[0x82]/resp[0x81]; ATUpload* / send-fail handled by fm100b_on_frame.
```
---
## 6. RAM state map (DMR)
| addr | size | contents |
|---|---|---|
| `0x2000706E` | ~0x200 | TX frame build buffer (`0x68…0x10`) |
| `0x20007476` | 0xC1 | **`resp[cmd]`** response/status array (0xFF=pending) |
| `0x20000C52` | u16 | Req timeout counter |
| `0x200092EF` | 0x1000 | USART3 RX ring data |
| `0x20000C64` | — | USART3 RX ring head/index |
| `0x20007575` | 0x400 | secondary RX capture ring |
| `0x20000B67` | u8 | USART3 TX→RX loopback capture flag |
| `0x20007DA9` | 5+ | **outgoing** current-call: `[0]=type,[1..4]=id`, `[+5]=id copy` |
| `0x20007DC2` | 9 | **incoming** call: `[0]=type,[1..4]=dest/TG,[5..8]=caller id` |
| `0x20000C3C/3D` | u8×2 | incoming-call status bytes |
---
## 7. Reuse guidance for the UI rewrite
- **Keep and call as-is**: `fm100b_send1 (0x0801B044)`, `fm100b_send (0x0801B0C4)`, `poll_serial (0x08003050)`, `fm100b_rx_parse (0x08018CB0)`, `fm100b_on_frame (0x08006348)`, `dmr_contact_read (0x08006D00)`, `nvic_system_reset (0x0801A38C)`, and the wrappers in §2. They contain the whole USART3 protocol and are codeplug/CPS-neutral.
- **Read, never reframe**: the incoming-call block `0x20007DC2` and `resp[]` `0x20007476` are your UI inputs. Poll `poll_serial()` from your event loop; read those to render.
- **To originate**: prefer the wrappers (`dmr_call_start_from_contact`, `dmr_sms_send`, `dmr_set_radio_id`) so type-mapping and current-call latching stay correct. If you bypass them, replicate the Group→1/Private→2/AllCall→4 mapping and the big-endian ID packing.
- **Do not** re-implement framing/checksums yourself — call the two send primitives; that guarantees the FM100B never sees a malformed frame and keeps the module firmware (unchanged) happy.
- **Color code / timeslot** are set through the per-channel config Reqs (`cmd 0x05` and the `0x0B/0x0C/0x42/0x48/0x4D` family — set from the channel record fields); these carry no codeplug-format dependency beyond reading the channel record the CPS already writes.
## Open items (medium confidence, worth a second pass on-target)
- Exact `sub`/field meaning of the `0x0B/0x0C/0x42/0x48/0x4D/0x55/0x57/0x62` param Reqs (which is color-code vs squelch vs power vs denoise) — the cmd numbers are certain; individual mapping needs tracing each wrapper's caller (channel-settings menu handlers).
- The cmd `0x84` `ATC_CalledContactINfoQuery` return payload layout (talker-alias source) — its Ind path falls to the default stub here, so alias text likely arrives on a different cmd or is assembled MCU-side from contacts.
- Encryption enable/key-select Req (menu `Encryption Set @0x080165D0`) — routed through one of the param Reqs above; not yet pinned to a specific cmd byte.
+311
Просмотреть файл
@@ -0,0 +1,311 @@
# RT-4D Input Subsystem — Keypad / PTT / Dispatch API
Reverse-engineered from `rt4d_stock_v3.25_abs_0x08002800.bin` (Cortex-M4F Thumb, load base `0x08002800`).
Scope: keypad matrix scan, debounce, key-code map, PTT/side keys, event delivery, and the callable
API + hook points a rewritten UI would use to read input and route keys to a custom router.
All addresses are absolute MCU flash vaddrs. RAM state vars are `0x2000xxxx`.
---
## 0. TL;DR — the input pipeline
```
TICK (main loop, ~1ms cadence)
keypad_task(col) 0x0801B398 ── drives one column output line (GPIOA/GPIOF ODR), cycles col 0..3
│ calls per column:
keypad_scan_col(col) 0x0801B294 ── reads 4 row inputs (GPIOF1, GPIOA8, GPIOA9, GPIOB11),
│ sets/clears bit (1<<(col*4+row)) in RAW word 0x20000B7C (active-low)
keypad_decode() 0x0801130C ── maps RAW bitmask 0x20000B7C → compact KEY CODE (0x00..0x12, 0xFF=none)
keypad_process() 0x08005A14 ── debounce + short/long/repeat classify; writes KeyEv struct @0x20000B57
│ KeyEv[+2]=delivered key, KeyEv[+3]=event type (1=short/long,2=repeat)
ui_input_service() 0x08005E54 ── main-loop input step: calls keypad_process, reads KeyEv[+2],
│ then calls the screen router, then clears KeyEv[+2]
screen_dispatch(ctx,aux,key) 0x08018DF4 ── reads current-screen id ctx[+1] (ctx=0x20002120),
tbb-dispatches to the active screen's key handler
```
**This is a polled GPIO 4×4 matrix (NOT an ADC ladder).** ADC1 is used only for battery/RSSI, not keys.
**Up/Down are matrix keys, not a rotary encoder.** PTT and the two side keys are separate GPIO reads.
---
## 1. Hardware: GPIO 4×4 key matrix
The keypad is a 4-column × 4-row matrix. Columns are driven low one at a time (outputs), rows are read
(active-low inputs, pulled high). Confirmed from `keypad_scan_col` (`0x0801B294`) which reads, per column,
four row pins via the GPIO bit-read helper and packs them into the 16-bit raw word `0x20000B7C`:
| Row idx | Port/pin (mask passed to gpio_read_pin) | Evidence (in 0x0801B294) |
|---|---|---|
| row 0 | **GPIOF pin1** (`0x40021400`, mask `0x0002`) | `ldr 0x40021400; movs r1,#2; bl 0x8021316` |
| row 1 | **GPIOA pin8** (`0x40020000`, mask `0x0100`) | `ldr 0x40020000; mov r1,#0x100` |
| row 2 | **GPIOA pin9** (`0x40020000`, mask `0x0200`) | `ldr 0x40020000; mov r1,#0x200` |
| row 3 | **GPIOB pin11** (`0x40020400`, mask `0x0800`) | `ldr 0x40020400; mov r1,#0x800` |
For each row: `bit = 1 << (col*4 + row)`; if `gpio_read_pin()==0` (pin low = pressed) → `raw |= bit`,
else `raw &= ~bit`. So `0x20000B7C` is a live pressed-keys bitmap, one bit per matrix cell.
Column drive lives in `keypad_task` (`0x0801B398`): a 4-state machine using `0x20000B7A` as the current
column index; each phase writes the column-select via `gpio_write_pin`/BSRR on GPIOA (`0x40020000`) and
GPIOF (`0x40021400`, BSRR/ODR region seen as `0x40021428`/`0x40020428`), then invokes `keypad_scan_col`
for that column. Columns cycle 0→1→2→3 across successive ticks.
### GPIO helper primitives (callable API)
| vaddr | signature | behavior |
|---|---|---|
| `0x08021316` | `int gpio_read_pin(GPIO_TypeDef* port, uint32_t mask)` | returns 1 if **all** masked IDR (`+0x10`) bits set, else 0 |
| `0x08021C6E` | `void gpio_write_pin(GPIO_TypeDef* port, uint32_t mask, int state)` | state 1 → `ODR(+0x0C)|=mask`; state 0 → `ODR&=~mask` |
| `0x08021C4C` | `void gpio_bsrr(GPIO_TypeDef* port, uint32_t mask)` (bit-set/reset via BSRR) | used by column drive & LCD |
---
## 2. KEY CODE MAP — `keypad_decode()` @ `0x0801130C`
Signature: **`uint8_t keypad_decode(void)`** — reads the raw matrix word `*(uint16_t*)0x20000B7C`
and returns the firmware key code. Returns `0xFF` when nothing/unknown is pressed. Every comparison in
the function reads the same `ldrh [0x20000B7C]`; verified all 19 literal loads resolve to `0x20000B7C`.
### Raw matrix-bit → key code (single-key)
| raw `0x20000B7C` value | KEY CODE (return) | physical key (inferred) |
|---|---|---|
| `0x4000` | `0x00` | **0** |
| `0x0002` | `0x01` | **1** |
| `0x0020` | `0x02` | **2** |
| `0x0200` | `0x03` | **3** |
| `0x0004` | `0x04` | **4** |
| `0x0040` | `0x05` | **5** |
| `0x0400` | `0x06` | **6** |
| `0x0008` | `0x07` | **7** |
| `0x0080` | `0x08` | **8** |
| `0x0800` | `0x09` | **9** |
| `0x0001` | `0x0B` | **Menu / M** (col0,row0) |
| `0x0010` | `0x0C` | **Up** ▲ |
| `0x0100` | `0x0D` | **Down** ▼ |
| `0x1000` | `0x10` | **Exit / Back** |
| `0x2000` | `0x0E` | **\*** (star) |
| `0x8000` | `0x0F` | **#** (hash) — used elsewhere as "menu enter" sentinel |
Note key code `0x0A` is not produced by a single raw bit; it is produced only by the combo pattern below
(it is the "PTT/M combined" boot code). The digit codes `0x00..0x09` map 1:1 to digits 0-9.
### Combo / multi-line patterns (special)
| raw `0x20000B7C` | KEY CODE | meaning |
|---|---|---|
| `0x1111` | `0x0A` | all-column row0 held (boot key-combo / factory) |
| `0x2222` | `0x11` | **Side key 1** family / all-column row1 |
| `0x4444` | `0x12` | **Side key 2** family / all-column row2 |
> Interpretation of `0x11`/`0x12` as the two programmable side keys is corroborated by
> `ui_input_service` (`0x08005E54`), which special-cases `KeyEv[+2]==0x11` and `==0x12` *before* the
> normal screen dispatch (see §4), matching the CPS "Side Key 1/2 (Short/Long)" menu items.
### Key-code enum (for the rewrite)
```c
enum key {
KEY_0=0x00, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, // 0x00..0x09
KEY_COMBO_A=0x0A, // boot combo
KEY_MENU=0x0B, // "M"
KEY_UP=0x0C,
KEY_DOWN=0x0D,
KEY_STAR=0x0E, // '*'
KEY_HASH=0x0F, // '#' (also menu-enter sentinel)
KEY_EXIT=0x10, // Back/Exit
KEY_SIDE1=0x11,
KEY_SIDE2=0x12,
KEY_NONE=0xFF
};
```
---
## 3. Debounce + short/long/repeat — `keypad_process()` @ `0x08005A14`
Signature: **`void keypad_process(void)`**. Called once per input-service pass from `ui_input_service`.
Operates on the **key-event struct `KeyEv` @ `0x20000B57`** (byte fields unless noted):
| field | addr | meaning |
|---|---|---|
| `KeyEv[+1]` | `0x20000B58` | live decoded key this pass (`= keypad_decode()`), `0xFF`=none |
| `KeyEv[+2]` | `0x20000B59` | **delivered event key** consumed by the UI (`0xFF` when no event) |
| `KeyEv[+3]` | `0x20000B5A` | **event type**: `1` = press/release (short or long), `2` = auto-repeat |
| `KeyEv[+4]` | `0x20000B5B` | repeat-active flag |
| `KeyEv[+5]` | `0x20000B5C` | `uint16_t` **hold-duration counter** (ticks; incremented while held) |
Behavior (decoded from `0x08005A14``0x08005B54`):
1. `KeyEv[+1] = keypad_decode()`. Also reads PTT: `gpio_read_pin(GPIOA, 0x1000)` (GPIOA pin12) → PTT state at `0x20000B75` (`KeyEv[+0x1E]`); PTT keycode is `0x11`-adjacent handling.
2. While a key stays held: `KeyEv[+5]++` (bounded).
3. On **release** (`KeyEv[+1]==0xFF`) with `KeyEv[+5] > 10`: publish the just-released key as an event (`KeyEv[+2]=key, KeyEv[+3]=1`); otherwise call `key_event_clear()` (`0x08012C24`).
4. **Long/repeat threshold `0x2BC` (700 ticks)**: if same key held and `KeyEv[+5] > 0x2BC` → publish repeat (`KeyEv[+2]=key, KeyEv[+3]=2, KeyEv[+4]=1`). A secondary `>10` gate distinguishes the short vs long delivery. Beep feedback is emitted via `0x8014CB8` on valid/invalid keys.
5. Screen-change edge (`0x08005B38`): if the current screen id changed vs `KeyEv[+1]`, resets the hold counter — prevents key bleed across screens.
Supporting call: `key_event_clear()` **`0x08012C24`** — `void key_event_clear(void)` sets
`KeyEv[+0..+2]=0xFF`, `KeyEv[+3..+5]=0`. **This is the "consume/ack the pending key" API.**
---
## 4. Delivery to the active screen — `ui_input_service()` @ `0x08005E54`
Signature: **`void ui_input_service(void)`** — the main-loop input step. Sequence:
1. `keypad_process()` (`0x08005A14`) — refresh KeyEv.
2. Service a couple of periodic timers (`0x200201DC`/`0x201F5E4` housekeeping — not input).
3. Read `KeyEv[+2]` (delivered key). Side keys `0x11`/`0x12` are handled specially (their own screen-independent action path) and are **not** forwarded to the generic router. `0xFF` (no key) is skipped.
4. Otherwise call **`screen_dispatch(ctx=0x20002120, aux=0x20002014, key=KeyEv[+2])`** (`0x08018DF4`).
5. After dispatch, clear: `KeyEv[+2]=0xFF` (`0x20000B59`) and `ctx[+0x15]=0xFF` (`0x20002135`).
6. Then paints: `0x080136E4` (status), `0x080142C0` (menu/standby render).
### The router — `screen_dispatch(ctx, aux, key)` @ `0x08018DF4`
Signature: **`void screen_dispatch(void* ctx, void* aux, uint8_t key)`** — `ctx = 0x20002120`.
- If `key == 0x0F` (`#`): enter/menu path → `0x0800F0E4` then `0x08005484` (menu-enter), return.
- Else read **current-screen id = `ctx[+1]` (byte at `0x20002121`)**, range 0..5, and `tbb`-dispatch:
| `ctx[+1]` | screen key handler | notes |
|---|---|---|
| 0 | `0x08018E5A` | **standby / main VFO screen** |
| 1 | `0x08018E5A` | (shares standby handler) |
| 2 | `0x08019018` | secondary screen (dual-area / menu list) |
| 3 | `0x0801926C` | screen 3 |
| 4 | `0x08018F08` | screen 4 |
| (5) | falls through | |
Inner standby handler `0x08018E5A(ctx, aux, key)`: `if key==0xFF return;` sets `ctx[+0x15]=0xFF`;
then `key-0x0A; cmp #7` `tbb` to handle the nav/function keys `0x0A..0x10` (Menu/Up/Down/*/#/Exit),
and the default branch treats numeric keys as ASCII (`key+0x30`) → digit-entry handler `0x080134F8`.
Menu navigation calls `0x08007984`; audible feedback via `0x08014CB8` (beep freq `0x1B8` ok / `0x65C` err).
**`ctx[+1]` (`0x20002121`) is the current-screen selector, and the `tbb` table at `0x08018E16` is the
screen→handler map. This is the exact hook point for a rewritten UI router** (see §7).
---
## 5. PTT, side keys, monitor, and analog inputs
- **PTT**: GPIO input **GPIOA pin12** (`gpio_read_pin(0x40020000, 0x1000)`), read inside `keypad_process`
and also standalone at `0x08005A32` (parent `0x08005A14`). PTT is *not* part of the matrix; it latches
into `KeyEv[+0x1E]` (`0x20000B75`) and gates TX. A separate small helper stores PTT/key state and
compares against key code `0x11`.
- **Side key 1 / Side key 2**: surface as decoded key codes `0x11` / `0x12` (combo patterns `0x2222` /
`0x4444`), intercepted in `ui_input_service` (`0x08005E54`) ahead of the router, matching the CPS
"Side Key 1/2 Short/Long" definitions. Their configured action is looked up from settings there.
- **Monitor / squelch-open**: driven from the same key/side-key path (no dedicated GPIO monitor line found
separate from the side keys); the "Monitor" function is a side-key/long-press action, not a distinct pin.
- **Rotary/channel knob & Up/Down**: **no rotary encoder**. Channel change is the matrix Up (`0x0C`) /
Down (`0x0D`) keys handled by the active screen. No quadrature-decode code exists.
- **Volume/power knob**: analog volume is a hardware potentiometer in the audio path (not MCU-sampled);
no ADC channel is decoded as a knob position. **ADC1 (`0x40012000`) is battery voltage + RSSI only**
`adc_init` @ `0x08002D64`, battery read/convert @ `0x08010960` (`raw*4/0x42` scaling → `BATT:x.xV`),
ADC EOC counters incremented in the ADC ISR `0x08002D1C`. None of this feeds key input.
### Boot-time key-combo handler `0x08003060`
`void boot_key_scan(void)` — at power-on reads `keypad_decode()` directly (not through KeyEv) and matches
combos: `0x11`, `0x0A` (→ enters a special mode: `0x080149D4`/`0x0801492C`, PC-programming/init screen),
`0x0F`, etc. This implements "hold `*`/`#`/side at power-on" entries. Uses the same key codes as §2.
---
## 6. RAM state variables (input)
| addr | name | type | meaning |
|---|---|---|---|
| `0x20000B7A` | `kp_cur_col` | u8 | current column being driven/scanned (0..3) |
| `0x20000B7C` | `kp_raw` | u16 | live pressed-key bitmap `1<<(col*4+row)`, active-low packed |
| `0x20000B57` | `KeyEv` | struct | key-event struct (base; see §3 for fields +1..+5) |
| `0x20000B58` | `KeyEv.live` | u8 | live decoded key |
| `0x20000B59` | `KeyEv.key` | u8 | **delivered event key** (read by router; `0xFF`=none) |
| `0x20000B5A` | `KeyEv.type` | u8 | 1=short/long press, 2=auto-repeat |
| `0x20000B5B` | `KeyEv.rep` | u8 | repeat-active flag |
| `0x20000B5C` | `KeyEv.dur` | u16 | hold-duration counter (ticks); long/repeat @ `0x2BC`=700 |
| `0x20000B75` | `KeyEv.ptt` | u8 | PTT pressed flag (GPIOA12) |
| `0x20002120` | `ui_ctx` | struct | UI/screen context passed to router |
| `0x20002121` | `ui_ctx.screen` | u8 | **current-screen id** (router `tbb` selector, 0..5) |
| `0x20002135` | `ui_ctx[+0x15]` | u8 | per-frame scratch (cleared after dispatch) |
---
## 7. CALLABLE API for a rewritten UI
### 7a. Read input (poll model — recommended)
The cleanest reuse is to keep the stock scan/decode/debounce and read the published event:
```c
// stock addresses (Thumb; call with bit0 set)
uint8_t keypad_decode(void); // 0x0801130C -> raw key code, 0xFF=none (no debounce)
void keypad_process(void); // 0x08005A14 -> updates KeyEv (debounce+long/repeat)
void key_event_clear(void); // 0x08012C24 -> ack/consume pending key
#define KeyEv_key (*(volatile uint8_t*)0x20000B59) // delivered key, 0xFF=none
#define KeyEv_type (*(volatile uint8_t*)0x20000B5A) // 1=press,2=repeat
#define KeyEv_dur (*(volatile uint16_t*)0x20000B5C) // hold ticks (for your own long-press cutoff)
#define KeyEv_ptt (*(volatile uint8_t*)0x20000B75) // PTT
// Our UI main loop:
for(;;){
keypad_process(); // let stock code scan+debounce
uint8_t k = KeyEv_key;
if(k != 0xFF){
my_router(k, KeyEv_type); // <-- our dispatch
key_event_clear(); // consume
}
// ... our render ...
}
```
You do **not** need to touch the timer ISRs: `keypad_task`/`keypad_scan_col` are invoked from the stock
tick path that also runs `keypad_process` housekeeping; if you replace the main loop you can call
`keypad_task(col)` yourself per tick, or simpler—call `keypad_process()` which reads the already-scanned
`kp_raw`. (If you fully own the loop, drive `keypad_task(0..3)` round-robin each ~1ms so `kp_raw` refreshes.)
Raw / low-level entry points if you want to bypass debounce:
```c
int gpio_read_pin (void* port, uint32_t mask); // 0x08021316
void gpio_write_pin(void* port, uint32_t mask,int);// 0x08021C6E
void keypad_scan_col(int col); // 0x0801B294 scans one column into kp_raw
void keypad_task(void); // 0x0801B398 column-drive state machine (call each tick)
```
### 7b. Hook to route keys to OUR router (drop-in replacement)
The single interception point is **`ui_input_service` (`0x08005E54`)** → it calls
**`screen_dispatch(ctx=0x20002120, aux=0x20002014, key)` at `0x08005F1E`**. Two options:
1. **Replace the router (minimal patch):** repoint the `bl 0x08018DF4` at `0x08005F1E` to our own
`router(ctx, aux, key)`. We then own all per-screen handling while stock keypad scan/decode/debounce,
PTT, side-key pre-handling, and post-dispatch clear remain intact. Our router reads
`ui_ctx.screen` (`0x20002121`) as the active-screen id (or we manage our own screen id) and dispatches.
2. **Replace the whole input+render loop:** call `keypad_process()` ourselves (7a) and never enter
`ui_input_service`; then stock `screen_dispatch` and the render calls (`0x080136E4`, `0x080142C0`) are
bypassed entirely — full UI ownership. Keep calling stock lower-level draw/RF/DMR APIs.
Either way the **serial/CPS protocol and SPI codeplug format are untouched** — the input path has no
contact with the USART6 framer (`0x0801F864`) or the SPI region writers, so replacing the UI router does
not affect CPS compatibility.
---
## 8. Confidence
| Item | Confidence | Basis |
|---|---|---|
| `keypad_decode` @0x0801130C + full key-code map | **High** | 19 loads all resolve to `0x20000B7C`; explicit `cmp`/`movs r0,#code`; single-exit |
| GPIO 4×4 matrix (not ADC ladder); row pins GPIOF1/GPIOA8/9/GPIOB11 | **High** | `keypad_scan_col` masks `2/0x100/0x200/0x800` on distinct ports + `1<<(col*4+row)` packing |
| `keypad_process` debounce + long/repeat (`0x2BC` threshold) + KeyEv layout | **High** | full disasm `0x08005A14`; field offsets confirmed via stores |
| `ui_input_service``screen_dispatch` route; ctx `0x20002120`, screen id `[+1]` | **High** | resolved call-site literals + `tbb` table at `0x08018E16` |
| PTT = GPIOA pin12; ADC1 = battery/RSSI only (no knob) | **High** | `gpio_read_pin(GPIOA,0x1000)` in key path; ADC only in battery/ISR |
| Physical-key labels (which code = Menu/Up/Down/Exit/*/#) | **Medium-High** | inferred from standby handler usage (digit ASCII, nav tbb, `#`=menu-enter, `0x11/0x12`=side keys); exact silk-screen assignment of the four *non-digit* codes 0x0B/0x0C/0x0D/0x10 should be confirmed on-device |
| Side keys = codes `0x11`/`0x12` (combo `0x2222`/`0x4444`) | **Medium** | special-cased before router; matches CPS side-key menu; combo-pattern origin unusual—verify on hardware |
+275
Просмотреть файл
@@ -0,0 +1,275 @@
# 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.
+242
Просмотреть файл
@@ -0,0 +1,242 @@
# RT-4D Standby / Main (Home) Screen — Render Map & Rewrite Recipe
**Key:** `main-screen` · **Target:** `stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin` (ARM CortexM4F Thumb, load/vaddr base `0x08002800`). All disassembly via capstone `CS_ARCH_ARM + CS_MODE_THUMB`.
This document reverseengineers the **standby / operating (VFO or channel) home screen** — the first UI redesign target. It ties into the three prior docs: geometry & draw primitives in **`display.md`**, the state machine & hook points in **`main-loop.md`**, and keys in **`input.md`**. Read those for the lowlevel draw API and the superloop hook; this doc covers *what the home screen paints, from which data, at which coordinates, and how to replace it.*
---
## 0. TL;DR
- The home screen is **screen id 0** (`g_screen @0x200008B3 == 0`). Its **draw handler is `home_draw @0x08013BD8`** (dispatched from `ui_draw_dispatch @0x0801E1BC`'s `tbb` table). An **alternate home / dualdisplay variant is id 7 → `0x08013804`**.
- Home render is **twostage and datadriven**, not hardcoded:
1. **`format_area_display(area, hi, shift) @0x08011414`** reads the live **channel cache** at `0x20002DEA` (48byte records) + the perarea **mode table** at `0x20002DCF`, formats frequency / channelname / modetag ASCII into the **display struct `g_disp @0x200009C3`**, and sets **dirty flags** in `g_dirty @0x200024EB`.
2. **`home_draw @0x08013BD8`** consumes `g_disp` + `g_dirty` and calls the `display.md` primitives (`draw_string`, `draw_number_row`, `draw_str_spaced`, `draw_marker5`) to paint each *changed* element.
- **Redraw is incremental / dirtyflag driven.** An element repaints only when its byte in `g_dirty @0x200024EB` is nonzero; setting a flag (e.g. after a channel/area change, RX event, or key) triggers repaint on the next UI tick. No fullframe clear.
- **The single struct you must understand is `g_disp @0x200009C3`.** Every onscreen string/flag is a field of it (see §3). To replace the home screen, either (a) keep `format_area_display` and relayout in your own `home_draw`, or (b) ignore both and render straight from the cache `0x20002DEA` + radio getters. Both keep the SPI codeplug format and CPS serial protocol **untouched** (they only *read* cache RAM and call display primitives).
---
## 1. Where the home screen is dispatched from
Chain (from `main-loop.md`, confirmed): superloop `app_main @0x0802136C` → normal UI tick `ui_tick_normal @0x080207DC`**`bl ui_draw_dispatch @0x0801E1BC` at callsite `0x080207E6`**. `ui_draw_dispatch` `tbb`jumps on `g_screen @0x200008B3`; **index 0 → `home_draw @0x08013BD8`**, index 7 → `0x08013804` (alt/dual home). The mirror input `tbb` (`ui_process_key @0x0801E6EC`) routes id 0 → key handler `0x08017F60`.
| screen id | draw handler | content struct | dirtyflag array | role |
|---:|---|---|---|---|
| **0** | **`home_draw 0x08013BD8`** | **`g_disp 0x200009C3`** | **`g_dirty 0x200024EB`** | **primary standby (VFO/channel) home** |
| 7 | `0x08013804` | `0x2000096A` | `0x200009BD` | alt home / dualdisplay variant |
| 2 | `0x08013980` | `0x200008D0` | `0x200008F5` | standbykey / list overlay |
> **Hook (recommended, from `main-loop.md` Option A):** redirect the `bl` at `0x080207E6` to `my_draw_router`; render your own home there. Everything else (RF, DMR RX, scan, battery, and the entire serial/CPS + SPI regionwrite path) is preserved. 2instruction cut.
---
## 2. The render pipeline (data → struct → pixels)
```
SPI codeplug channels (0x004000, 48B recs) ← format on flash, DO NOT CHANGE
│ loaded/cached by channel-load code (writes 0x20002DEA)
g_chcache @0x20002DEA : live channel record cache, 48-byte (0x30) stride, [area]
g_chmode @0x20002DCF : per-area display-mode bytes (= g_chcache 0x1B)
▼ format_area_display(area, hi, shift) @0x08011414 (4 call sites)
│ reads freq/tones/power/mode from cache, formats ASCII
g_disp @0x200009C3 : DISPLAY STRUCT (freq/name strings, mode flags, TG, markers)
│ + sets element dirty flags in g_dirty @0x200024EB
▼ home_draw @0x08013BD8 (dispatched every UI tick for screen 0)
│ paints only elements whose g_dirty byte != 0
draw_string / draw_number_row / draw_str_spaced / draw_marker5 (display.md prims)
▼ lcd_set_pos + lcd_write_col → direct-to-GDDRAM (no framebuffer)
```
### 2.1 `format_area_display(u8 area, u8 hi, u8 shift) @0x08011414` — the formatter
Prototype (AAPCS): `void format_area_display(u8 area /*r0*/, u8 hi /*r1→r5*/, u8 shift /*r2→r8*/)`.
- `area` (r4) = which VFO/area (0 = A, 1 = B) — indexes the cache `0x20002DEA + area*0x30`.
- `hi` (r5) = highlight/selection state (0,1,2,3) → stored to `g_disp+0x1D`; `2` selects an alt copy path.
- `shift` (r8) = TXshift display mode: `0`=simplex/RX shown, `1`=+shift, `2`=shift/reverse (Talkaround/Reverse). Selects which of RX(`+5`)/TX(`+9`) freq words is shown.
Reads (per 48byte cache record at `0x20002DEA + area*0x30`):
| cache off | field | used for |
|---:|---|---|
| `[+0]` | flags; **`>>6` = mode** (1 ⇒ DMR/digital, else analog) | "DMR"/"ANA" tag, "A-"/"D-" prefix |
| `[+5]` | u32 **RX frequency** (`MHz×100000`, `FREQ_MULTIPLIER`) | main freq digits |
| `[+9]` | u32 **TX frequency** | shown when `shift`≠0 / reverse |
| `[+0xD]` | u12 (`ubfx #0,#0xC`) **RX tone** (CTCSS/DCS) | tone field |
| `[+0xE]>>4` | nibble | power/flag |
| `[+0xF]` | u12 **TX tone** | tone field |
| `[+0x10]>>4` | nibble | flag |
| `[+0x20]` | 16byte **channel name** (ASCII) | name display (mode==2) |
Also reads globals: `g_power @0x20000B76``g_disp+0x1C` (the "HD"/status flag byte); `g_chmode @0x20002DCF + area``g_disp+0x1E` (element mode), `[+2+area]``g_disp+0x1F` (submode).
Writes into `g_disp @0x200009C3` (see §3) and builds ASCII via helpers:
- **`num_to_ascii(uint val, int ndigits) @0x08018530`** — rightaligns `ndigits` decimal ASCII into scratch **`0x200024D0`**. Used to render the frequency (`freq`, 8 digits) and channel number.
- **`str_insert_char(buf, ch, pos, len) @0x080135A8`** — inserts `ch` at `pos` (shifting right). Used to splice the **"." decimal point** into the frequency string (`ch=0x2E` at pos 3) → `"438.80000"`.
- **`memcpy_off(dst, src, dstoff, len) @0x080062EC`** — `dst[dstoff+i]=src[i]`. The workhorse for copying templates / name / formatted number into `g_disp` fields.
Inline ASCII templates embedded in the formatter (decoded): `"CH MODE"`, `"VFO MODE"`, `"VFO MODE-A"`, `"VFO MODE-B"`, `"CH-"`, `"A-"` (analog), `"D-"` (digital), `"ANA"`, `"DMR"`. These are the literal strings the stock home screen shows in the mode/status line.
Called from 4 sites (`0x080033D2`, `0x08004F60`, `0x08008F28`, `0x0800951C`) — always after a state change (area switch, channel change, mode toggle). Example call: `0x0800951C``format_area_display(area=[0x20000B5F], hi=2, shift=[0x200029BB+0x67])`.
### 2.2 `home_draw @0x08013BD8` — the painter
Consumes `g_disp @0x200009C3` and perelement dirty flags `g_dirty @0x200024EB`. For each element: `if (g_dirty[k]) draw…`. Ends `pop {…pc}` at `0x08013E50`. Row constants used: `r4=1` (top status page pair), `r5=2` (Area A page pair), `r6=5` (Area B page pair) — but these are rederived from `g_disp+0x42` (active area) at entry so the *selected* area gets the larger/highlighted rows. Uses `mode` arg to `draw_string` = normal(0)/inverse(1) for highlight.
---
## 3. `g_disp @0x200009C3` — the display struct (field map)
Two parallel area subrecords of stride **0x43** (Area A at `+0x00`, Area B at `+0x43`), plus shared/status fields. Offsets below are from `0x200009C3` (Area A); add `0x43` for Area B. All confirmed from `format_area_display` writes + `home_draw` reads.
| off | type | name | meaning / source | drawn by (element) |
|---:|---|---|---|---|
| `+0x00` | char[16] | `area_line0` | Area A top text (name/tag line) | `draw_str_spaced` (flag+0) |
| `+0x15` | char[7] | `line_prefix` | `"CH-"`+num or `"A-"/"D-"` mode tag | `draw_str_spaced`, x=0x68 (flag+6) |
| `+0x1C` | u8 | `status_flag` | ← `g_power @0x20000B76`; drives "HD"/" " topright indicator | `draw_str_spaced` x=0x74, template `"HD"`/`" "` (flag+2) |
| `+0x1D` | u8 | `hiliteA` | highlight state (r5) for Area A frame | `draw_frame_corners` (0x08008224) |
| `+0x1E` | u8 | `modeA` | **element mode**: 0 = blank, 1 = **frequency**, 2 = **channel name** | selects freq vs name path |
| `+0x1F` | u8 | `submodeA` | submode: 0/1 name width, 2 = bigdigit freq | picks big_char vs string |
| `+0x20` | char[16] | `contentA` | **the main Area A string** (formatted freq `"438.80000"` OR channel name OR `"CH MODE"`/`"VFO MODE"`) | `draw_number_row`(big digits) / `draw_string` on row r5 (flag+7) |
| `+0x30` | u8 | `modeB` | Area B element mode (0/1/2) | (flag+5) |
| `+0x31` | u8 | `submodeB` | Area B submode | |
| `+0x32` | char[16] | `contentB` | Area B main string | `draw_string` row r6 (flag+5) |
| `+0x42` | u8 | `active_area` | 0 = A selected, else B — chooses which rows get big/highlight | branches at handler entry |
| `+0x43…` | — | Area B record | mirror of `+0x00…` at stride 0x43 | (rendered by sibling fn `0x08013E64`) |
| `+0x63` | u8 | `ab_marker` | 0/1/2 A/B rightedge marker state ← `g_active_area 0x20000B5F` | `draw_marker5` pages 2 & 5 (flag+7 preamble) |
| `+0x64` | u8 | `batt_marker` | 0/1 → battery/dual marker layout | `draw_marker5` pages 1/3/6 (flag+8) |
Additional companion buffer **`g_disp2 @0x20000A28`** holds the **modetag / secondary line** ("ANA"/"DMR", "A-"/"D-") built alongside `contentA` — drawn on the status row.
> **`draw_marker5(page, on) @0x0800870C`** (aka the "batt/arrow icon" in `display.md`): draws a 5column glyph at columns **123–127** (`0x7B+i`). Pattern bytes `30 78 FC 30 30` = a rightpointing arrowhead → the **A/B activearea selector arrows** on the far right edge. `draw_icon_batt @0x08008874` is the structurallyidentical sibling used for the battery/dual markers.
---
## 4. Onscreen element → data source → draw call (the master table)
Coordinates: `page` = 8px row band (0=top…7), `x` = pixel column (0…127). Rows: top status ≈ page 1, Area A ≈ pages 2–3, Area B ≈ pages 5–6 (exact page passed in `r4/r5/r6`, reordered by `active_area`).
| # | Element (what you see) | Data source | Draw call (page,x) | Dirty flag |
|---|---|---|---|---|
| 1 | **Area A main line** — frequency `"438.80000"` (big digits) *or* channel name *or* `"CH MODE"/"VFO MODE"` | `g_disp+0x20``format_area_display` from cache `0x20002DEA[area]` (`+5` freq / `+0x20` name) & `modeA=g_disp+0x1E` | `draw_number_row(r5,0x14,…,8|7)` big digits **or** `draw_string(r5,0x64,…,2)` | `g_dirty[7]` |
| 2 | **Area B main line** (dualwatch second area) | `g_disp+0x32` ← same formatter, `area=1` | `draw_string(r6,0x0A/0x27/0x1C,…)` per `modeB=g_disp+0x30` | `g_dirty[5]` |
| 3 | **Top name/tag line** (radio name / area label) | `g_disp+0x00` | `draw_str_spaced(r4,0x00,…,16)` | `g_dirty[0]` |
| 4 | **Channel # / A/D prefix** (`"CH-001"`, `"A-"`, `"D-"`) | `g_disp+0x15` ← templates `"CH-"/"A-"/"D-"` + `num_to_ascii` | `draw_str_spaced(r4,0x68,…,4)` | `g_dirty[6]` |
| 5 | **Mode tag** `"ANA"` / `"DMR"` (FM/AM/SSB vs digital) | `g_disp2 0x20000A28``cache[+0]>>6` (mode bits) | `memcpy_off`+`draw_string` on status row | (with #4) |
| 6 | **HD / status indicator** (topright) | `g_disp+0x1C``g_power @0x20000B76`; template `"HD"`/`" "` | `draw_str_spaced(r4,0x74,…,2)` | `g_dirty[2]` |
| 7 | **A/B selector arrows** (right edge, col 123) | `g_disp+0x63``g_active_area 0x20000B5F`; `g_disp+0x64` | `draw_marker5(2,on)` & `draw_marker5(5,on)`; batt markers pages 1/3/6 | `g_dirty[7]`,`[8]` |
| 8 | **Area A highlight frame** | `g_disp+0x1D` (hilite) & `+0x42` | `draw_frame_corners(page,x) 0x08008224` | `g_dirty[3]` |
| 9 | **CTCSS/DCS tone, power nibble** | cache `[+0xD]`/`[+0xF]` (12bit tone), `[+0xE]`/`[+0x10]` (nibble) | folded into `contentA`/`g_disp2` via formatter | (with #1) |
| 10 | **DMR TG / Contact / ColorCode (RX)** | DMR call ctx `g_call @0x2000A6C5` (`+1`=call type, `+2`=ID, `+0x38`=name), fed from cache & FM100B | built by the callinfo path near `0x08006754`; painted on Area line as name/ID | RXevent driven |
| 11 | **Battery / signal icons** | see `display.md` §draw_icon_batt/signal (cols 21 & 123); battery volts from ADC1 `0x08010960` | `draw_icon_batt`/`draw_icon_signal` | periodgated task |
Notes:
- **FM / AM / SSB vs DMR**: the coarse analog/digital split is `cache[+0]>>6``"ANA"`/`"DMR"`. The fine analog demod (FM/AM/SSB) is a perchannel field (`RX Demod (A)` menu) stored in the channel record and shown via the same statusline path; it is *not* a separate draw primitive.
- **RX/TX arrow**: TX vs RX display is driven by the `shift` arg (Talkaround/Reverse) selecting cache `[+5]` (RX) vs `[+9]` (TX); the onair TX indicator is a statusflag element, not a distinct routine.
- **Dualwatch A/B**: `active_area (g_disp+0x42)` and `ab_marker (+0x63)` decide which area is highlighted and where the arrows point; `g_active_area @0x20000B5F` is the master.
---
## 5. Key RAM state variables (home screen)
| addr | width | name | role |
|---|---|---|---|
| `0x200009C3` | struct | `g_disp` | **home display struct** — all home strings/flags (§3). Area A `+0`, Area B `+0x43`. |
| `0x200024EB` | u8[9+] | `g_dirty` | perelement **dirty flags**; element k paints iff `g_dirty[k]!=0`. Indices used: 0,2,3,4,5,6,7,8. |
| `0x20002DEA` | rec[N] | `g_chcache` | **live channel cache**, 48byte (`0x30`) stride; `[+5]`RXfreq `[+9]`TXfreq `[+0xD/0xF]`tones `[+0x20]`name. Mirrors codeplug channel record — **readonly for UI**. |
| `0x20002DCF` | u8[] | `g_chmode` | perarea display mode/submode bytes (`= g_chcache 0x1B`). |
| `0x20000B5F` | u8 | `g_active_area` | selected VFO/area index (0=A). |
| `0x20000B76` | u8 | `g_power`/status | drives `g_disp+0x1C` (HD indicator). |
| `0x200024D0` | u8[16] | `g_numbuf` | scratch for `num_to_ascii` (freq/number ASCII build). |
| `0x2000A6C5` | struct | `g_call` | DMR RX call ctx: `+1` call type, `+2` ID (BCDdecoded via `0x080112B8`), `+0x38` name. |
| `0x200029BB` | struct | `g_uictx2` | secondary UI context (`+0x63`,`+0x67`,`+0x197` read by formatter callers). |
| `0x200008B3` | u8 | `g_screen` | screen id; 0 = this home screen. |
---
## 6. Redraw cadence & triggers
- **Cadence:** `home_draw` is called **every UI tick** (unthrottled) from `ui_tick_normal @0x080207DC` via `ui_draw_dispatch @0x0801E1BC` at `0x080207E6`, but each element is gated by its `g_dirty` byte, so a steady screen costs ~nothing.
- **Triggers (what sets `g_dirty`):**
- **Key / channel / area change** — the standbykey handler path (e.g. `0x080094F8`) calls `format_area_display`, then sets `g_dirty[7]=1` (`0x080095DA`) and `g_disp+0x63 = g_active_area`, forcing Area A repaint next tick. Other setters at `0x08005DA0`, `0x0800975E`.
- **Screen entry** — `ui_draw_dispatch` preamble `clear_dirty_flags @0x080136E4` walks a companion dirty row `0x20002512` and forcemarks fields dirty on entry (full repaint on screen switch).
- **RX / DMR event** — updates `g_call @0x2000A6C5` and reruns the formatter → dirties the affected area.
- **Periodic** — battery/signal icons and RSSI are repainted by the slower periodgated tasks in `ui_tick_normal` (counters `0x20000BF5/BF6/BF8`), independent of `g_dirty`.
- **Model:** directtoGDDRAM (no framebuffer, `display.md` §1). Partial repaint is safe because elements occupy fixed, nonoverlapping regions.
---
## 7. Recipe to REPLACE the home screen
### 7.1 Hook point
Redirect the draw dispatch (`main-loop.md` Option A): patch `bl 0x0801E1BC` at **`0x080207E6`** → `bl my_draw_router`. In `my_draw_router`, `if (g_screen==0) my_home(); else <call stock 0x0801E1BC for other screens>`. (Symmetrically patch key dispatch `0x08020816` if you want custom home navigation — see `input.md`.)
### 7.2 Two build strategies
**Strategy A — reuse stock formatter, relayout only (least work).**
Keep calling `format_area_display(area, hi, shift) @0x08011414` (it does the codeplug→ASCII math for you), then read the readymade strings from `g_disp @0x200009C3` and paint them wherever you like with `draw_string`. You get frequency/name/mode formatting for free and never touch the cache or codeplug.
```c
// stock entry points (thumb; set bit0 when taking a function pointer)
void format_area_display(u8 area,u8 hi,u8 shift); // 0x08011414 — fills g_disp
void draw_string(u8 pg,u8 x,const char*s,u8 n,u8 mode); // 0x08008A50
void draw_number_row(u8 pg,u8 x,const u8*d,u16 n,u8 mode);// 0x08008B90 (big freq digits)
#define G_DISP ((volatile u8*)0x200009C3)
void my_home(void){
format_area_display(g_active_area,2,0); // stock builds strings into g_disp
for(u8 pg=0;pg<8;pg++) blit_cols(pg,128,0,0); // clear (display.md §2.1)
draw_string(0, 2, (char*)&G_DISP[0x00], 16, 0); // top line
draw_string(2, 4, (char*)&G_DISP[0x20], 16, 0); // Area A main (freq/name)
draw_string(5, 4, (char*)&G_DISP[0x32], 16, 0); // Area B main
// add your own layout, icons, TG/CC from g_call@0x2000A6C5, battery, etc.
}
```
**Strategy B — full custom, read straight from the cache (max control).**
Skip `g_disp` entirely. Read the live channel cache and radio getters yourself:
```c
#define CH(area) ((volatile u8*)(0x20002DEA + (area)*0x30))
u32 rx = *(u32*)(CH(area)+5); // RX freq, MHz*100000
u32 tx = *(u32*)(CH(area)+9); // TX freq
u16 rtone = *(u16*)(CH(area)+0xD) & 0x0FFF; // RX CTCSS/DCS
u8 isDMR = CH(area)[0] >> 6; // 1 => digital
char*name = (char*)(CH(area)+0x20); // 16-byte channel name
// DMR RX overlay:
#define CALL ((volatile u8*)0x2000A6C5) // +1 type, +2 id, +0x38 name
// battery volts: call 0x08010960 (raw*4/0x42 -> tenths of a volt)
```
Format freq with `num_to_ascii(rx,8) @0x08018530` (→ `0x200024D0`) then `str_insert_char(buf,'.',3,len) @0x080135A8`, or roll your own. Paint with the `display.md` primitives.
### 7.3 Data getters + draw primitives your home screen will call
| purpose | vaddr | prototype |
|---|---|---|
| build all home strings (opt.) | `0x08011414` | `format_area_display(u8 area,u8 hi,u8 shift)` |
| decimal ASCII | `0x08018530` | `num_to_ascii(uint val,int ndigits)``0x200024D0` |
| insert char (decimal pt) | `0x080135A8` | `str_insert_char(char*buf,char ch,int pos,int len)` |
| byte copy w/ offset | `0x080062EC` | `memcpy_off(void*dst,const void*src,int dstoff,int len)` |
| text | `0x08008A50` | `draw_string(pg,x,s,n,mode)` |
| big freq digits | `0x08008B90` | `draw_number_row(pg,x,digits,n,mode)` |
| spaced small text | `0x08008BC6` | `draw_str_spaced(pg,x,s,n,mode)` |
| A/B arrow / batt marker | `0x0800870C` / `0x08008874` | `draw_marker5(page,on)` (col 123) |
| highlight frame corners | `0x08008224` | `draw_frame_corners(x,page)` |
| battery/signal icons | `0x08008874`/`0x08008A04` | (see `display.md`) |
| battery volts (ADC) | `0x08010960` | `u8 batt_read(void)` (tenths V) |
| clear region | `0x08008D24` | `blit_cols(pg,count,NULL,0)` |
### 7.4 Boundary compliance (hard constraint)
Everything above **reads** RAM caches (`0x20002DEA`, `0x2000A6C5`, `0x200009C3`) and calls **display + ADC** primitives. None of it writes SPI codeplug regions or touches the USART6 CPS framer / SPI regionwrite engine (`main-loop.md` §5). The channel cache `0x20002DEA` mirrors the onflash 48byte channel record but is a *separate RAM copy* — reading it cannot change the codeplug format. **CPS + codeplug compatibility is unaffected.**
---
## 8. Confidence
- **HIGH** — home screen = id 0, draw handler `0x08013BD8`; the formatter `format_area_display @0x08011414`; the display struct `g_disp @0x200009C3` field map; the channel cache `0x20002DEA` (48byte stride, RX@+5/TX@+9/tones@+0xD,0xF/name@+0x20); helpers `num_to_ascii 0x08018530`, `str_insert_char 0x080135A8`, `memcpy_off 0x080062EC`; the dirtyflag model (`g_dirty 0x200024EB`) and the `0x080095DA` trigger. All directly disassembled, literal pools resolved, inline templates decoded (`"CH MODE"/"VFO MODE"/"CH-"/"A-"/"D-"/"ANA"/"DMR"`).
- **MEDIUMHIGH** — exact page (row) numbers per element: the `r4/r5/r6` row registers are reordered by `active_area` at handler entry, so the absolute page of Area A vs B swaps with selection; the column xvalues are exact (from the draw calls). Battery/signal icon columns per `display.md`.
- **MEDIUM** — the DMR TG/ColorCode overlay path (`g_call 0x2000A6C5`, near `0x08006754`) is identified and its fields typed, but the full CC/slot rendering sequence on the standby line is only partially traced (it interleaves with FM100B RX events); the analog FM/AM/SSB subtag is inferred from the `RX Demod (A)` channel field rather than a distinct draw routine. Confirm ondevice when redesigning the DMR overlay.
+393
Просмотреть файл
@@ -0,0 +1,393 @@
# 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.