Skip to content

BT-9000: settings on program, scan-add, and zones (all three hardware-proven) - #127

Merged
ww8l merged 5 commits into
mainfrom
bt9000-settings-on-program
Sep 3, 2026
Merged

BT-9000: settings on program, scan-add, and zones (all three hardware-proven)#127
ww8l merged 5 commits into
mainfrom
bt9000-settings-on-program

Conversation

@ww8l

@ww8l ww8l commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Three defects Tim found by using a shipped radio, all the same shape: a flag
that described the radio correctly and the operator's intent not at all.

Every fix in here was proven on the real BT-9000.

1. A codeplug program never wrote the profile's settings

carries_profile_settings was false, with a note saying that was the safe
choice because this radio validates nothing.

It was not the safe choice, because it was never the quiet one. The whole-image
upload addresses WRITE_SEGMENTS, and the function block is one of them —
every program was already rewriting the settings segment, with the bytes it
had read off the radio moments earlier. The flag did not withhold a write; it
overwrote the profile with the radio, every time.

Settings now go into that block before it goes out. The write is not one byte
wider. The encoder is a PATCH, so a key the profile does not carry keeps what
the radio had.

2. Every channel was programmed excluded from the scan

Byte 15 bit 2 was left clear on all 960 memories, so a scan stopped on nothing
the app wrote. Now set on every programmed channel, as the TD-H3 does.

The radio then confirmed the convention on its own: a channel stored by hand on
the radio comes back with b15=0x06 — scan-add set.

3. Zones were unused, and then wrong

zones_supported was false because the radio stores no zone NAMES, which
confused the label with the zone. Ten zones exist and the operator switches
between them.

