docs(series4): firmware static analysis, and all five bench events decoded

Solo session while the bench was unattended. Read-only throughout.

Architecture: ColdFire/68K, big-endian, Freescale MQX RTOS -- not ARM as the
vector table first suggested. The tell is 4E 5E 4E 75 4E 56 (UNLK A6 / RTS /
LINK A6) throughout both images, plus an MQX_OK assertion.

CB vs BD: a byte diff is useless (68% of bytes differ -- separately linked
builds, everything relocated). A string-set diff is position-independent and
shows 17,128 strings shared, with almost every "unique" string being the same
message at a different source line:

    CB:  MONITOR[3268]: STATUS_BATTERY_LOW
    BD:  MONITOR[3258]: STATUS_BATTERY_LOW

Consistently 10 lines apart across five different MONITOR messages, so one
~10-line block differs in the monitor module and essentially nothing else. The
only functional string unique to either build is CITIZEN (a printer brand) in
BD. This corroborates the bench A/B from the other direction: the split is a
tiny code delta, not two protocol stacks.

The SUB dispatch is a 68K switch jump table, so byte-pattern hunting will not
isolate the write opcodes -- that needs a disassembler.

Call-home config field names recovered from the firmware's own debug dump:
Enable, DialString, Retries, SessionTimeout, WaitForConnection, WarmupTime,
PowerSave -- seven fields for the 126-byte SUB 0x2C block. SessionTimeout and
PowerSave have no Series III equivalent, and Series III's scheduled-time fields
are absent, consistent with scheduling moving into the THOR-downloaded
scheduler. AT+CSQ is present, so the firmware speaks AT to the modem directly.

All five bench events downloaded and decoded over USB: each arrived at exactly
its declared size, every channel equal length, timestamps sequential.

Two gaps recorded:

- No content-based record-type discriminator. read_idf_file() dispatches on the
  .IDFH/.IDFW filename suffix, which does not exist over the wire, and the
  first 64 bytes of a histogram and a waveform are byte-identical. The protocol
  supplies one instead: SUB 0x0A returns 0x1E for a histogram and 0x00 for a
  waveform, so the type must be carried from the chain walk.
- The 0x0C peak float runs 2-5% above max(Tran,Vert,Long) and is not the vector
  sum either. Its offset was inferred from a byte marker rather than
  established, so it may not be the peak at all. Marked do-not-rely-on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
This commit is contained in:
2026-09-23 19:14:26 -04:00
co-authored by Claude Opus 5
parent 23cdbef737
commit 02ed22f561
+106
View File
@@ -652,6 +652,112 @@ This is the **read half of setup management**, and it means a setup can be
round-tripped: read the active config, modify, write it back. The write half
is not yet attempted.
## Static analysis of the firmware (2026-09-23, solo session)
### Architecture
**ColdFire / 68K, big-endian, Freescale MQX RTOS** — not ARM as the vector
table first suggested. The giveaway is the function epilogue/prologue
`4E 5E 4E 75 4E 56` = `UNLK A6` / `RTS` / `LINK A6`, littered through both
images, plus an `MQX_OK` assertion string.
### CB vs BD: the same source, ~10 lines apart
A byte diff is useless — **68% of bytes differ** because the two are separately
linked builds with everything relocated. A *string-set* diff is
position-independent and tells the real story: **17,128 strings shared**, and
almost every "unique" string is the same message with a different source line
number:
```
CB: MONITOR[3268]: STATUS_BATTERY_LOW
BD: MONITOR[3258]: STATUS_BATTERY_LOW ← consistently 10 lines apart
```
The offset is exactly 10 across `STATUS_BATTERY_LOW`, `STATUS_BATTERY_CRITICAL`,
`Battery Critical Exit Monitor`, `histogram interval size of 0` and
`Offsets by channel` — so one ~10-line block differs in the monitor module and
essentially nothing else. The only functional string unique to either build is
`CITIZEN` (a receipt-printer brand) in BD.
**This corroborates the bench A/B from the other direction:** the CB/BD split
is a tiny code delta, not two protocol stacks. Whatever drives Instantel to
ship two downloads, it is not a different wire protocol.
⚠ The `SUB` dispatch is a 68K switch jump table (`CMPI.L` bounds check →
`MOVE.W (table,PC,Dn)` → `JMP (d8,PC,Xn)`). Byte-pattern hunting will not
isolate the write opcodes — that needs a real disassembler.
### Call-home config field names, from the firmware's own debug dump
```
CallHome.Enable = %s
CallHome.DialString = "%s"
CallHome.Retries = %d
CallHome.SessionTimeout = %d
CallHome.WaitForConnection = %d
CallHome.WarmupTime = %d
CallHome.PowerSave = %s
```
Seven fields, which is what the 126-byte `SUB 0x2C` block has to encode. Note
`SessionTimeout` and `PowerSave` have no Series III equivalent, and Series III's
scheduled-time fields (`time1/time2 hour/min`) are absent here — consistent
with Series IV moving scheduling into the THOR-downloaded scheduler instead.
Also present: `AT+CSQ` (signal quality), so the firmware talks AT to the modem
directly.
## All five bench events, downloaded and decoded
Read-only, over USB, no Instantel software:
| key | declared size | got | decoded |
|---|---|---|---|
| `055d4a81` | 4,076 | 4,076 | histogram, 1 interval, 16:33:16 |
| `055d4a82` | 11,032 | 11,032 | waveform, 3072 × 4 ch, 16:33:19 |
| `055d4a83` | 11,502 | 11,502 | waveform, 3072 × 4 ch, 16:33:27 |
| `055d4a84` | 13,424 | 13,424 | waveform, 3072 × 4 ch, 16:33:34 |
| `055d4a85` | 8,746 | 8,746 | waveform, 2048 × 4 ch, 16:33:36 |
Every event arrived at exactly its declared size, every channel came out equal
length, and the timestamps are sequential across the recording session.
### ⚠ Gap: no content-based record-type discriminator
`read_idf_file()` decides waveform vs histogram from the **filename suffix**
(`.IDFH` / `.IDFW`) — and there is no filename when downloading over the wire.
The first 64 bytes of a histogram and a waveform are **byte-identical**, so the
header does not obviously carry the type either.
The protocol does supply one: **`SUB 0x0A` returns length `0x1E` for a
histogram and `0x00` for a waveform** (see the event-chain section). Any
direct-download implementation must carry the type from the chain walk rather
than inferring it from the payload, or find the type field inside the IDF
header.
### ⚠ Unresolved: the `0x0C` peak float
The float32 extracted from `0x0C` runs 2–5% above `max(Tran, Vert, Long)` from
the decoded samples:
| key | `0x0C` float | max channel |
|---|---|---|
| `…81` | 3.5152 | 3.4419 |
| `…82` | 1.3720 | 1.3706 |
| `…83` | 2.3542 | 2.2227 |
| `…84` | 3.5152 | 3.4419 |
| `…85` | 0.4227 | 0.4198 |
It is not peak vector sum either (computed PVS runs *higher* than both). The
field may not be the peak at all — its offset was inferred from a byte marker,
not established. **Do not rely on it** until it is pinned properly.
Worth noting the histogram (`…81`) and the loudest waveform (`…84`) report
*identical* peaks to four decimals, in both measures. That is self-consistent:
the histogram's single 1-minute interval spans the whole thumping session, so
its maximum should equal the loudest event in it.
## ⚠ Untested and unsafe-until-agreed
Nothing below has been sent to a unit, and nothing should be without an