# JieLi AD15N (AD152A4): hardware and software architecture

This document describes the JieLi AD15N family as found on the AD152A4 "shark"
toy: the CPU and its instruction set, the SoC around it, the boot ROM and its
download protocol, a programmer built from an ESP32-C6, and the flash image
format. It aims to hold enough detail to write an emulator of the chip and to
build and flash new firmware.

Nothing here comes from a JieLi manual for the CPU, because none is
published. The facts come from four kinds of evidence. Each claim carries a
tag that says which:

| Tag | Meaning |
|---|---|
| **[HW]** | Measured on an AD152A4 with a probe program, a scope or the dongle |
| **[DIS]** | Read from a disassembly: the mask ROM dump, the RAM loader, the toy's app, the SDK's annotated listing |
| **[TC]** | Checked against JieLi's own toolchain (their clang, assembler and shipped libraries) |
| **[DOC]** | From the AD15N chip manual or SDK headers, not otherwise checked |
| **[SPEC]** | Inferred and not checked. Treat as a guess |

Main sources in this tree:

| Path | What it holds |
|---|---|
| `fw-AD15N/sdk/app/post_build/sh55/sdk.lst` | JieLi's annotated disassembly of an SDK build, in their arithmetic syntax. The best single reference for instruction meaning |
| `fw-AD15N/sdk/include_lib/cpu/sh55/{sfr.h,csfr.h,hwi.h}` | Peripheral and core register layouts |
| `fw-AD15N/doc/ad15n/AD15N_芯片手册.pdf` | Chip manual: interrupts, timers, UART, IOMC, clocks |
| `backups/toy-flash-2026-09-28.bin`, `.app.dis` | Full flash backup of the toy and an annotated disassembly of its app |
| `os/os/kernel/src/platform/arch/pi32/`, `os/drivers/mcu/jieli/pi32/` | The osdyne port: interrupt entry, context switch, register maps measured on the chip |
| `os/tools/jieli-uart`, `os/tools/lib/jieli-image` | Current flashing tool and image packer (Rust) |

External references:

- kagaimiq, *jielie*, notes on JieLi chips: https://github.com/kagaimiq/jielie
  - The pi32 instruction set: https://github.com/kagaimiq/jielie/blob/main/cpu/pi32.md
  - The UART download key: https://github.com/kagaimiq/jielie/blob/main/isp/uart/uart-key.md
  - Loading a loader over UART: https://github.com/kagaimiq/jielie/blob/main/isp/uart/load-a-loader.md
  - The flash file system: https://github.com/kagaimiq/jielie/blob/main/datafmt/jlfs.md
- Quarkslab, Ghidra processor module for JieLi's cores (pi32, pi32v2, q32s): https://github.com/quarkslab/ghidra-jieli
- Quarkslab blog, "Nerd life weeks: firmware teardown, we were right": https://blog.quarkslab.com/nerd-life-weeks-firmware-teardown-we-were-right.html

---

## Contents

