Files
viktorиClaude Opus 4.8 ae36c3b729 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

222 строки
18 KiB
Markdown
Исходник Постоянная ссылка Ответственный История

Этот файл содержит неоднозначные символы Юникода
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.