# 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 function’s 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.