1. [The chip at a glance](#1-the-chip-at-a-glance)
2. [The CPU: pi32 / SH55](#2-the-cpu-pi32--sh55)
3. [The instruction set](#3-the-instruction-set)
4. [Interrupts and exceptions](#4-interrupts-and-exceptions)
5. [Memory map and peripherals](#5-memory-map-and-peripherals)
6. [Boot: mask ROM, key, download protocol, RAM loader](#6-boot-mask-rom-key-download-protocol-ram-loader)
7. [Wiring an ESP32-C6 as the programmer](#7-wiring-an-esp32-c6-as-the-programmer)
8. [The flash image and how to build one](#8-the-flash-image-and-how-to-build-one)
9. [Notes for an emulator](#9-notes-for-an-emulator)
10. [Open questions](#10-open-questions)

---

## 1. The chip at a glance

| Item | Value |
|---|---|
| Part | AD152A4 (AD15N family, SOP16). The SDK calls the chip "SH55", the flash header names "SH54" |
| CPU | pi32, JieLi's own 32-bit core derived from Analog Devices' Blackfin. Little endian, 16 GPRs, 64-bit multiply-accumulator, no FPU, no MMU |
| Clock | As the bootloader leaves it: CPU and bus (hsb) 48 MHz (the SDK's own clock setup raises the CPU to 80 MHz), PLL 480 MHz locked to an internal ~200 kHz LRC oscillator. No crystal, so all clocks run ~0.2 % slow [HW] |
| RAM | 20 KiB at 0x0 to 0x4FFF [DOC][HW]. The top 0x300 bytes belong to the ROM and uboot |
| ROM | 16 KiB mask ROM at 0xC0000 [HW] |
| Flash | Internal SPI NOR in the package (Puya, JEDEC id 0x856013, 512 KiB on this toy) [HW], executed in place through a cache at 0x100000 |
| Peripherals | GPIO (PA0-15, PB0-11, PD0-4), 3 timers + tick timer, 2 UARTs, 2 SPI, IIC, ADC, class-D audio DAC, IR filter, MCPWM, USB, watchdog in a separate always-on "P33" domain |

### Package pinout (SOP16)

| Pin | Function | Pin | Function |
|---|---|---|---|
| 1 | PA8 | 9 | PB11 / DACP |
| 2 | PA7 | 10 | PB8 / DACN |
| 3 | PA6 | **11** | **PB9**: UART1 single-wire, download pin, console |
| 4 | PA2 (toy: IR receiver) | 12 | PB2 |
| 5 | PA1 (toy: IR receiver power) | 13 | PB1 (toy: right motor) |
| 6 | VDDIO (internal LDO output, ~3.0 V) | 14 | PB0 (toy: right motor) |
| 7 | VBAT (2.0 to 5.5 V) | 15 | PA15 (toy: left motor) |
| 8 | VSS | 16 | PA14 (toy: left motor) |

Source: `os/tools/pinctrl-gen/data/jieli/ad152a-sop16.csv`, the AD152A datasheet
V1.3, and the toy's own firmware (`backups/toy-flash-2026-09-28.app.dis`).

---

## 2. The CPU: pi32 / SH55

### 2.1 Registers

**General purpose registers.** `r0` to `r14`, and `r15`, which is the stack
pointer and is written `sp`. All 32 bits.

**Special function registers** (SFRs) are reached with `mov`, `movh`/`movl`/`movs`,
`pushs`/`pops` and the SFR forms of the load/store-multiple instructions. Their
numbering:

| # | Name | Use |
|---|---|---|
| 0 | `reti` | Return address of an interrupt or exception. Written by hardware on entry, read by `rti` |
| 1 | `rete` | Emulator/exception return. Read 0 after every CPU exception seen on hardware [HW]; meaning unclear |
| 2, 3 | `sfr2`, `sfr3` | Unknown |
| 4 | `maccl` | Low word of the 64-bit accumulator |
| 5 | `macch` | High word of the accumulator |
| 6 | `rets` | Return address of a subroutine. Written by `call` |
| 7 | `psr` | Status: N Z C V flags |
| 8, 9 | `sfr8`, `sfr9` | Unknown |
| 10 | `ie1` | Interrupt enable 1. Read 0 at app entry [HW]; the controller is configured through memory-mapped ICFG registers instead |
| 11 | `ssp` | Supervisor stack pointer, used while handling an interrupt |
| 12 | `ie0` | Interrupt enable 0. Read 0 [HW] |
| 13 | `icfg` | Interrupt configuration: global enable and running priority (§4) |
| 14 | `pc` | Program counter |
| 15 | `usp` | User stack pointer, used outside interrupts |

### 2.2 The status register `psr`

| Bit | Flag |
|---|---|
| 0 | V, signed overflow [SPEC: never observed set; Ghidra module and position by elimination] |
| 1 | C, carry [HW] |
| 2 | Z, zero [HW] |
| 3 | N, negative [HW] |

Other bits are unknown. `psr` is 0x2 at app entry [HW].

**Carry follows the ARM convention.** After a subtract or compare, C = 1 means
*no borrow* (unsigned a >= b). Measured `cmp a, b` results [HW]:

| a, b | psr | Flags |
|---|---|---|
| 16000, 1000 | 0x2 | C |
| 1000, 1000 | 0x6 | Z C |
| 5, 1000 | 0x8 | N |
| -5, 1000 | 0xA | N C |
| 16000, -10 | 0x0 | none |
| -5, -10 | 0x2 | C |

**Every data-processing instruction writes the flags**, not just compares
[HW]. Measured: `add` and `sub` in all forms (register, immediate,
sp-relative), `addc`, `subc`, `and`, `or`, `xor`, the shifts, `mul`, `udiv`,
`sxtb`. The vendor compiler relies on this (`r5 = r5 + 1; ifs (nzcv != 0)`).
**Moves, loads and stores leave the flags alone**: `mov`, `movz`, `movh`,
`movl`, `movs`, the encoded-constant move, all loads and stores [HW].

What each class does to C and V is only partly measured:

| Class | N, Z | C | V |
|---|---|---|---|
| `add`, `addc`, `cmn` | from result | carry out [HW for add] | signed overflow [SPEC] |
| `sub`, `subc`, `cmp`, `rsb`, `neg` | from result | NOT borrow [HW] | signed overflow [SPEC] |
| Shifts | from result | Observed cleared by `lsl`/`lsr` with no bits shifted out [HW]; carry-out unknown | unknown |
| Logic, extend, mul, div | from result | unknown; clearing C and V is a reasonable guess [SPEC] | unknown |

### 2.3 Condition codes

Used by the conditional branches (`j<cc>`), the predication prefix (`if<cc>`)
and the register-indirect conditional jumps. The numbering is ARM's [TC][HW]:

| cc | Name | Condition | Vendor syntax |
|---|---|---|---|
| 0 | eq | Z | `ifs (nzcv == 0)` |
| 1 | ne | !Z | `ifs (nzcv != 0)` |
| 2 | cs | C | `if (nzcv >= 0)` (unsigned >=) |
| 3 | cc | !C | `if (nzcv < 0)` (unsigned <) |
| 4 | mi | N | |
| 5 | pl | !N | |
| 6 | vs | V | |
| 7 | vc | !V | |
| 8 | hi | C && !Z | `if (nzcv > 0)` (unsigned >) |
| 9 | ls | !C \|\| Z | `if (nzcv <= 0)` (unsigned <=) |
| 10 | ge | N == V | `ifs (nzcv >= 0)` |
| 11 | lt | N != V | `ifs (nzcv < 0)` |
| 12 | gt | !Z && N == V | `ifs (nzcv > 0)` |
| 13 | le | Z \|\| N != V | `ifs (nzcv <= 0)` |
| 14 | always | true | |
| 15 | | Unused (no encoding seen) | |

The vendor prints `if` for unsigned and `ifs` for signed conditions.

### 2.4 Memory model

- Byte addressed, little endian, 32-bit flat address space.
- Halfword and word accesses must be aligned. A misaligned word store (`sw`
  to 0x3F1) raised a CPU exception with the faulting PC in `reti` [HW]. The
  SDK enables the trap explicitly (`EMU_CON = 0x8007`: misalign, illegal
  instruction, divide by zero) [DIS]. Whether the trap is on by default
  after the uboot is not measured.
- There is no compare-and-swap or exclusive access. `testset b[rX]` exists
  but was never exercised. osdyne implements atomics as restartable
  sequences [HW].

### 2.5 Calling convention

From JieLi's own compiler, checked twice [TC]:

- Arguments: integers and pointers in `r0` to `r3`, then 4-byte stack slots
  from `[sp]` upwards (5th at `[sp]`, 6th at `[sp + 4]`).
- A 64-bit argument takes two consecutive registers, low word first, with no
  alignment gap: `(long long a, int b)` puts `a` in `r0:r1` and `b` in `r2`.
- Results: `r0`, or `r0:r1` (low, high) for 64 bits.
- Structs are passed by pointer to a caller-made copy, whatever their size.
  A struct return uses a hidden pointer in `r0`.
- `r0` to `r3` are caller-saved; `r4` to `r14` are callee-saved; `rets` is
  saved by any function that calls.
- Narrow (`char`, `short`) return values are sign- or zero-extended by the
  callee.
- Varargs: the callee spills the unnamed register arguments to a save area
  that `va_arg` walks.
- Typical prologue/epilogue: `push {rets, r7-r4}` ... `pop {pc, r7-r4}`. The
  pop into `pc` *is* the return; no `rts` follows.
- Data layout recorded in the SDK's LLVM bitcode libraries: `e-m:e-p:32:32-i1:32-i64:32-f64:32:32-n8:16:32-a:0:32`.
- ELF machine number 0xF0 (240), `e_flags` 0.

---

## 3. The instruction set

### 3.1 Instruction words and length

An instruction is one or two 16-bit **iwords**, each stored little endian.
For a two-iword instruction the iword holding the opcode comes **first**. So
the 32-bit value `0xEA80_1234` is stored as bytes `80 EA 34 12`.

**Length rule:** if the first iword has its top three bits set
(`iword & 0xE000 == 0xE000`, i.e. 0xE000 to 0xFFFF), the instruction is two
iwords long; otherwise it is one. Instructions are halfword aligned.

```c
uint16_t w0 = read16(pc);
if ((w0 & 0xE000) == 0xE000) { insn = (w0 << 16) | read16(pc + 2); len = 4; }
else                         { insn = w0;                          len = 2; }
next_pc = pc + len;
```

In the tables below bit patterns are written most significant bit first,
with the first iword on the left for 32-bit instructions. Letters stand for
fields:

| Letter | Field |
|---|---|
| `x` | Rx, usually the destination |
| `y` | Ry, usually the first source |
| `z` | Rz, second source |
| `X`, `Y` | The high (fourth) bit of a 4-bit register split from its low three bits |
| `i` | Immediate |
| `d` | Branch displacement |
| `c` | Condition code |
| `s` | Size or sub-opcode |
| `r` | Register mask |
| `w` | 12-bit encoded constant (§3.2) |

A 3-bit register field reaches `r0` to `r7`; a 4-bit field reaches all 16
registers (15 = `sp`), or all 16 SFRs where the instruction names SFRs.

Mnemonics follow the older JieLi toolchain's "mnemonic" syntax, as kagaimiq's notes give it.
The "Vendor" column gives JieLi's current arithmetic syntax as printed in
`sdk.lst`, where it helps.

### 3.2 Operand encodings

**Branch displacements.** Stored halved, sign-extended, and counted from the
**next** instruction (the address after the branch) [TC: 459 of 459 calls in
JieLi's libraries land on a symbol under this rule, none under the other].

```
target = next_pc + (sign_extend(field, width) << 1)
```

Example [DIS]: `ff 0f` (0x0FFF, `j` with field -1) at 0xC0012 is `j 0xC0012`,
a loop on itself.

**PC-relative literal loads.** The "absolute" `lw rX, addr` forms (16-bit
`00101...` and 32-bit `11100100 ...`) load from a literal pool relative to
the PC:

```
addr = ((pc + len) & ~3) + (offset_field << scale)
```

`pc + len` is the next instruction; the base is rounded down to a word.
Checked on several pools [DIS]. Example: `c1 28` at 0xC0454 (`lw r1, 12`)
reads `((0xC0456) & ~3) + 12 = 0xC0460`, which holds 0x00004EF0. Example from
the SDK: `20 e4 5c c9` at 0x10B012 is `r12 = [addr(5924)]`, and
`0x10B014 + 5924 = 0x10C738`, the listed pool entry. The SDK's offsets are
always positive. Whether the field is signed is not known; that matters only
for offsets past half the range.

**The 12-bit encoded constant ("WeirdImm")** used by `movz`, `add`, `sub`,
`rsb`, `cmp`, `tst`, `and`, `or`, `xor`, `andnot`, `mul` and `memor`/`memandnot`
[TC]:

| Field (bits 11-0) | Value |
|---|---|
| `0000 bbbbbbbb` | `0x000000bb` |
| `0001 bbbbbbbb` | `0x00bb00bb` |
| `0010 bbbbbbbb` | `0xbb00bb00` |
| `0011 bbbbbbbb` | `0xbbbbbbbb` |
| `rrrrr ccccccc`, r = 8..31 | `(0x80 \| c) << (32 - r)` |

```c
uint32_t wimm(uint32_t f) {
    uint32_t b = f & 0xff;
    switch (f >> 8) {
    case 0: return b;
    case 1: return b << 16 | b;
    case 2: return b << 24 | b << 8;
    case 3: return b * 0x01010101u;
    }
    return (0x80u | (f & 0x7f)) << (32 - (f >> 7));
}
```

Examples: 1.0f (0x3F800000) is `r=9, c=0x7F`; 0x80000000 is `r=8, c=0`.

**Shift amounts.** A 5-bit shift field of 0 means 32; a 6-bit one, 0 means 64.

**Counts.** `rep` sizes and counts and `if` then-counts are stored minus one.

**push/pop register sets.** A 4-bit set number, not a mask, plus one bit for
`rets` (push) or `pc` (pop):

| Set | Registers | Set | Registers |
|---|---|---|---|
| 0 | r3-r0 | 8 | r8-r4 |
| 1 | r3-r1 | 9 | r9-r4 |
| 2 | r3, r2 | 10 | r10-r4 |
| 3 | r3 | 11 | r11-r4 |
| 4 | r4 | 12 | r12-r4 |
| 5 | r5, r4 | 13 | r13-r4 |
| 6 | r6-r4 | 14 | r14-r4 |
| 7 | r7-r4 | 15 | none |

**Register masks** for `lm`/`sm` (8 bits, r0 to r7), `pushs`/`pops` (8 bits,
sfr0 to sfr7), and the 32-bit multiple forms (16 bits, bit n = register n;
for GPR lists bit 15 means `pc` on a load and `rets` on a store instead of
`sp`).

### 3.3 One-iword instructions

**System and control**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0000 0000 0000 0000` | `nop` | |
| `0000 0000 0000 0010` | `bkpt` | Emulator breakpoint (source 0) |
| `0000 0000 0000 0011` | `hbkpt` | Hardware breakpoint |
| `0000 0000 0000 0100` | `clrmacc` | `macc = 0` |
| `0000 0000 0000 0111` | ? | Seen 4 times in JieLi's libraries; meaning unknown |
| `0000 0000 0000 1000` | `csync` | Core sync. Treat as `nop` |
| `0000 0000 0000 1001` | `ssync` | System sync. Treat as `nop` |
| `0000 0000 0000 1010` | `illeg` | Illegal instruction (raises the exception) |
| `0000 0000 0000 1100` | `lockset` | Bus lock. Treat as `nop` |
| `0000 0000 0000 1110` | `lockclr` | |
| `0000 0000 0001 0000` | `idle` | Stop until an interrupt is pending. Timers keep running [HW] |
| `0000 0000 0010 0000` | `rts` | `pc = rets` |
| `0000 0000 0011 0000` | `rti` | Return from interrupt (§4) |
| `0000 0000 0011 1000` | `rte` | Return from exception/emulation |
| `0000 0000 0100 0000` | `cli` | Clear the global interrupt enable |
| `0000 0000 0101 xxxx` | `cli rX` | `rX = icfg`, then clear the global enable |
| `0000 0000 0110 0000` | `sti` | Set the global interrupt enable |
| `0000 0000 0111 xxxx` | `sti rX` | Restore the enable state saved by `cli rX` |
| `0000 0000 10ii iiii` | `swi imm6` | Software interrupt. **Does not trap on the AD152A4** [HW]; use `ILAT_SET` (§4.3) |
| `0000 0000 11ii iiii` | `excpt imm6` | Raise an exception |

**Hardware loops and predication**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0000 0001 0Xss sxxx` | `rep s+1, rX` | Run the next `s+1` **iwords** `rX` times |
| `0000 0010 sssi iiii` | `rep s+1, i+1` | Run the next `s+1` iwords `i+1` times (1 to 32) |
| `0000 0011 cccc ffTT` | `if<cc> T+1, f` | If cc holds, run the next `T+1` **instructions** and skip the `f` after them; otherwise skip `T+1` and run `f`. cc = 14 is unconditional (`if`) |

The field order of `if` (f in bits 3-2, T in bits 1-0) was checked against
JieLi's assembler [TC]. The units were read off the SDK listing [DIS]:

```
10012a: 29 03          if (nzcv >= 0) {           ; cc=2 (cs), f=2, T=1 -> 2 then, 2 else
10012c: 03 80              r3 = 0                 ; 1 iword
10012e: 90 ee 22 20        r2 = r2 - 32           ; 2 iwords
                       } else {
100132: 13 c6              r3 = r2
100134: 02 80              r2 = 0
                       }
100136: 0b 01          rep 2, r3 {                ; body = 2 iwords = one 32-bit insn
100138: 48 e8 01 40        b[r0 ++= 1] = r1
                       }
```

So `if` counts instructions (2 instructions, 3 iwords, in the then-block),
while `rep` counts iwords (`rep 2` covers one 4-byte instruction; elsewhere
`rep 4, r4` covers two). This memset loop also shows that a **register count
of 0 runs the body 32 times**: the then-branch sets `r3 = 0` to clear a
32-byte chunk [DIS; the hardware behaviour is inferred from this code being
correct]. Model the register count as `((rX - 1) & 31) + 1`, or as 32 when
`rX == 0`, and log other values over 32 until it is measured. Whether an
interrupt can be taken inside a `rep` body is unknown.

**Register-indirect control and cache**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0000 0001 1X00 0xxx` | `flush rX` | Data cache line flush. `nop` for an emulator |
| `0000 0001 1X00 1xxx` | `flushinv rX` | Flush and invalidate |
| `0000 0001 1X01 0xxx` | `iflush rX` | Instruction cache invalidate |
| `0000 0001 1X01 1xxx` | `pfetch rX` | Prefetch |
| `0000 0001 1X10 0xxx` | `j rX` | `pc = rX` |
| `0000 0001 1X10 1xxx` | `call rX` | `rets = next_pc; pc = rX` |
| `0000 0001 1X11 0xxx` | `tbb rX` | Table branch, byte: `pc = next_pc + (b[rX] << 1)` [SPEC: Thumb-style] |
| `0000 0001 1X11 1xxx` | `tbh rX` | Table branch, halfword: `pc = next_pc + (h[rX] << 1)` [SPEC] |

**Multiply and accumulate**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0000 0101 0000 xxxx` | `divmacc rX` | `macc = macc / rX` (unsigned) |
| `0000 0101 1000 xxxx` | `sdivmacc rX` | Signed |
| `0000 0110 00yy yxxx` | `mul rX, rY` | `rX = rX * rY` (low 32 bits); sets flags |
| `0000 0110 01yy yxxx` | `mulmacc rX, rY` | `macc = rY * rX` (unsigned 32x32 -> 64) |
| `0000 0110 11yy yxxx` | `smulmacc rX, rY` | Signed |
| `0000 0111 00yy yxxx` | `macmacc rX, rY` | `macc += rY * rX` |
| `0000 0111 01yy yxxx` | `msumacc rX, rY` | `macc -= rY * rX` |
| `0000 0111 10yy yxxx` | `smacmacc rX, rY` | Signed `+=` |
| `0000 0111 11yy yxxx` | `smsumacc rX, rY` | Signed `-=` |

`macc` is the 64-bit pair `macch:maccl`. A 32x32->64 multiply in C becomes
`mulmacc` then `r0 = maccl; r1 = macch` [TC].

**Branches**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0000 0100 dddd dddd` | `call d8` | `rets = next_pc; pc = next_pc + (sext8(d) << 1)` |
| `0000 1ddd dddd dddd` | `j d11` | `pc = next_pc + (sext11(d) << 1)` |
| `0001 cccc dddd dddd` | `j<cc> d8` | If cc: `pc = next_pc + (sext8(d) << 1)`. cc=14 is `jmp` |
| `0010 0ddd dddd 0xxx` | `jz rX, d7` | If `rX == 0`: `pc = next_pc + (sext7(d) << 1)` |
| `0010 0ddd dddd 1xxx` | `jnz rX, d7` | If `rX != 0` |

`jz`/`jnz` do not touch the flags.

**Loads and stores**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `0010 1aaa aabb bxxx` | `lw rX, lit` | `rX = [pc_base + ({bbb,aaaaa} << 2)]`, see §3.2 |
| `0011 0xxx rrrr rrrr` | `lm {mask}, rX` | Load r0-r7 in the mask from `[rX]` upwards; `rX` advances by 4 per register |
| `0011 1xxx rrrr rrrr` | `sm {mask}, rX` | Store, same scheme |
| `0100 0aaa aabb bxxx` | `lw rX, sp, o` | `rX = [sp + sext8({bbb,aaaaa}) * 4]` |
| `0100 1ooo ooyy yxxx` | `lw rX, rY, o` | `rX = [rY + sext5(o) * 4]` |
| `0101 0Baa aayy yxxx`* | `lhz rX, rY, o` | `rX = zext h[rY + sext5(o) * 2]`; field bit 10 is offset bit 0, bits 9-6 are offset bits 4-1 |
| `0101 1BBa aayy yxxx`* | `lbz rX, rY, o` | `rX = zext b[rY + sext5(o)]`; bits 10-9 are offset bits 1-0, bits 8-6 are offset bits 4-2 |
| `0110 0aaa aabb bxxx` | `sw rX, sp, o` | `[sp + sext8(o) * 4] = rX` |
| `0110 1ooo ooyy yxxx` | `sw rX, rY, o` | `[rY + sext5(o) * 4] = rX` |
| `0111 0...` | `sh rX, rY, o` | Halfword store, offset as `lhz` |
| `0111 1...` | `sb rX, rY, o` | Byte store, offset as `lbz` |

\* The halfword and byte forms scramble the offset bits, exactly as given.

**Immediate arithmetic**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1000 0iii iiii ixxx` | `movs rX, s8` | `rX = sext8(i)`; flags unchanged |
| `1000 1iii iiii ixxx` | `add rX, s8` | `rX += sext8(i)` |
| `1001 0iii iiii ixxx` | `add rX, sp, s8` | `rX = sp + sext8(i)` (bytes, unscaled) |
| `1001 1iii iiii ixxx` | `cmp rX, s8` | flags of `rX - sext8(i)` |
| `101i iiii 00yy yxxx` | `lsl rX, rY, n` | `rX = rY << n` (n=0 means 32) |
| `101i iiii 01yy yxxx` | `lsr rX, rY, n` | Logical right |
| `101i iiii 10yy yxxx` | `qasl rX, rY, n` | Arithmetic left, saturating ("q") [SPEC: saturation unverified] |
| `101i iiii 11yy yxxx` | `qasr rX, rY, n` | Arithmetic right. The only arithmetic right shift; `abs()` uses `qasr rX, rY, 31` [TC] |

**Stack, moves and register arithmetic**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1100 0000 000p ssss` | `pop {pc?, set}` | Load the set (§3.2) from `[sp]` upwards, then `pc` if p; `sp` advances |
| `1100 0010 000r ssss` | `push {rets?, set}` | `sp` moves down by 4 per register; the set and then `rets` if r are stored |
| `1100 0011 0000 xxxx` | `testset rX` | Atomic test-and-set of `b[rX]` [SPEC: untested] |
| `1100 0100 rrrr rrrr` | `pops {sfr mask}` | Pop sfr0-sfr7 in the mask |
| `1100 0101 rrrr rrrr` | `pushs {sfr mask}` | Push sfr0-sfr7. ISRs start `pushs {psr, rets, reti}` = `c1 c5` |
| `1100 0110 YXyy yxxx` | `mov rX, rY` | |
| `1100 0111 YXyy yxxx` | `mov sfrX, rY` | Write an SFR. `icfg = r0` is `45 c7` |
| `1100 1000 YXyy yxxx` | `mov rX, sfrY` | Read an SFR. `r4 = rets` is `34 c8` |
| `1100 1001 00yy yxxx` | `uxtb rX, rY` | `rX = rY & 0xff` (vendor `rX = rY.b`) |
| `1100 1001 01yy yxxx` | `uxth rX, rY` | `& 0xffff` (`rX = rY.l`) |
| `1100 1001 10yy yxxx` | `sxtb rX, rY` | Sign-extend byte (`rY.b (s)`) |
| `1100 1001 11yy yxxx` | `sxth rX, rY` | Sign-extend halfword |
| `1100 1010 YXyy yxxx` | `addrev rX, rY` | `rX += rY` with the carry running from bit 31 down (bit-reversed add, for FFTs; vendor `rX += rY (brev)`) |
| `1100 1011 YXyy yxxx` | `add rX, rY` | `rX += rY` |
| `1100 1100 YXyy yxxx` | `cmn rY, rX` | flags of `rY + rX` |
| `1100 1101 iiii iiii` | `add sp, s8` | `sp += sext8(i)` (bytes; vendor `sp += -104` is `98 cd`) |
| `1100 1110 YXyy yxxx` | `cmp rY, rX` | flags of `rY - rX`. Reaches all 16 registers. `08 ce` is `cmp r1, r0` |
| `1100 1111 00yy yxxx` | `lsl rX, rY` | `rX <<= rY` |
| `1100 1111 01yy yxxx` | `lsr rX, rY` | |
| `1100 1111 10yy yxxx` | `qasl rX, rY` | |
| `1100 1111 11yy yxxx` | `qasr rX, rY` | |
| `1101 00ii iiyy yxxx` | `add rX, rY, s4` | `rX = rY + sext4(i)` |
| `1101 010z zzyy yxxx` | `add rX, rY, rZ` | |
| `1101 011z zzyy yxxx` | `sub rX, rY, rZ` | `rX = rY - rZ` |
| `1101 1000 iiii ixxx` | `bitset rX, n` | `rX \|= 1 << n` |
| `1101 1001 iiii ixxx` | `bittgl rX, n` | `rX ^= 1 << n` |
| `1101 1011 iiii ixxx` | `bitclr rX, n` | `rX &= ~(1 << n)` |
| `1101 1010 00yy yxxx` | `or rX, rY` | `rX \|= rY` |
| `1101 1010 01yy yxxx` | `xor rX, rY` | |
| `1101 1010 10yy yxxx` | `and rX, rY` | |
| `1101 1010 11yy yxxx` | `not rX, rY` | `rX = ~rY` |
| `1101 1100 00yy yxxx` | `addc rX, rY` | `rX += rY + C` |
| `1101 1100 01yy yxxx` | `subc rX, rY` | `rX -= rY + !C` (ARM SBC) |
| `1101 1100 10yy yxxx` | `neg rX, rY` | `rX = -rY` |
| `1101 1100 11ii iiii` | `lslmacc n` | `macc <<= n` (n=0 means 64) |
| `1101 1101 00yy yxxx` | `rotr rX, rY` | Rotate right by `rY` |
| `1101 1101 01yy yxxx` | `rotrc rX, rY` | Rotate right through carry |
| `1101 1101 1X00 0xxx` | `lslmacc rX` | `macc <<= rX` |
| `1101 1110 0X00 0xxx` | `lsrmacc rX` | `macc >>= rX` (logical) |
| `1101 1110 1X00 0xxx` | `asrmacc rX` | Arithmetic |
| `1101 1111 00ii iiii` | `lsrmacc n` | |
| `1101 1111 10ii iiii` | `asrmacc n` | |

### 3.4 Two-iword instructions

Bits 31-16 are the first iword.

**Control**

| Pattern (31..0) | Mnemonic | Operation |
|---|---|---|
| `1110 0000 0000 0000 0000 0000 xxxx cccc` | `j<cc> rX` | If cc: `pc = rX` (cc=14: `jmp rX`) |
| `0xE080_0000` | `trigger` | Debug trigger. `nop` |
| `1110 0001 dddd...d(20) DDDD` | `call d24` | `rets = next_pc; pc = next_pc + (sext24({D,d}) << 1)`. Bits 23-4 are displacement bits 19-0, bits 3-0 are bits 23-20 |
| `1111 1010 dddd...d(20) DDDD` | `j d24` | Same field layout |
| `1111 1011 dddd...d(20) cccc` | `j<cc> d20` | If cc: `pc = next_pc + (sext20(d) << 1)` |

Example [DIS]: `00 e1 10 22` at 0xC000E is `0xE100_2210`; displacement
`0x221 << 1 = 0x442`, plus 0xC0012, is `call 0xC0454`.

**Memory read-modify-write**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1110 0010 OOOO wwww wwww wwww xxxx oooo` | `memor rX, off, w` | `[rX + sext8({O,o}) * 4] \|= wimm(w)` |
| `1110 0011 OOOO w... xxxx oooo` | `memandnot rX, off, w` | `&= ~wimm(w)` |

The SDK uses these for single-bit SFR updates: `00 e2 cd 0c` is
`[r12 + 52] |= 12`.

**PC-relative loads** (§3.2), first iword `1110 0100 ssss ssss`:

| Sub (bits 23-16) | Mnemonic | Field layout (bits 15-0) |
|---|---|---|
| 0x00 | `lbz rX, lit` | a[9:0] in 15-6, a[11:10] in 5-4, x in 3-0. Byte offset |
| 0x10 | `lhz rX, lit` | a[8:0] in 15-7, a[11:9] in 6-4. Offset × 2 |
| 0x20 | `lw rX, lit` | a[7:0] in 15-8, a[11:8] in 7-4. Offset × 4 |
| 0x80 | `lbs rX, lit` | As `lbz`, sign-extending |
| 0x90 | `lhs rX, lit` | As `lhz`, sign-extending |

Only `lw` has been seen in real code. The narrow forms are listed by analogy.

**Doubleword loads and stores**, first iword `1110 0101 ssss zzzz`:

| Sub | Mnemonic | Operation |
|---|---|---|
| 0000 | `ldd rX, rZ, rY, o` | `rX, rZ = d[rY + sext8(o) * 4]`. Bits 15-8 hold `o` |
| 0001 | `addldd` | Pre-increment form (bits 19-16 = written-back register, 11-8 = index) [SPEC] |
| 0010 | `lddadd` | Post-increment form [SPEC] |
| 0011 | `ldda rX, rY, o` | PC-relative doubleword |
| 0100 | `std rX, rZ, rY, o` | `d[rY + sext8(o) * 4] = rX, rZ` |
| 0101 | `addstd` | [SPEC] |
| 0110 | `stdadd` | [SPEC] |

Vendor examples: `02 e5 00 06` is `r0, r2 = d[r0 + 24]`; `01 e5 c0 fe` is
`r0, r1 = d[r12 + -8]` (the offset is signed).
Which register takes the lower word is not proven. Assume `rX = [a]`,
`rZ = [a + 4]` [SPEC, consistent with 64-bit values living in `r0:r1`].

**Load/store multiple**, `1110 0110 ssss xxxx rrrr rrrr rrrr rrrr`, base `rX`,
16-bit mask:

| Sub | Mnemonic | Direction | Base written back | Registers |
|---|---|---|---|---|
| 1000 | `lm` | Load, up | yes | GPR (bit 15 = pc) |
| 0000 | `lmz` | Load, up | no | GPR (bit 15 = pc) |
| 1010 | `lmd` | Load, down | yes | GPR |
| 0010 | `lmdz` | Load, down | no | GPR |
| 1001 | `lms` | Load, up | yes | SFR |
| 0001 | `lmzs` | Load, up | no | SFR |
| 1100 | `sm` | Store, up | yes | GPR |
| 0100 | `smz` | Store, up | no | GPR |
| 1111 | `smd` | Store, down (pre-decrement) | yes | GPR (bit 15 = rets) |
| 0111 | `smdz` | Store, down | no | GPR (bit 15 = rets) |
| 1110 | `smds` | Store, down | yes | SFR |
| 0110 | `smdzs` | Store, down | no | SFR |

`ff e6 ff 7f` is `smd {r14-r0}, sp` (vendor `[--sp] = {r14-r0}`) and
`8f e6 ff 7f` is `lm {r14-r0}, sp` (`{r14-r0} = [sp++]`).

**Register + register addressing**, `1110 0111 oooo oo00 0000 zzzz yyyy xxxx`:

The 6-bit op is `kind << 2 | mode`:

| kind | Access | kind | Access |
|---|---|---|---|
| 0000 | `lbz` | 0100 | `sb` |
| 0001 | `lhz` | 0101 | `sh` |
| 0010 | `lw` | 0110 | `sw` |
| 1000 | `lbs` (sign-extending byte) | 1001 | `lhs` (sign-extending halfword) |

| mode | Mnemonic | Address | Write-back |
|---|---|---|---|
| 00 | `lw rX, rY, rZ` | `rY + rZ` | none |
| 01 | `addlw` | `rY + rZ` | `rY = rY + rZ` (pre) |
| 10 | `lwadd` | `rY` | `rY = rY + rZ` (post) |

The index `rZ` is not scaled (vendor `r0 = [r0 + r10]`).

**Register + immediate addressing**, `1110 1000 oooo oo00 ...offset... yyyy xxxx`:
same 6-bit op as above, offset an 8-bit signed value scaled by the access
size:

| Size | Field layout in bits 15-8 |
|---|---|
| Byte | bits 15-14 = offset[1:0], bits 13-8 = offset[7:2] |
| Halfword | bit 15 = offset[0], bits 14-8 = offset[7:1]; offset × 2 |
| Word | bits 15-8 = offset; offset × 4 |

Vendor syntax for the modes: `b[r0 + 4] = r8` (plain), `b[++ r1 = 104] = r0`
(pre: `r1 += 104`, then store at the new `r1`), `b[r0 ++= 1] = r1` (post:
store at `r0`, then `r0 += 1`) [DIS].

**sp + 12-bit offset**, first iword `1110 1001 ssss ssss`, same field layouts
as the PC-relative loads, offset signed and scaled by size:

| Sub | 0x00 | 0x10 | 0x20 | 0x80 | 0x90 | 0x40 | 0x50 | 0x60 |
|---|---|---|---|---|---|---|---|---|
| Op | `lbz` | `lhz` | `lw` | `lbs` | `lhs` | `sb` | `sh` | `sw` |

**Constants and moves**, `1110 1010 hXss sxxx iiii iiii iiii iiii`:

| h, sss | Mnemonic | Operation | Vendor |
|---|---|---|---|
| 0, 000 | `movl rX, i16` | `rX = (rX & 0xffff0000) \| i` | `rX.l = i` |
| 0, 001 | `movl sfrX, i16` | Same on an SFR | |
| 0, 010 | `movz rX, i16` | `rX = i` (zero-extended) | |
| 1, 000 | `movh rX, i16` | `rX = (i << 16) \| (rX & 0xffff)` | `rX.h = i` |
| 1, 001 | `movh sfrX, i16` | | |
| 1, 010 | `movs rX, s16` | `rX = sext16(i)` | |
| 1, 011 | `movs sfrX, s16` | | |

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1110 1010 0010 wwww wwww wwww 0000 xxxx` | `movz rX, w` | `rX = wimm(w)`. The bit pattern is distinct from `movz rX, i16`, whose bits 21-19 are 010 |
| `1110 1011 0000 0000 0000 0000 yyyy xxxx` | `rev8 rX, rY` | Byte-reverse |
| `1110 1100 ss00 0000 0000 0000 yyyy xxxx` | `uxtb`/`uxth`/`sxtb`/`sxth` (ss=0..3) | All 16 registers |
| `1110 1101 000i iiii iiii iiii yyyy xxxx` | `add rX, rY, s13` | |

**Three-operand arithmetic and logic**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1110 1110 0000 0000 0000 zzzz yyyy xxxx` | `add rX, rY, rZ` | |
| `1110 1110 0100 0000 0000 zzzz yyyy xxxx` | `sub rX, rY, rZ` | `rY - rZ` |
| `1110 1110 1000 0000 0000 zzzz yyyy xxxx` | `addc rX, rY, rZ` | `+ C` |
| `1110 1110 1100 0000 0000 zzzz yyyy xxxx` | `subc rX, rY, rZ` | `- !C` |
| `1110 1110 0001 wwww wwww wwww yyyy xxxx` | `add rX, rY, w` | `rY + wimm` |
| `1110 1110 0010 w... yyyy xxxx` | `rsb rX, rY, w` | `wimm - rY` (vendor `r0 = 64 - r0`) |
| `1110 1110 1001 w... yyyy xxxx` | `sub rX, rY, w` | `rY - wimm` |
| `1110 1110 0011 ...` | ? | 18 occurrences in JieLi's libraries, always with constant 0. Probably arithmetic; unknown |
| `1110 1111 0000 wwww wwww wwww xxxx 0000` | `cmp rX, w` | |
| `1110 1111 1000 iiii iiii iiii xxxx 0000` | `cmp rX, s12` | -2048..2047 [HW] |
| `1111 0000 ss00 0000 0000 zzzz yyyy xxxx` | `or`/`xor`/`and`/`andnot` (ss=0..3) | `rX = rY op rZ` (`andnot`: `rY & ~rZ`) |
| `1111 0001 ss00 0000 0000 zzzz yyyy xxxx` | `bitset`/`bittgl`/`and1`/`bitclr` | `rX = rY \| (1<<rZ)`, `^ (1<<rZ)`, `& (1<<rZ)`, `& ~(1<<rZ)`. Vendor `r0 = r5 & setbit(r6)` |
| `1111 0010 0000 0000 0000 0000 yyyy xxxx` | `tst rX, rY` | flags of `rX & rY` |
| `1111 0010 0100 0000 0000 0000 yyyy xxxx` | `not rX, rY` | |
| `1111 0011 ss00 wwww wwww wwww yyyy xxxx` | `or`/`xor`/`and`/`andnot rX, rY, w` | With wimm |
| `1111 0100 0000 wwww wwww wwww xxxx 0000` | `tst rX, w` | flags of `rX & wimm`. Vendor `tst r4, 1` |
| `1111 0101 ss00 0000 0000 0000 yyyy xxxx` | `clz`/`cls`/`zcmp` (ss=0..2) | Count leading zeros / sign bits / ? |

**Bit fields**, `1111 0110 oooo oo pp ppp lllll yyyy xxxx` (pos in bits 17-13, len
in 12-8):

| op | Mnemonic | Operation |
|---|---|---|
| 000000 | `uextra rX, rY, p, l` | `rX = (rY >> p) & ((1 << l) - 1)` |
| 010000 | `insert rX, rY, p, l` | Insert the low `l` bits of `rY` into `rX` at `p` (vendor `r4 <= insert(r3, p:1, l:1)`) |
| 100000 | `sextra rX, rY, p, l` | Signed extract |
| 001000, 011000, 101000 | Register forms | Three registers; how `rZ` carries pos and len is unknown |

Vendor `01 f6 41 08` is `r1 = uextra(r4, p:8, l:8)`.

**Shifts, rotates, MAC moves, multiply, divide**

| Pattern | Mnemonic | Operation |
|---|---|---|
| `1111 0111 sss0 0000 0000 zzzz yyyy xxxx` | sss even: `lsl`/`lsr`/`qasl`/`qasr rX, rY, rZ` (000, 010, 100, 110) | Shift by register |
| `1111 0111 sss0 0000 000i iiii yyyy xxxx` | sss odd: the same with an immediate (001, 011, 101, 111) | |
| `1111 1000 sss0 ... zzzz yyyy xxxx` | `rotr` (000) / `rotrc` (100) by register; 010 / 110 by immediate | |
| `1111 1001 0000 0000 0000 yyyy 0000 xxxx` | `movz64 rX, rY` | MAC move, semantics unknown (probably `rX = macc >> rY`) [SPEC] |
| `1111 1001 0100 0000 00ii iiii 0000 xxxx` | `movz64 rX, n` | |
| `1111 1001 1000 ...` / `11 ...` | `movs64` | Signed (saturating?) forms [SPEC] |
| `1111 1100 0000 wwww wwww wwww yyyy xxxx` | `mul rX, rY, w` | `rY * wimm`. Vendor `r2 = r2 * 5` |
| `1111 1100 0100 0000 0000 zzzz yyyy xxxx` | `udiv rX, rY, rZ` | `rY / rZ` unsigned. Vendor `r2 = r1 / r2`. Divide by zero: unknown (the SDK traps it, `EMU_CON` bit 2) |
| `1111 1100 1000 0000 0000 zzzz yyyy xxxx` | `mul rX, rY, rZ` | |
| `1111 1100 1100 0000 0000 zzzz yyyy xxxx` | `sdiv rX, rY, rZ` | |
| `1111 1101 S0oo 0000 0000 0000 yyyy xxxx` | 32-bit forms of `mulmacc` (oo=00), `macmacc` (10), `msumacc` (11); S = signed | All 16 registers |
| `1111 1110 oooo oooo iiii zzzz yyyy xxxx` | `mulmaccm` family (memory operands, register steps) | `macc op= [rY] * [rX]`, pointers stepped by `ri`/`rz`. Bit 23 signed, bit 22 halfword first operand, bits 21-20 = 00 mul, 10 mac, 11 msu |
| `1111 1111 oooo SSSS SSss ssss yyyy xxxx` | Same family, immediate steps: S = step of `rY` (bits 19-14), s = step of `rX` (bits 13-8), 6-bit signed, ×4 | |

Unassigned or unknown patterns should raise the illegal-instruction exception
(source 1) and be logged, so missing encodings show up at once.

### 3.5 Assembly syntax

In the mnemonic syntax used above:

```asm
        movh    r0, %hi(table)
        movl    r0, %lo(table)
        lw      r1, r0, 4               // r1 = [r0 + 4]
        push    {rets, r7-r4}
        call    memcpy
        pop     {pc, r7-r4}
```

JieLi's current toolchain prints the same code in its arithmetic syntax
(`r1 = [r0 + 4]`, `[--sp] = {rets, r7-r4}`, `{pc, r7-r4} = [sp++]`), which is
what `sdk.lst` uses. kagaimiq's notes give both spellings for every encoding.

---

## 4. Interrupts and exceptions

### 4.1 Sources and vectors

The core vectors 32 sources through a **table of handler addresses in RAM at
0x4F00** (`_IRQ_MEM_ADDR`), one word per source [HW]. No base register for the
table has been found: treat 0x4F00 as fixed. Taking source *n* jumps to
`[0x4F00 + 4n]`.

| # | Source | # | Source |
|---|---|---|---|
| 0 | Emulator exception (bkpt); ignores enables | 16 | SPI0 |
| 1 | CPU exception (misalign, illegal, watchdog interrupt mode); ignores enables | 17 | SPI1 |
| 2 | "syscall": `swi` never reaches it on this chip [HW] | 18 | GPC |
| 3 | Tick timer (TTMR) [HW] | 19 | - |
| 4 | TMR0 | 20 | MCTMR |
| 5 | TMR1 [HW] (the ROM's key decoder uses it) | 21 | USB SOF |
| 6 | TMR2 | 22 | USB control |
| 7 | TDM | 23 | LP timer 0 |
| 8 | P33 | 24 | LP timer 1 |
| 9 | Audio DAC [HW] | 25 | ALINK |
| 10 | UART0 | 26 | LRCT |
| 11 | UART1 | 27 | SRC |
| 12 | SD0 | 28 | Software 0 [HW] |
| 13 | Port wakeup | 29-31 | Software 1-3 (31 also "bank") |
| 14 | IIC | | |
| 15 | ADC | | |

### 4.2 The interrupt controller

Memory-mapped in the core SFR block [DOC, partly HW]:

| Address | Register | Notes |
|---|---|---|
| 0x80100 + 4k | `ICFG00`..`ICFG15` | Source n lives in `ICFG[n / 8]`, nibble `4 * (n % 8)`. Nibble bit 0 = enable, bits 3-1 = priority (0 lowest, 7 highest) |
| 0x80180 + 4k | `IPND0`..`IPND7` | Read-only pending bits. A source shows as pending only while it is enabled [HW] |
| 0x801A0 | `ILAT_SET` | Write-only. Bit 0 raises source 28 [HW]. Other bits: no observable effect in one probe, a fault in another; treat as unknown |
| 0x801A4 | `ILAT_CLR` | Write-only. Clears a latched software interrupt |

The SDK's `HWI_Install(idx, isr, prio)` stores `isr` at `0x4F00 + 4 * idx`
and sets the source's nibble to `(prio << 1) | 1` with interrupts off [DIS].

**Global enable, `icfg`:**

- Bits 9-8: GIE per the manual. Interrupts are taken when bit 9 is set (and,
  per the manual, bit 8). Read 0x500 at app entry (bits 8 and 10) and 0x700
  after `sti` [HW]. `cli`/`sti` clear/set bit 9. `cli rX` returns the old
  `icfg` in `rX`, and `sti rX` restores it.
- Bits 7-0: IBIT, the running-priority mask (read-only). Taking an interrupt
  of priority *p* blocks sources of priority <= *p* until `rti` [DOC].
- The SDK startup does `icfg |= 0xB00`; the meaning of bits 10 and 11 is
  unknown. Inside an ISR `icfg` read 0x01030302 [HW], so the upper half also
  holds nesting state.

### 4.3 Taking an interrupt

The hardware does very little [DOC][HW]:

1. When a source is pending, enabled, above the running priority, and global
   interrupts are on, the core finishes the current instruction.
2. `reti` = the address of the next instruction to run.
3. The stack pointer bank switches: `sp` (r15) now reads and writes **`ssp`**.
   The interrupted code's stack pointer stays in **`usp`**.
4. The running priority is raised (IBIT), masking equal and lower priorities.
5. `pc = [0x4F00 + 4n]`.

Nothing is pushed and **psr is not saved**: every handler must save what it
uses, starting with `psr` before any arithmetic (an osdyne bug came from an
`add` placed before `mov r1, psr`). A typical vendor ISR:

```
pushs  {psr, rets, reti}         ; c1 c5
push   {r3-r0}                   ; or smd {r14-r0}, sp
...                              ; acknowledge the peripheral's pending bit
pop    {r3-r0}
pops   {psr, rets, reti}
rti
```

`rti`: `pc = reti`, the stack pointer bank returns to `usp`, and the running
priority drops one level.

**Quirks measured on hardware:**

- `rti` executed when no interrupt is active corrupts the priority tracking:
  the next interrupt then raises a CPU exception [HW]. osdyne enters its first
  thread through a real software interrupt for this reason.
- A software interrupt raised through `ILAT_SET` is taken a **variable number
  of instructions late**: 1 instruction in a tight loop in RAM, 4 or more in
  code running from flash [HW]. Code that raises one must spin on a marker
  the handler writes.
- `swi` does not trap at all [HW].
- `idle` sleeps until an interrupt is pending, and the tick timer keeps
  counting through it [HW].

**Reset state.** The ROM's first steps set `sp = 0x4A20` and `usp = 0x4E20`,
then do `reti = 0xC0362; rti` [DIS]. So the core leaves reset in interrupt
(supervisor) mode, using `ssp`, and the ROM's `rti` drops it into thread mode
on `usp`. An emulator should start at 0xC0000 with the "in interrupt" state
set and `sp` aliased to `ssp`.

### 4.4 Exceptions

- **CPU exception (source 1).** A misaligned access gives `reti` = the
  faulting instruction and `rete = 0` [HW]. Returning into data also faults,
  with `rete = 0` [HW].
- **EMU_CON** (0x800D0) enables traps: bit 0 misaligned access, bit 1
  illegal instruction, bit 2 divide by zero, bit 15 overall. The SDK writes
  0x8007 [DIS]. `EMU_MSG` (0x800D4) presumably reports the cause [SPEC].
- **The ROM's handler** (vector 1 is installed with priority 7 at boot,
  pointing to 0xC0004) saves `{usp, icfg, ssp, psr, rets, rete, reti}` and
  `{r14-r0}` with `smds`/`smd`, calls the hook stored at 0x4EF0, and resets
  the chip [DIS].

---

## 5. Memory map and peripherals

### 5.1 Address map

| Range | Content |
|---|---|
| 0x00000-0x04D3B | Application RAM (`.data`, `.bss`, stacks). The SDK links RAM up to 0x4D3C |
| 0x04D3C-0x04D5F | `.boot_info`, 0x24 bytes written by the uboot |
| 0x04620-0x04E1F | Mask ROM stack (zeroed at reset; free once the app runs) |
| 0x04E20-0x04E93 | Mask ROM data (key decoder state at 0x4E20) |
| 0x04EB0-0x04EF7 | `_MASK_MEM`: ROM state shared with apps. `boot_arg_list` at 0x4ED0, eFuse copies at 0x4EDC/0x4EE0, boot-state magic at 0x4EE4, putchar hook 0x4EEC, exception hook 0x4EF0, IRQ-disable nesting counter 0x4EF4 |
| 0x04F00-0x04F7F | Interrupt vector table |
| 0x04F80-0x04FFF | Unknown |
| 0x80000-0x801FF | Core SFRs: debug registers, breakpoints, PMU, EMU, tick timer, interrupt controller |
| 0x80200-0x8023F | SFC (SPI flash controller) |
| 0x80400 / 0x80440 / 0x80480 | HCORE / cache / debug (PC and data access limits; unused) |
| 0x80500 / 0x80600 | SRC (sample rate converter) / HMNG |
| 0x90000-0x93FFF | Low-speed peripherals (§5.3) |
| 0xC0000-0xC3FFF | Mask ROM (16 KiB). Reset vector at 0xC0000 |
| 0xC8000-0xC87FF | Cache tag RAM (the ROM's `sfc_drop_cache` clears 128 × 16 bytes here) |
| 0x100000-... | Flash, through the cache, up to 32 MiB of window. Flash offset 0x1000 appears at 0x100000, decrypted |

The RAM layout the app sees after boot is in `fw-AD15N/sdk/app/post_build/sh55/mcu/app_ld.c`
and `maskrom_stubs.ld`. osdyne links its RAM as 0x0-0x4C00 and keeps word 0
unused.

### 5.2 Core SFRs (0x80000)

| Address | Register | Bits |
|---|---|---|
| 0x80000-0x8003C | DR00-DR15 | Read-only debug |
| 0x80040-0x8007C | SR00-SR15 | Read-only debug |
| 0x80080-0x80094 | BPCON, BSP, BP0-BP3 | Breakpoints |
| 0x800C0 | PMU_CON | |
| 0x800D0 / 0x800D4 | EMU_CON / EMU_MSG | §4.4 |
| 0x800E0 | TTMR_CON | bit 0 EN, bit 6 CPND (write 1 to clear), bit 7 PND |
| 0x800E4 | TTMR_CNT | Counts up at hsb clock (48 MHz after boot) [HW] |
| 0x800E8 | TTMR_PRD | On `CNT == PRD`: CNT = 0, PND = 1, IRQ 3 if enabled. PND sets even with the source disabled [HW] |
| 0x800F8 / 0x800FC | BANK_CON / BANK_NUM | Code banking (unused) |
| 0x80100-0x8013C | ICFG00-15 | §4.2 |
| 0x80180-0x8019C | IPND0-7 | §4.2 |
| 0x801A0 / 0x801A4 | ILAT_SET / ILAT_CLR | §4.2 |

The SDK runs a 2 ms tick from TTMR.

### 5.3 Low-speed peripherals (0x90000)

Every register takes one 32-bit word, although many use only 16 bits.

**GPIO.** Ports at PA 0x90000, PB 0x90024, PD 0x90048 (no port C). Nine
words each [HW]:

| Offset | Register | Notes |
|---|---|---|
| +0x00 | OUT | |
| +0x04 | IN | Reads 0 for pins whose DIE bit is clear |
| +0x08 | DIR | **1 = input** (reset state), 0 = output |
| +0x0C | DIE | Digital input enable |
| +0x10 | PU | Pull-up |
| +0x14 | PD | Pull-down |
| +0x18 / +0x1C | HD0 / HD1 | Drive strength |
| +0x20 | DIEH | |

PB9 is bit 9 of 0x90024 (OUT) and 0x9002C (DIR). PD0-PD4 are the in-package
flash: PD0 CLK, PD1 DO, PD2 CS, PD3 DI, PD4 flash power (the ROM's
`flash_poweron` drives it high).

**Pin mux, IOMC** (0x90080 to 0x9008C) [DOC]:

| Register | Fields |
|---|---|
| IOMC0 0x90080 | [4] UART0 enable, [6:5] UART0 pins; [7] UART1 enable, [9:8] UART1 pins: 00 PA4/PA5, **01 PB9 half duplex**, 10 PA4/ICH0, 11 PB9/ICH0; SPI, timer clock inputs, PWM |
| IOMC1 0x90084 | [3] IR filter output to TMR1/TMR2, [5:4] capture select, [6] capture edge, IIC/SD pins |
| IOMC2 0x90088 | Input channels, 6 bits each: [5:0] ICH0 (UART1 RX, TMR2 capture), [11:6] ICH1 (IR filter, wakeup), [17:12] ICH2 (UART0 RX), [23:18] ICH3. Value = pin index: PA0-15 = 0-15, PB0-11 = 16-27, PD0-4 = 28-32. Readable, despite the manual [HW] |
| IOMC3 0x9008C | Output channels, 3 bits each: CH0/CH1 drive PA pads, CH2/CH3 PB pads; 100 = UART0 TX or SPI0 DO, 101 = UART1 TX |

**Timers TMR0/1/2** at 0x90400, 0x9040C, 0x90420 (CON, CNT, PRD; TMR2 adds
PWM registers):

| CON bits | Field |
|---|---|
| [1:0] | Mode: 0 off, 1 count to PRD then wrap and set PND, 2 capture rising edge, 3 capture falling edge (CNT free-runs and the captured value lands in PRD) |
| [3:2] | Clock: 0 lsb clock, 1 pin or PLL 24 MHz, 2 RCH (TMR0) / OSC (TMR1), 3 HTC |
| [5:4] | Prescaler: /1, /4, /16, /64 |
| [6] | PCLR (write 1 to clear pending) |
| [7] | PND |

In capture mode the counter wraps at 0xFFFF **without** setting PND [HW].

**UART0** at 0x90800 (CON, BUF +4, BAUD +8; no DMA). **UART1** is the one on
PB9 [DOC][DIS]:

| Address | Register | Notes |
|---|---|---|
| 0x90810 | HRXCNT | Bytes received (written by hardware) |
| 0x90814 | OTCNT | Idle timeout, in uart/lsb clocks |
| 0x90818 | TXADR | TX DMA address |
| 0x9081C | TXCNT | Writing it starts TX DMA |
| 0x90820 | RXEADR | RX DMA ring end |
| 0x90824 | CON0 | [0] TX enable, [1] RX enable, [2] TX IE, [3] RX IE, [4] DIVS, [5] timeout IE, [6] RX DMA, [7] latch count into HRXCNT, [10] clear timeout pending, [11] timeout pending, [12] clear RX pending, [13] clear TX pending, [14] RX pending, [15] TX pending |
| 0x90828 | CON1 | |
| 0x9082C | CON2 | 9-bit mode |
| 0x90830 | BUF | |
| 0x90834 | BAUD | `baud = uart_clk / ((BAUD + 1) * 4)` (×3 with DIVS) |
| 0x90838 | RXSADR | RX DMA ring start |
| 0x9083C | RXCNT | |

uart_clk is 48 MHz after boot; the ROM writes BAUD = 0x4E1 for 9600.

**Clocks** at 0x91800 [DOC][HW]:

| Register | Fields |
|---|---|
| CLK_CON0 0x91800 | [0] RC enable, [1] RC select, [4:3] source (0 osc, 1 RCH, 2 LRC, 3 pll_sys), [5] sys select, [9:8] UART clock |
| CLK_CON1 0x91804 | [4:2] lsb divider, [7:5] hsb divider, [17:16] DIVA, [19:18] DIVB, [21:20] pll_sys tap |
| PLL_CON0/1 0x91820/24 | PLL configuration (write-only) |

After the uboot: PLL 480 MHz, pll_sys 96 MHz, **hsb = CPU = lsb = TTMR =
UART = 48 MHz**, flash SPI 48 MHz. CLK_CON1 reads 0x00100020 [HW].

**Watchdog and the P33 domain.** The watchdog sits in the always-on "P33"
domain, reached through a byte-serial link [DIS][HW]:

| Address | Register |
|---|---|
| 0x93C88 | P33 SPI_CON: [0] CS, [1] BUSY, [4] START, [8] HIGH |
| 0x93C8C | P33 SPI_DAT |

- Write: transfer `((addr >> 8) & 3) | (op << 5)` (op 0 write, 1 OR, 2 AND,
  3 XOR), then `addr & 0xFF`, then the data byte.
- Read: transfer `((addr >> 8) | 0x80) & 0x83`, then `addr`, then a dummy;
  the result is in SPI_DAT.
- P33 register 0x80 is WDT_CON: [3:0] timeout (0 = 1 ms ... 0xD = 8 s,
  0xF = 32 s), [4] enable, [5] mode (0 reset, 1 interrupt), [6] clear, [7]
  pending.
- The ROM leaves the watchdog enabled, in reset mode, at 8 s [DIS]. A loader
  that receives no command resets after ~4 s [HW], so something shortens it.
  Apps call the ROM's `wdt_clear` (0xC27B8), or write 0 to P33 0x80 to stop it.
- P33 0xA0 (PR_PWR) bit 4 is the software reset (`chip_reset`).

**Other blocks** (base addresses from `sfr.h`):

| Block | Address | Notes |
|---|---|---|
| SPI0 / SPI1 | 0x90C00 / 0x90C20 | SPI0 talks to the in-package flash during boot |
| Audio DAC | 0x91400 | CON0: [0] EN, [3:1] rate (0 8 kHz, 1 16 kHz, 2 32 kHz, 5 12 kHz, 6 24 kHz), [13] IE, [14] CPND, [15] PND, [12] which half plays. ADR (+0xC, 16-bit, so the buffer must be below 0x10000) and LEN (+0x10, samples per half): a double buffer of s16 mono. IRQ 9 [HW] |
| ANA (LDO, LVD) | 0x91C00 | |
| MCPWM | 0x92000 | |
| USB / ALINK / TDM | 0x92400 / 0x92800 / 0x92C00 | |
| CRC | 0x93C00 FIFO, 0x93C04 REG | Hardware CRC-16/XMODEM: write REG with the seed, push bytes into FIFO, read REG |
| IR filter | 0x93C24 | Feeds TMR1/TMR2 capture |
| CHIP_ID | 0x93C38 | |
| PERIENC | 0x93C58 CON, 0x93C5C KEY, 0x93C60 ADR | Streaming decryptor used by the ROM on flash reads |

### 5.4 SFC: flash execute-in-place

| Address | Register |
|---|---|
| 0x80200 | SFC_CON: [0] EN, [31] BUSY |
| 0x80204 | SFC_BAUD |
| 0x8020C | BASE_ADR |
| 0x80214 / 0x80218 | ENC_CON / ENC_KEY |
| 0x8021C / 0x80220 | UNENC_ADRH / UNENC_ADRL |
| 0x80440 | Cache CON: [5] idle |

The uboot points the SFC window at flash offset 0x1000 and sets the chip's
flash key. Reads from 0x100000 + n then return flash byte 0x1000 + n,
decrypted in 32-byte blocks with ENC keyed `flash_key ^ (n >> 2)` taken per
block (§8.4). Nothing may run from flash while SFC_CON.EN is clear, so code
that reprograms the clocks or the SFC must run from RAM. Delay loops in flash
show cache-miss jitter (90 to 275 µs bit widths in one probe); loops in RAM
are clean [HW].

### 5.5 Mask ROM exports

The ROM's entry points are a table of 4-byte `j` stubs at 0xC2718 to 0xC27FC
(`fw-AD15N/sdk/app/post_build/sh55/maskrom_stubs.ld`). Apps call them
directly, so an emulator that runs app code must provide the ROM: either the
dump, or high-level replacements trapped at these addresses.

| Address | Function | Address | Function |
|---|---|---|---|
| 0xC2718 | `local_irq_enable` | 0xC27A8 | `sfc_drop_cache` |
| 0xC271C | `local_irq_disable` | 0xC27AC | `chip_crc16` |
| 0xC2720 | `bit_set_swi(n)` (writes `1 << n` to ILAT_SET) | 0xC27B0 | `CrcDecode` |
| 0xC2724 | `bit_clr_swi` | 0xC27B4 | `mask_init(exp_hook, putchar)` |
| 0xC2728-0xC2740 | P33 helpers (`p33_buf`, `_p33_tx_1byte`, `_p33_rx_1byte`, ...) | **0xC27B8** | **`wdt_clear`** |
| 0xC2744-0xC276C | `memcpy`, `memset`, `memcmp`, `str*` | 0xC27C0 | `chip_reset` |
| 0xC2770-0xC2794 | `cbuf_*` ring buffers | 0xC27C4 | `flash_poweron` |
| 0xC2798 | `sys_clock_init` | 0xC27C8 | `exception_irq_handler` |
| 0xC27A0 | `_sfc_suspend` | 0xC27CC-0xC27DC | `print`, `sprintf`, `xprintf`, `puts` |
| 0xC27A4 | `sfc_resume` | 0xC27E0-0xC27EC | LZ4 |
| | | 0xC27F0 / 0xC27F4 | `_mdelay` / `_udelay` |
| | | 0xC27F8 / 0xC27FC | UART helpers |

---

## 6. Boot: mask ROM, key, download protocol, RAM loader

### 6.1 Overview

```
power on
  -> mask ROM (0xC0000)
       -> watches PB9 for ~10 ms for a pulse-train key
          key 0x68AF -> UART download: 21-byte header, then a RAM loader at 0x0
                        -> the loader's command link: read/erase/write flash
          no key     -> flash boot: header at flash 0, uboot (LZ4) into RAM
                        -> uboot maps flash 0x1000 at 0x100000, sets the key
                        -> app at 0x100100
```

Everything to do with downloading happens on one pin, **PB9** (package pin 11),
which is a single-wire, half-duplex UART: both sides drive the same wire in
turn and each side hears its own transmissions.

### 6.2 Mask ROM reset path [DIS]

1. 0xC0000: `j 0xC0314`. (0xC0004 is the exception entry.)
2. 0xC0314: `sp = 0x4A20`, `usp = 0x4E20`; clear r0-r14, psr, rets, reti,
   rete, macc. Switch the system clock to RCH (16 MHz). `reti = 0xC0362; rti`.
3. Init (0xC0690): `icfg` bit 9, PMU wait, vector 1 (exception, priority 7),
   copy eFuse to 0x4EDC, trim the LRC, **watchdog on (8 s, reset mode)**,
   flash power on (PD4), PERIENC key 0xFFFF.
4. Clear RAM 0x4620-0x4E94.
5. main (0xC11C0): PLL init (480 MHz), hook pointers, then the **boot-state
   check**: 8 bytes at RAM 0x4EE4 are compared against three magics, which
   survive a warm reset:

   | Magic | State | Effect |
   |---|---|---|
   | `"uart\xd9\xc4\x05\xa8"` | 3 | UART download without a key (one-shot: byte 0 is inverted afterwards) |
   | `"OTA\x85f:{c"` | 6 | Flash boot (bytes cleared) |
   | `"fastboot"` | 8 | Flash boot (bytes cleared) |

   Any non-zero state skips the key window.
6. Key window (§6.3). It yields a code: 0x68AF -> state 4, 0x16EF -> 1,
   0x6B7C -> 5.
7. Dispatch: states 1, 2, 7 -> `chip_reset`; states 3, 4 -> UART download
   (§6.4); everything else -> flash boot (§6.6).

### 6.3 The UART key

The ROM sets PB9 as an input with a **pull-down**, routes it (IOMC2 ICH0 =
25) to the TMR1 capture input, and decodes pulse widths in the TMR1 interrupt
while TMR0 times a window [DIS]:

- The line idles **low**; pulses are **high**.
- Each bit is one period: rising edge, high, falling edge, low, next rising
  edge. The ROM evaluates a bit at the rising edge that ends its period, so
  the sender must make a final rising edge after the last bit.
- A **start pulse** comes first. It only sets the reference period.
- A period must be 2000 to 32000 TMR1 ticks long, and within ±10 % of the
  reference.
- **Bit 1:** high/low between 3 and 5 (e.g. 80 % high). **Bit 0:** low/high
  between 3 and 5 (20 % high). Anything else resets the decoder.
- Bits shift MSB first into a 16-bit register. The download key is
  **0x68AF** (also known: 0x16EF, 0x6B7C).
- Each decoded bit restarts a ~10 ms TMR0 window, up to 100 times. If the
  window expires with no key, the ROM boots the flash.
- **Acknowledgement:** on 0x68AF the ROM drives PB9 high (push-pull) for
  about 2 ms, then switches it to a UART input with a pull-up, so the line
  stays high [DIS]. The dongle sees ~8 ms of high [HW].

The working timing [HW]:

| Parameter | Value |
|---|---|
| Key start after VDDIO rises | 17 ms. Working window 13 to 23 ms on this toy |
| Line before the train | Held low for one period |
| Period | 1000 µs |
| '1' / '0' | 800 µs / 200 µs high |
| Start pulse | 500 µs high, 500 µs low |
| After the last bit | Drive high for 100 µs, then release |
| Trains | One is enough |

If the key is missed, the app boots. On the toy the app starts using PB9 about
38 ms after power-on.

### 6.4 The ROM's UART download [DIS][HW]

The ROM switches PB9 to UART1 (half duplex), with pull-up, at **9600 8N1**
(BAUD 0x4E1 at 48 MHz), and DMAs exactly 21 bytes into 0x4E3C. There is no
ROM timeout here, so only the watchdog bounds the wait.

**Header**, little-endian fields:

| Offset | Size | Content |
|---|---|---|
| 0 | 1 | `00` |
| 1 | 4 | `55 AA 10 20` |
| 5 | 4 | Load and entry address (0 for SH55: RAM) |
| 9 | 4 | Image length (0x2220 for the SDK loader) |
| 13 | 2 | CRC16 of the image |
| 15 | 2 | CRC16 of bytes 5-14. The ROM requires it to be non-zero and to match |
| 17 | 1 | Options. Bit 1: the image is CrcDecode-scrambled (the SDK sends 0x0A) |
| 18 | 1 | Image and loader rate in units of 10 kbaud; 0 means 100 kbaud. The SDK uses 100 (1 Mbaud); the dongle uses 10 |
| 19 | 1 | 0 |
| 20 | 1 | CRC8 of bytes 1-19. The ROM does not appear to check it, but the vendor sends it |

Then:

1. ~5 ms later the ROM answers `55 AA 01 20 22` at 9600 (measured 5.7 ms).
2. It switches to `rate × 10 kbaud`: `BAUD = 48e6 / (rate × 10000) / 4 - 1`.
3. It DMAs exactly `length` bytes to `address`. The dongle waits 150 ms
   after the acknowledgement before sending.
4. It checks the image CRC16 (a mismatch resets the chip).
5. If option bit 1 is set, it descrambles the whole image in place with
   CrcDecode, key `FF FF FF FF` (§6.7).
6. It calls `address` with `r0` = 0x4E3C, the header.

The SDK loader answers `55 AA 01 20 22` at the new rate about 50 ms later.

**The loader image.** `fw-AD15N/sdk/app/post_build/sh55/mcu/sh55loader.uart` is a
JLFV container (§8.3) holding a version string ("LOADER-v1.0.0-...-20230630")
and a code entry of 0x2200 bytes, stored CrcDecode-scrambled. The image sent
is that code entry followed by a 32-byte **key blob** encoding chip key
0xFFFF (§8.5): 8736 = 0x2220 bytes. Descrambling the code entry with
CrcDecode, key `FF FF FF FF`, gives the loader's plain code, which runs at
address 0.

### 6.5 The RAM loader's command link

The loader keeps the ROM's rate. It receives into a 0x1020-byte DMA ring and
takes a frame when the UART's **idle timeout** (OTCNT 0x5DC0) fires after at
least 18 bytes [DIS]. Run the link at **100 kbaud or more**: at 20 kbaud the
loader never answered [HW]. 100 kbaud is the tested rate; 1 Mbaud (the
vendor's) is untested with the dongle. The loader resets into the app if no
command arrives for ~4 s.

**Command**, host to loader: 16 bytes, multi-byte arguments **big-endian**:

| Offset | Content |
|---|---|
| 0 | `FC` (device type) |
| 1 | Opcode |
| 2-14 | Arguments |
| 15 | Nonce (any byte) |

On the wire: `crc16_be(cmd) ++ cmd`, 18 bytes. One stray leading byte is
tolerated.

**Reply**, loader to host: 20 bytes, `xx xx ++ crc16_be(payload) ++ payload[16]`.
The first two bytes are not checked. Payload bytes 0-1 echo `FC` and the
opcode; bytes 2-3 are a big-endian status: 0 OK, 0x16 bad CRC or device byte,
0x26 opcode not implemented.

**Bulk data** after a reply (reads, CRC lists, UUID):
`crc16_be(data) ++ ~crc16_be(data) ++ data`. Opcode 0x1B instead returns just
4 bytes `crc16_be ++ ~crc16_be`.

**Key swap and scrambling.** The first command must be 0x03 with 14 random
argument bytes, unscrambled; the reply is unscrambled. Then both sides derive

```
key = crc16_be(18-byte command frame) ++ crc16_be(20-byte reply frame)
```

and every later frame (command, reply, bulk, the 4 bytes of 0x1B) is XORed
with the CrcDecode keystream of that key, **restarting at the first byte of
each frame**. A receiver resynchronises by dropping one byte at a time until a
CRC matches.

**Opcodes** (table at loader 0x20A0):

| Op | Name | Arguments | Result | Tested |
|---|---|---|---|---|
| 01 | Read flash ID | Optional flash config | Type u16 at payload 4, JEDEC id u32 at 6 | yes |
| 02 | Chip reset | u32 at 2 | | no |
| 03 | Key swap | 14 random bytes | | yes |
| 05 | Chip ID | | u32 at 6 | yes |
| 06 | Flash UUID | | Bulk, 20 bytes | no |
| 07 | Buffer size | | Not implemented (0x26). Use 0x1000 | yes |
| 08 | Write chip key | u32 at 2 | | no |
| 09 | Read chip key | | u16 at 4, 6, 8. The first, CrcDecode-descrambled with key FFFFFFFF, is the **flash key** | yes |
| 0B | Read eFuse | Byte at 11 | | no |
| 11 | Erase chip | | | no |
| 12 / 13 / 14 | Erase block / sector / page | Address at 2 | | 13 yes |
| 15 | Erase unit | | u32 at 4 (0x100) | yes |
| 18 | Write | Address at 2, length at 6, crc16 at 10; data frame follows | | yes |
| 19 | Read | Address at 2, length at 6 | Bulk | yes, 4 KiB chunks |
| 1A | CRC16 list | Address, length | Bulk | no |
| 1B | CRC16 of a range | Address at 2, length at 6 | 4 raw bytes | yes |
| 20 | Done | | | yes |

Session used by `jieli-uart`: key swap, 09, 01, 07, 15. Write: per 4 KiB sector,
compare with 0x1B and skip if equal; else erase (0x13), write (0x18), verify
(0x1B). Send 0x20 at the end. Each command has a 3 s timeout and up to 20
retries; before a retry that carries data, send 10 zero bytes and wait 5 ms.

### 6.6 Flash boot [DIS]

1. Power the flash (PD4), wake it (0xAB), poll status (0x05), soft reset
   (0x66, 0x99), wait 2 ms. CS is PD2; data moves through SPI0 with DMA,
   descrambled on the fly by PERIENC when enabled.
2. Look for a valid flash header (§8.2) at offset 0, then 0x1000, 0x10000,
   0x80000.
3. Read the first top-level entry (`uboot.boot`) and its 16-byte bank
   header. If the entry's attribute has bit 6 set, LZ4-decompress the bank
   using the first 0x2800 bytes of the ROM as a dictionary.
4. Disable interrupts and call the uboot with `r0` = flash descriptor and
   `r2` = 0x4ED0 (`boot_arg_list`).
5. If no header is found, listen on UART1 for 5 ms windows (interrupt mode,
   IRQ 11), power-cycle the flash and retry.

**The uboot** (closed source; SDK `uboot.boot`, ~4.5 KiB decompressed) reads
`isd_config.ini` from flash, programs the clocks (48 MHz), sets up the SFC
window and the flash key, writes `.boot_info` at 0x4D3C, and jumps to
**0x100100** with boot information in `r5` to `r8`.

**State at app entry** [HW]: `icfg = 0x500`, `psr = 0x2`, `ie0 = ie1 = 0`,
watchdog running, hsb and TTMR at 48 MHz, code running in place from flash.

### 6.7 Checksums and ciphers

All are implemented and unit-tested in `os/tools/lib/jieli-image/src/{crc.rs,cipher.rs}`.

| Name | Definition | Check |
|---|---|---|
| CRC16 | CRC-16/XMODEM: poly 0x1021, init 0, no reflection, no final XOR | `"123456789"` -> 0x31C3 |
| CRC8 | Poly 0x8C reflected (i.e. 0x31 reflected, Dallas/Maxim), init 0 | `"123456789"` -> 0xA1 |
| ENC(k) | XOR each byte with the low byte of a 16-bit LFSR: `out = in ^ (s & 0xFF); s = (s << 1) ^ (s & 0x8000 ? 0x1021 : 0)`, s starts at k | |
| CrcDecode(key[4]) | `crc = crc16_update(seed = le16(key[0..2]), key[2..4])`; for byte i: `crc = crc16_update(crc, MAGIC[i % 16]); out = in ^ (crc & 0xFF)` | Key `12 34 56 78` on `"hello world 0123456789abcdef"` gives `add1cb8a...` |

`MAGIC` = `c3 cf c0 e8 ce d2 b0 ae c4 e3 a3 ac d3 f1 c1 d6`.

```rust
fn crc16_update(mut crc: u16, data: &[u8]) -> u16 {
    for &b in data {
        crc ^= (b as u16) << 8;
        for _ in 0..8 {
            crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x1021 } else { crc << 1 };
        }
    }
    crc
}
```

### 6.8 Timeline of a successful download

| Time | Who | What |
|---|---|---|
| 0 | Toy | VDDIO rises. The dongle holds PB9 low through 1 kΩ |
| 1 ms | Dongle | VDDIO seen high and stable |
| 17 ms | Dongle | Key train: 1 ms low, start pulse, 16 bits (~18 ms) |
| 35 ms | Dongle | Drives high 0.1 ms, releases |
| 35-37 ms | ROM | Drives PB9 high (ack), then holds it with its pull-up |
| 43 ms | Dongle | 21-byte header at 9600 (~22 ms) |
| +5.7 ms | ROM | `55 AA 01 20 22` at 9600 |
| +150 ms | Dongle | 8736-byte loader image at 100 kbaud (~0.9 s) |
| +50 ms | Loader | `55 AA 01 20 22` at 100 kbaud |
| < 4 s | Host | Key swap, then commands. A full 512 KiB read takes ~66 s at 100 kbaud |

---

## 7. Wiring an ESP32-C6 as the programmer

### 7.1 Why a microcontroller

The host cannot do this over a USB serial adapter: the key needs
microsecond-accurate pulses at a fixed delay after power-on, the ROM's
acknowledgement and the image push must follow each other within a window,
and the single wire must be shared between a UART receiver, an open-drain
transmitter and a weak pull. The ESP32-C6 does the time-critical part and
bridges the rest to the host over its built-in USB-Serial-JTAG port.

### 7.2 Wiring

All logic is 3.3 V. The toy's VDDIO is ~3.0 V and PB9 is 5 V tolerant, so
no level shifting is needed.

```
               ESP32-C6-DevKitC-1                       AD152A4 (SOP16)

  GPIO4  (UART0 RX) ---------------------------+
  GPIO5  (UART0 TX, open drain) ---------------+
  GPIO2  (line sense input) -------------------+------- pin 11  PB9
  GPIO7  (strong push-pull, optional) ---------+
  GPIO6  (key / header / pull) ----[ 1 kΩ ]----+

  GPIO10 (VDDIO sense) ---------------+-------------- pin 6   VDDIO
                                      |
                                   [ 10 kΩ ]
                                      |
  GND --------------------------------+-------------- pin 8   VSS ---- supply -

  bench supply + (4.0 V) ---------------------------- pin 7   VBAT (battery contacts)
  GPIO11 (power switch, optional, e.g. to a MOSFET) -- unconnected when a bench supply is used
```

| ESP32-C6 pin | To | Via | Role |
|---|---|---|---|
| GND | VSS (pin 8) and the supply minus | wire | Common ground |
| GPIO4 | PB9 | wire | UART RX (UART0, 9600 or 100 kbaud) |
| GPIO5 | PB9 | wire | UART TX, **open drain**: never fights the chip when it drives PB9 |
| GPIO2 | PB9 | wire | Line sense: edge timing, safe FIFO reads (§7.4) |
| GPIO6 | PB9 | **1 kΩ** | Key pulses and ROM header (bit-banged); also holds the line low while the toy is off |
| GPIO7 | PB9 | wire | Strong driver, for diagnostics only. It can fight the chip's driver |
| GPIO10 | VDDIO (pin 6) | wire, **10 kΩ to GND** | Power-on detection. The key delay is timed from this edge |

Why 1 kΩ on the key pin: it overrides the ROM's weak pull-down cleanly, it
limits current if the chip drives the line at the same time, and it lets the
sense pin see what the chip does.

Power: the toy runs from a bench supply (DPS150 at 4.0 V) switched by the
host, or from the toy's batteries switched by hand. With manual power the
dongle waits for VDDIO to fall for 300 ms and then rise.

Check the wiring with the toy **off** (the running app drives PB9): pulling
GPIO6 low must read low on GPIO2, pulling it high must read high, and the UART
loopback (TX to RX through the wire) must work.

### 7.3 Firmware structure

The reference firmware is an osdyne application, built with `osdyne run`.
A firmware for this job has four parts.

**1. Host link.** Frames over USB-Serial-JTAG, both directions:

```
A5 | type u8 | len u16 LE | payload | crc8(type, len, payload)
```

| Type | Payload | Reply | Purpose |
|---|---|---|---|
| `V` | none | `V` + version | Identify |
| `P` | on u8 | `A` | Power pin |
| `K` | Key parameters, header, image, optional first command | `K` + result | The whole ROM stage in one call |
| `B` | baud u32 | `A` | Line rate |
| `S` | driver u8 (0 UART pad, 1 strong, 2 pull pin) | `A` | Write driver |
| `D` | bytes | `A` once sent, echo removed | Write to the line |
| `R` | max u16, timeout ms u16 | `D` + bytes | Read from the line |
| `F` | none | `A` | Reset the receive FIFO |
| `E`, `X`, `M`, `T`, `W` | | | Diagnostics: edge recorder, line levels, self-test |

`K` must do everything up to the loader's acknowledgement on the dongle,
because a host round trip is too slow and too jittery. Its parameters
(31 bytes, little endian): off_ms u16 (0xFFFF = manual power), delay_us u32,
period_us u16, repeat u8, polarity u8, listen_ms u16, header_bit_us u16,
flags u8, loader_baud u32, ask_baud u32, header_len u8, init_baud u32,
pre_push_ms u16, then the header and the image. Its result: status, whether
the ROM acknowledged, timings, and a stage byte (0 no ROM ack, 1 ROM ack only,
2 loader replied).

**2. The ROM stage**, run with interrupts masked where timing matters:

```rust
fn rom_stage(k: &Key, header: &[u8], image: &[u8]) -> Stage {
    pull_pin.drive(Low);                       // hold PB9 low while the toy is off
    power_cycle_or_wait_for_vddio(k.off_ms);   // VDDIO low 300 ms, then high 1 ms
    spin_us(k.delay_us - 1000);                // 17 ms after the rising edge

    // Key: idle low, start pulse, 16 bits MSB first.
    spin_us(k.period);                         // one period low
    pulse(k.period / 2, k.period / 2);         // start pulse
    for bit in (0..16).rev().map(|i| 0x68AF >> i & 1) {
        let high = if bit == 1 { k.period * 4 / 5 } else { k.period / 5 };
        pulse(high, k.period - high);
    }
    pull_pin.drive(High); spin_us(100);        // final rising edge
    pull_pin.release();
    let acked = sample_sense_for(8_000);       // ROM drives PB9 high

    bitbang_uart(header, 104);                 // 9600 8N1 on the pull pin
    if !uart_wait_for(&[0x55, 0xAA, 0x01, 0x20, 0x22], k.listen_ms) {
        return Stage::NoRomAck;
    }
    sleep_ms(150);
    uart.set_baud(k.loader_baud);              // 100 kbaud
    uart_write_open_drain(image);              // 8736 bytes
    if uart_wait_for(&ROM_ACK, 600) { Stage::Loader } else { Stage::RomAckOnly }
}
```

The header is bit-banged on the pull pin, not sent with the UART: frames from
the UART pad were not decodable on this wiring at 9600 [HW]. Images and
commands go through the UART pad (open drain), because the chip's pin is an
input while it receives.

**3. The line bridge** (`B`, `D`, `R`, `F`) for the loader link. The host
does the loader protocol (§6.5) and the dongle just moves bytes, removing the
echo of its own transmissions (single wire: TX is heard on RX).

**4. Power control**: the supply over its own USB link from the host, or
GPIO11 to a switch.

### 7.4 ESP32-C6 UART pitfalls

These cost a full day on the bench:

- **Never read the RX FIFO while a byte may be arriving.** A read that
  collides with the receiver leaves the FIFO count one short: the last byte
  of a burst never shows up, and old bytes resurface in a later exchange.
  The fix: watch PB9 on GPIO2 and read the FIFO half a bit after a falling
  edge (at most 2 bytes), or after 20 quiet bit times. Every frame starts
  with a falling edge, so this keeps up with continuous 4 KiB bursts at
  100 kbaud.
- **To discard data, reset the FIFO in hardware** (UART0 CONF0.RXFIFO_RST,
  bit 22 of 0x60000020, latched through REG_UPDATE at 0x60000098). Do not
  read it empty.
- Wait two bit times after a transmit before draining, so the echo of the
  last byte has landed.
- The FIFO is 128 bytes. At 1 Mbaud the "read after an edge" rule cannot
  keep up with 4 KiB bulk frames; that rate would need UART DMA (UHCI).

### 7.5 The host side

`os/tools/jieli-uart` (run as `osdyneup run .jieli-uart ...`) is the current
host tool:

| Command | What it does |
|---|---|
| `info` | ROM stage, key swap, flash ID and size, chip key and flash key |
| `read FILE [--addr --len]` | Read the flash |
| `write APP` | Pack APP (linked at 0x100100) for this chip's key and write it |
| `write --raw --addr A FILE` | Write a ready image |
| `pack APP --key K -o IMG` | Pack offline |
| `check IMG` | Verify every CRC in an image |
| `console`, `run APP` | Restart the toy and copy its PB9 console (9600 baud) to stdout |
| `power on\|off\|cycle` | Switch the DPS150 |

It finds the dongle and the supply by USB identity under `/dev/serial/by-id/`
(Espressif and Artery AT32), powers the toy off for 8 s before each attempt,
and retries the key up to 3 times. For `run`, it boots the toy without the key
(the dongle releases PB9) and listens at 9600 baud.

---

## 8. The flash image and how to build one

### 8.1 Flash layout (512 KiB toy)

| Flash offset | Content | Stored as |
|---|---|---|
| 0x00000 | Flash header, 32 bytes | ENC(0xFFFF), see §8.2 |
| 0x00020 | Top-level entries: `uboot.boot`, `isd_config.ini`, `app_dir_head` | Each 32-byte entry ENC(0xFFFF) |
| 0x00080 | `uboot.boot` bank (bank header and data scrambled separately) | ENC(0xFFFF) |
| after it | `isd_config.ini` blob | Clear |
| 0x01000 | Application area, appears at CPU address 0x100000 | Area cipher (§8.4) |
| 0x01100 | `app.bin`, appears at **0x100100** | Area cipher |
| after the app | Resource images (sounds), chained | Area cipher |
| VM start (rounded to 256) | `VM`: app's saved settings, to 8 KiB before the end | Raw, written by the app |
| 0x7E000 | `EEPROM`, 8 KiB | Raw, written by the app |

An image ends where `VM` starts, so writing it keeps the toy's saved settings.

### 8.2 Flash header and entries

**Flash header** (32 bytes, little endian):

| Offset | Size | Field | Toy |
|---|---|---|---|
| 0 | 2 | CRC16 of bytes 2-31 | |
| 2 | 2 | Burner size | 0x220 |
| 4 | 4 | VID | unset |
| 8 | 4 | Flash size | 0x80000 |
| 12 | 1 | FS version | 0x0D |
| 13 | 1 | Block align | 0x01 |
| 14 | 1 | Reserved | 0xA4 |
| 15 | 1 | Special option | 0x00 |
| 16 | 16 | PID | "SH54" |

It is built so that VID and PID read in the clear after descrambling while
the CRC still holds: fill 32 bytes with 0xFF, put "SH54" at 16, apply
ENC(0xFFFF), set bytes 2-15, compute the CRC into bytes 0-1, apply
ENC(0xFFFF) again.

**Entry** (32 bytes, the same layout everywhere: top-level list, application
area, resources):

| Offset | Size | Field |
|---|---|---|
| 0 | 2 | CRC16 of bytes 2-31 |
| 2 | 2 | CRC16 of the data (0xFFFF when not fixed) |
| 4 | 4 | Offset (absolute, or relative to the list) |
| 8 | 4 | Size |
| 12 | 1 | Attributes (bit 6: compressed) |
| 13 | 1 | Reserved |
| 14 | 2 | 1 on the last entry of a list |
| 16 | 16 | Name, NUL-terminated, 0xFF-filled |

Top-level list at 0x20:

| Name | Attr | Res | Offset | Size | Data CRC |
|---|---|---|---|---|---|
| `uboot.boot` | 0x40 | 0x00 | 0x80 | bank length | CRC16 of the plaintext bank |
| `isd_config.ini` | 0x02 | 0xFF | after the bank | blob length | CRC16 of the blob |
| `app_dir_head` (last) | 0x81 | 0xFF | 0x1000 | 0xFFFFFFFF | 0xFFFF |

### 8.3 Containers, the uboot bank, the config blob

**JLFV container** (SDK `.uart` and `.boot` files): a 32-byte header and
32-byte entries, each ENC(0xFFFF):

- Header: magic "JLFV" (or 0) at 0, CRC16 of header bytes 6-31 at 4, CRC16 of
  the entry table at 6, file size at 8, entry count at 12.
- Entry: kind at 0 (0 version, 1 code, 4 load address), data CRC16 at 2,
  offset at 4, length at 8, name at 16.

**The uboot bank** is the code entry of the SDK's
`fw-AD15N/sdk/app/post_build/sh55/mcu/uboot.boot`, stored in the clear in the
container:

- Bank header, 16 bytes: count u16 at 0 (1), data size u16 at 2, load address
  u32 at 4 (0), data offset u32 at 8 (16), CRC16 of the data at 12, CRC16 of
  header bytes 0-13 at 14.
- Bank data: already LZ4-compressed. A 16-byte LZ4 header (dictionary size
  0x2800, dictionary pointer 0xC0000, temporary buffer 0x2000, decompressed
  size, each u32) and an LZ4 block stream.

In flash the bank header and the bank data are each ENC(0xFFFF)-scrambled on
their own. The toy's uboot (4444 bytes decompressed) and the SDK's (4632)
both boot it.

**The `isd_config.ini` blob**: `0x20`, a 32-byte key blob (§8.5; for key
0xFFFF any valid blob works), `0xFF`, then records of `[size u8][key\0][value]`:
`SPI` = "2_3_0" (size 5) and `BOOT_TYPE` = u32 0 (size 4). The uboot also
looks for UTRX, VLVD, WAIT_TIME, FLASH_INSIDE, CACHE_WAY and UARTUPDATE.

### 8.4 The application area and the area cipher

Everything from flash 0x1000 on that belongs to an image is encrypted in
32-byte blocks. The block at flash address `A` (a multiple of 32) uses ENC
with key

```
flash_key ^ ((A - 0x1000) >> 2)        // truncated to 16 bits
```

restarting the keystream at each block. That is the SFC's `key ^ (addr >> 2)`
with the area mapped at 0x100000. Padding inside an image is encrypted 0xFF;
gaps between images are raw 0xFF.

**The flash key is per chip.** Get it from the loader (opcode 0x09, first
word, then CrcDecode with key FFFFFFFF): this toy says 0xBABE, which is flash
key **0x1E91**. `jieli-uart info` prints it. A key can also be recovered from
an existing image by trying all 65536 keys until the entry at 0x1000 decodes
as `app_area_head`.

Application area contents (offsets from 0x1000, before encryption):

| Offset | Content |
|---|---|
| 0x000 | `app_area_head`: attr 0x83, res 0x02, offset = entry point **0x100100**, size = 0x100 + app size, data CRC over area bytes 0x20 to the end |
| 0x020 | `app.bin`: attr 0x82, res 0xFF, offset 0x100, size, CRC16 of the (padded) app |
| 0x040 | `VM`: attr 0x12, res 0x81, absolute offset and size, CRC 0xFFFF, name field `"VM\0" ff×9 00 04 00 00` |
| 0x060 | `EEPROM` (last): attr 0x92, res 0x81, offset 0x7E000, size 0x2000, name field `"EEPROM\0" ff×5 "AUTO"` |
| 0x080 | 0xFF up to 0x100 |
| 0x100 | `app.bin`, padded with 0xFF to a multiple of 4 KiB |

**Resources** (optional) follow the area as a chain of images: a 32-byte
entry (attr 0x82, res 0xFF, offset 0x20, size = 0x20 + payload, CRC16 of the
payload, last flag on the final one) and the payload, a plaintext JLFS
directory image as the SDK's `voice_toy/dir_*` files. The next image starts
`size` bytes later. Apps open them by name (`/dir_song`).

### 8.5 The key blob

32 bytes that encode a 16-bit key: the first 16 bytes are random; their byte
sum is the threshold `v` (sum <= 0x10 becomes 0x55, sum >= 0xE0 becomes 0xAA).
Bit `i` of the key is 1 when `blob[16 + i] ^ blob[15 - i] < v`. Used after
the loader image (key 0xFFFF) and at the start of `isd_config.ini`.

### 8.6 Making a program

The app is a flat binary linked to run at **0x100100** from flash, with its
data and stack in RAM below 0x4C00 (0x4D3C at most). The uboot jumps to its
first byte.

**Minimum startup:**

```asm
        .section .text.start
        .globl  _start
_start:                         // at 0x100100
        movh    sp, %hi(_stack_top)
        movl    sp, %lo(_stack_top)
        call    _cstart         // copy .data from flash, clear .bss, call main
1:      j       1b
```

The program must keep the watchdog alive (call the ROM's `wdt_clear` at
0xC27B8 every few seconds) or turn it off (P33 register 0x80 = 0). To blink
PB9, clear bit 9 of PB DIR (0x9002C) and toggle bit 9 of PB OUT (0x90024).

Two ways to build one:

1. **osdyne (Rust)** for the board `drivers/board/jieli/ad15n-shark`:
   `osdyne run -t <target>` builds, packs, flashes through the dongle and
   shows the console. Examples: `examples/blinky`.
2. **The vendor SDK** builds `app/post_build/sh55/app.bin` with JieLi's own
   toolchain, linked by `app.ld` at the same address.

### 8.7 Packing and flashing

With the dongle attached, `write` reads the chip's key and packs for you:

```
osdyneup run .jieli-uart write app.bin               # pack for this chip, erase, write, verify
osdyneup run .jieli-uart run app.bin                 # same, then show the console
```

Offline:

```
osdyneup run .jieli-uart info | grep 'flash key'     # e.g. 0x1e91
osdyneup run .jieli-uart pack app.bin --key 0x1e91 -o image.bin
osdyneup run .jieli-uart check image.bin
osdyneup run .jieli-uart write --raw --addr 0 image.bin
```

`pack` takes `uboot.boot` from the SDK (`--sdk`, or `JIELI_SDK`, or found
next to the tool). The algorithm, in `os/tools/lib/jieli-image/src/flash_image.rs`:

```
app      = pad(app.bin, 0xFF, multiple of 0x1000)
area_len = 0x100 + len(app)
vm       = round_up(0x1000 + area_len, 0x100)
eeprom   = flash_size - 0x2000                      ; fail if vm > eeprom
body     = entry(app.bin) ++ entry(VM) ++ entry(EEPROM, last) ++ 0xFF.. to 0xE0 ++ app
area     = entry(app_area_head, crc = crc16(body)) ++ body
image    = 0xFF × vm
image[0x000..0x020] = flash header (§8.2)
image[0x020..0x080] = ENC(0xFFFF) of each of the three top-level entries
image[0x080..]      = ENC(0xFFFF)(bank header) ++ ENC(0xFFFF)(bank data) ++ config blob
image[0x1000..]     = area, encrypted with the area cipher for the chip's key
```

To restore the toy's original firmware:
`osdyneup run .jieli-uart write --raw --addr 0 backups/toy-flash-2026-09-28.bin`.

**Warnings**

- Writing a raw `app.bin` at flash 0 bricks the boot (restore with the
  backup). The ROM download path is in mask ROM, so the chip can always be
  recovered through the key.
- An image packed for the wrong flash key boots the uboot but runs garbage
  at 0x100100.

---

## 9. Notes for an emulator

### 9.1 Two ways to start

1. **Full chain.** Map the ROM dump at 0xC0000, model the SPI flash behind
   SPI0/PD2 and the SFC window, and start at 0xC0000 in interrupt mode (§4.3).
   This exercises the key window (keep PB9 low or skip it), the LZ4 uboot,
   and the real flash image with its ciphers. It needs the PERIENC and SFC
   decryption models, the P33 link, the clock registers and TMR0/TMR1.
2. **Shortcut.** Load the plaintext `app.bin` at 0x100100 as read-only memory
   (or decrypt an image's area: 32-byte blocks, ENC keyed
   `flash_key ^ (offset >> 2)`), map the ROM dump at 0xC0000 for the exported
   functions, and set the state the uboot leaves: `pc = 0x100100`,
   `icfg = 0x500`, `psr = 0x2`, thread mode on `usp`, all clocks 48 MHz,
   TTMR off, watchdog running at 8 s, the vector table at 0x4F00 holding
   0xC0004 in entry 1. Zero `.boot_info` at 0x4D3C (or copy it from a real
   boot) and `r5`-`r8`.

The RAM loader (the descrambled code entry of `sh55loader.uart`) is a good second target: load it at
0 with `r0` pointing to a 21-byte header with byte 18 = 10, emulate UART1, and
drive it with the host side of §6.5.

### 9.2 Core loop

```c
for (;;) {
    if (irq_pending_and_allowed()) take_interrupt();   // §4.3
    if (idle && !irq_pending()) { advance_time_to_next_event(); continue; }

    uint32_t pc0 = pc;
    decode(pc0, &insn, &len);                          // §3.1
    uint32_t next = pc0 + len;

    if (skip_count) { skip_count--; pc = next; continue; }   // if<cc> skipped block
    execute(&insn, next);                              // may change pc
    if (pc == pc0 /* not a taken branch */) pc = next;

    if (rep_active && pc == rep_end) {                 // hardware loop
        if (--rep_count) pc = rep_start;
        else rep_active = false;
    }
}
```

- **`if<cc> T, f`**: if cc holds, run T instructions, then skip f; otherwise
  skip T, then run f. Count *instructions* (decode each one's length to skip).
- **`rep s, n`**: `rep_start = next`, `rep_end = next + 2 * s`; run the body
  `n` times (a register count of 0 runs 32 times).
- **`push`**: compute the total size, move `sp` down by it, then store the
  registers in ascending order from the new `sp`, with `rets` last (at the
  highest address). `pop` loads ascending from `sp`, `pc` last. `rets` at the
  top is proven [HW, via a varargs bug]; ascending order within the GPRs is
  the convention but has not been measured [SPEC]. No code seen depends on it.
- **PC-relative loads**: base `(next & ~3)`.
- **Flags**: every data-processing instruction writes NZCV (§2.2); moves,
  loads and stores never do.
- **Stack bank**: keep `ssp` and `usp` separate and alias `sp` to one of them
  by mode. `mov rX, usp` and `mov rX, ssp` read the banked values directly.

### 9.3 Timing

No per-instruction cycle counts have been measured. Known points:

- A C `volatile` busy loop running from flash took ~188 ns per iteration at
  48 MHz; a bit-bang loop in RAM took 147 ns per iteration [HW]. One
  instruction per cycle from RAM is a fair first model; flash fetches cost
  more on cache misses.
- TTMR, timers, the UART baud rate and the DAC sample rate are what software
  actually times against. Model those against a 48 MHz clock (or
  47.9 MHz to match this toy's LRC-derived PLL) and the CPU speed matters
  little.
- The tick timer keeps counting during `idle`.

### 9.4 Devices worth modelling first

| Device | Why |
|---|---|
| Interrupt controller (ICFG, IPND, ILAT) and the vector table | Everything uses it |
| TTMR | The SDK tick and osdyne's console bit timing |
| GPIO PB9 | The osdyne console bit-bangs 9600 baud on it (start, 8 data bits LSB first, 2 stop bits). Decode edges against TTMR time |
| P33 link (WDT) | Apps clear or disable the watchdog through it |
| TMR0-2, UART1 with DMA | The ROM, the loader, the IR receiver |
| DAC | The toy's sounds (s16 double buffer, IRQ 9) |
| SFC, cache, CLK, PLL | Can be stubbed: accept writes, return "idle" and "not busy" |

A useful test program with a known result on hardware: a PB9 blink with
two busy-wait delays showed edges 18.78 ms and 37.56 ms apart. The toy's own
app (in the backup, linked at 0x100100, flash key 0x1E91) drives four motor
pins and the DAC.

---

## 10. Open questions

| Question | Status |
|---|---|
| GPR order inside a `push`/`pop` block | Assumed ascending; `rets`/`pc` at the top is proven |
| V flag position; C and V after shifts and logic | V assumed bit 0 |
| `rep` with a register count above 32; interrupts inside a `rep` body | Unknown |
| Divide by zero result | Unknown; the SDK traps it |
| `rete` contents | Always 0 after a CPU exception so far |
| `ILAT_SET` bits 1-7 | Bit 0 raises source 28; others unclear |
| `icfg` bits 10, 11 and the upper half (running priority) | Unknown |
| Encodings `0xEE3...` and iword `0x0007` | Unknown |
| `addldd`/`lddadd`/`addstd`/`stdadd`, register-form bit fields, `movz64`/`movs64`, `tbb`/`tbh`, `testset`, `qasl` saturation | Encodings known, semantics inferred |
| Doubleword register order | Assumed `rX` low |
| The SFC register values the uboot writes | The uboot is closed; read them from a running chip |
| `.boot_info` layout at 0x4D3C and the `r5`-`r8` arguments | Not decoded |
| 1 Mbaud loader link with the ESP32-C6 | Untested; needs UART DMA on the dongle |
| Why a loader with no commands resets after ~4 s when the ROM sets an 8 s watchdog | Not traced |
| What the ROM does with keys 0x16EF and 0x6B7C | Decoded, not exercised |
| Per-instruction cycle counts | Not measured |