The first attempt at this used the geometry the manual gives ("up to 15 zones
... 64 channels per zone") and the RT-950 Pro reference driver computes
(index // 64). Both are wrong, and programmed with them a codeplug's
second list went to memory 64 — which the radio calls zone 1, channel 65.

Eleven memories were written and read off the radio's own screen, three of them
as predictions made before looking:

memory radio says
0, 63 zone 1 ch 1, ch 64 factory
64 zone 1 ch 65 ⚠ kills the 64-wide model on its own
99, 100, 128, 192 zone 2 ch 1, 2, 30, 94 99 stored by hand
198 zone 3 ch 1 stored by hand
296 zone 3 ch 99 predicted, confirmed
297 zone 4 ch 1 predicted, confirmed
959 zone 10 ch 69 predicted, confirmed

So zone_base = (zone - 1) * 99, ten zones, and ⚠ 99 does not divide 960
the tenth holds 69. Anything laying channels out by zone must ask each zone's
capacity rather than multiply, or it addresses memory 989 on a radio with 960
and patch_image drops those channels silently. ZoneLayout carries the memory
total for exactly this.

One channel list per zone, spilling into the next zone rather than truncating,
because dropping channels an operator explicitly listed is the worse failure. A
channel in two lists is programmed twice — on a positional radio that is the
only way to be in both. The Program dialog shows the zone map before the write,
because the radio can only ever say "zone 2".

Also ruled out, so nobody re-derives them

Zone membership is not in the channel record (memories 64 and 100 hold
byte-identical records in different zones), not in the 2 KB at 0x7800 that
no published map describes, and nowhere in the clone image — creating a zone
on the radio changed only VFO state, the settings shadow and a firmware journal.
The vendor CPS cannot set zones either; it keeps 15 zone names in its own .dat.

Two checks that could not have caught any of this

  • Hardware ladder step 3 ("full codeplug across every zone: PASSED") wrote to
    computed addresses and compared the read-back against the same computation. It
    never asked the radio what it called any of them, so it proved the transport
    and nothing about the layout. Its doc now says so.
  • The driver and the seed both said 64, so they agreed and nothing
    complained. seed::tests::the_bt9000_zone_geometry_is_the_radios_own now holds
    the columns the layout actually reads to the constants that carry the
    measurement.

fixed_zone_layout also asks the DRIVER, not the three zone columns: the
AT-D890UV declares zones with 250 of 160 too, and its zones are records naming
arbitrary slots. Reading the columns alone — how it was first written — would
have laid its channels out positionally.

Verification

settings on program ✅ on the radio
scan-add ✅ on the radio
zone layout ✅ on the radio — HOME zone 1, GMRS zone 2
zone geometry ✅ 11 memories off the radio's screen, 3 predicted
resolver 4 layout rules + the ragged last zone, against a real database
dialog zone map rendered against canned IPC

⚠ Byte 15 bit 2 as scan-add is inherited from the RT-950 Pro reference driver
rather than measured directly, but a scan on the radio now stops on programmed
channels and a radio-authored channel carries the same bit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM

ww8l and others added 5 commits September 2, 2026 20:04
`carries_profile_settings` answered false, so `program_codeplug` ignored
`req.settings` — and the note said that was the safe choice, because this
radio validates nothing and an unintended value would be stored rather
than rejected.

It was not the safe choice, because it was never the quiet one. The
whole-image `upload` addresses WRITE_SEGMENTS, and the function block is
one of them: every codeplug program was already rewriting the settings
segment, with the bytes read off the radio moments earlier. Saying "we
leave settings alone" described the effect on the radio, not on the
profile — the profile's settings were being overwritten by the radio's
own on every program.

So the settings now go into that block before it goes out. The write is
not one byte wider than it was. The risk the old note worried about is
bounded by the encoder being a PATCH: a key the profile does not carry
keeps the value the radio had, the command layer only fills
`req.settings` from a profile the operator saved, and the range strip
runs first so a stale value is dropped with a note instead of failing
the program. The narrow standalone settings write stays — a third of a
second against the four minutes a full program takes.

The Program dialog needs no change: its banner is written from the
capability flag, so it now promises the settings and drops the "written
separately" sentence on its own.

⚠ Not verified on the radio. The encoder, the segment table and the
read-back verifier are all the ones hardware ladder step 5 proved in
s128, but this particular path has not been run against a BT-9000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
Byte 15 bit 2 was left clear on all 960 memories, so a radio programmed
by this app scanned nothing it had been given. The old comment called the
whole byte "measured defaults, not guesses" — true of the bits that had
been measured, and quietly untrue of this one, which had never been set
either way.

The channel library has nowhere to store a per-channel skip, so this is a
policy choice and it is now the same one the TD-H3 makes: scan-add set
beside the used flag on every channel written, cleared on none, because a
memory excluded from scan is the surprising default of the two.

⚠ The BIT is inherited, not measured. `chirp_rt950/rt950pro/channel.py`
decodes `flags & 0x04` as `scan_add` and round-trips it against the
vendor CPS's own `.dat` files, and no BT-9000 image on this machine has
it set — both radio-authored dumps carry 0x02, TX-enable alone. Its
neighbour at bit 1 was a source claim of exactly this kind until the
radio was made to refuse a PTT. So: unproven until a scan on the radio
stops on a programmed memory. The test pins the DECISION rather than the
bit, so if the bit is wrong, one constant moves and the policy does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
The radio has fifteen zones of sixty-four and the operator switches
between them with a key. This app poured every channel list into one
undifferentiated run of memories, so a zone began and ended wherever the
previous list happened to stop.

The seed said `zones_supported: false`, and the comment gave the reason:
the radio stores no zone NAMES, so declaring zones "would be offering a
name the radio cannot store." That confused the label with the zone. The
zones exist either way; withholding the flag withheld the layout, not the
name.

Zones here are index arithmetic — a memory's zone is `slot / 64` and
there is no zone table anywhere in the image — so honouring them is a
decision about which slots the channels land in, and it belongs in the
resolver, not a driver that only ever sees slots.
`resolve_codeplug_zone_slots` lays one list into each zone:

  * a list that does not fill its zone leaves the rest of it empty, and
    that gap is the feature — it is what keeps zone 2 the second list;
  * a list longer than one zone spills into the next rather than being
    truncated, because dropping channels an operator explicitly listed is
    the worse failure and "this list is zones 1-2" costs one sentence;
  * a list with nothing programmable takes no zone, and the rest move up;
  * a channel in two lists is programmed TWICE, once in each zone — on a
    positional radio that is the only way to be in both.

⚠ `fixed_zone_layout` asks the DRIVER, not the three zone columns. The
AT-D890UV declares zones with 250 of 160 as well, and its zones are
records naming arbitrary slots; reading the columns alone — which is how
this was first written — would have laid its channels out positionally
and shown it a zone map it does not use. A radio programmed as a whole
image has no zone table to write, which is exactly what makes position
the membership.

The names stay ours, so the Program dialog shows the map before the
write: the radio can only ever say "zone 2".

Verified: the resolver against a real database (four decisions, plus the
AnyTone exclusion), and the dialog rendered against canned IPC. Not yet
run on the radio — the check is that its zone 2 holds the second list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
The previous commit laid one channel list into each zone using the
geometry the manual gives ("up to 15 zones can be set, with 64 channels
per zone") and the RT-950 Pro reference driver computes
(`zone = index // 64 + 1`). Both are wrong. Programmed with them, a
codeplug's second list went to memory 64 — which this radio calls zone 1,
CHANNEL 65 — so the operator got one zone, which is exactly what he
reported.

Eleven memories were written and read back off the radio's own screen:

  | memory | radio says      |
  |--------|-----------------|
  | 0      | zone 1 ch 1     | factory
  | 63     | zone 1 ch 64    | factory
  | 64     | zone 1 ch 65    | ⚠ kills the 64-wide model on its own
  | 99     | zone 2 ch 1     | stored by hand on the radio
  | 100    | zone 2 ch 2     |
  | 128    | zone 2 ch 30    |
  | 192    | zone 2 ch 94    |
  | 198    | zone 3 ch 1     | stored by hand on the radio
  | 296    | zone 3 ch 99    | PREDICTED, then confirmed
  | 297    | zone 4 ch 1     | PREDICTED, then confirmed
  | 959    | zone 10 ch 69   | PREDICTED, then confirmed

So `zone_base = (zone - 1) * 99`, and there are ten zones, not fifteen.
99 is almost certainly the two-digit channel display.

⚠ 99 does not divide 960: nine zones of 99 and a tenth of 69. Anything
that lays channels out by zone must ask each zone's capacity instead of
multiplying, or it addresses memory 989 on a radio with 960 and
`patch_image` drops those channels without a word. `ZoneLayout` now
carries the memory total for exactly this, and a test drives the ragged
edge through a 6-memory geometry so it arrives in four channels rather
than nine hundred.

Also ruled out along the way, so nobody re-derives them: zone membership
is NOT in the channel record (memories 64 and 100 hold byte-identical
records and sit in different zones), NOT in the 2 KB at 0x7800 that no
published map describes (all 00/FF), and NOT anywhere in the clone image
— creating a zone on the radio changed only VFO state, the settings
shadow and a firmware journal. The vendor CPS cannot set zones either;
it keeps 15 zone NAMES in its own .dat and no zone data in the radio.

⚠⚠ Hardware ladder step 3 ran against the wrong geometry in s128 and
passed. It could not have failed: it wrote to computed addresses and
compared the read-back to the same computation, never asking the radio
what it called any of them. A check that closes the loop on itself proves
the transport and nothing about the layout. Its doc now says so.

The driver and the seed both said 64, so they agreed and nothing
complained. `seed::tests::the_bt9000_zone_geometry_is_the_radios_own`
holds the columns the layout actually reads to the constants that carry
the measurement.

Not yet programmed to the radio through the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
Review of the branch. The zone layout introduced a regression with the
worst possible failure mode, and six smaller problems.

★★★ THE ONE THAT MATTERS. `program_codeplug` refuses a codeplug with
more channels than the radio holds. After the zone layout, an
over-capacity codeplug no longer arrives as an over-long slot list — it
arrives as an EMPTY one, because every list was refused. So the guard
could not fire, `patch_image` filled all 960 records with 0xFF, and the
radio came back blank, having been told it now "matches" the codeplug.
The dialog helped: `zones.length > 0` meant both "not a fixed-zone radio"
and "fixed-zone radio, nothing placed", so it fell back to the flat
channel count and offered to write 961 channels that were not there.

Three parts to the fix, because one is not enough:

  * `program_radio` refuses outright when the layout placed nothing and
    the codeplug wanted something — before the port is opened, so the
    mandatory pre-write backup is not the thing standing between the
    operator and an empty radio;
  * `ExportPreview` declares `fixed_zones` instead of leaving the
    frontend to infer it from a list that is empty in two different
    situations;
  * `ZonedCodeplug` reports the channels it REFUSED, and the preview
    marks those rows excluded. They were listed `included: true` with no
    reason, so the channel table promised to program channels the write
    was going to leave behind, and only the prose note disagreed.

Also:

  * the confirm said "to slots 1-N", which is exactly what fixed-zone
    placement is not — 5 channels in two lists land in memories 1-3 and
    100-101. It names zones now. That sentence is the last thing read
    before a destructive write.
  * `zones_written` was set by the command layer and read by nothing on
    the generic screen (#87's shape again), so the zone map was promised
    before the write and never confirmed after it. Now in the result.
  * `a_bt9000_codeplug_reaches_the_image_through_the_apps_own_pipeline`
    asserted "slots must be dense from 0" through a resolver production
    no longer calls for this radio — dense-from-0 IS the bug. It goes
    through `fixed_zone_layout` now, like `program_radio` does.
  * five stale "15 zones of 64" comments, including one in the seed that
    said "the flag now says what is true — fifteen zones of sixty-four"
    three lines above `Some(10)` and `Some(99)`, inside the block whose
    subject is that 15/64 was measured wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
@ww8l
ww8l merged commit 76179bd into main Sep 3, 2026
3 checks passed
@ww8l
ww8l deleted the bt9000-settings-on-program branch September 3, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant