Skip to content

Read the rig position and the morph from the channels that carry them - #4

Merged
gotwalt merged 16 commits into
mainfrom
fix/live-rig-position
Aug 24, 2026
Merged

Read the rig position and the morph from the channels that carry them#4
gotwalt merged 16 commits into
mainfrom
fix/live-rig-position

Conversation

@gotwalt

@gotwalt gotwalt commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Three bugs with one shape: a value the docs said was on the wire, that never actually arrived, so it silently never moved. Nothing errored — the value just sat still.

The rig position

docs/08 claimed the device announces every rig change as a Bank Select LSB plus a Program Change. It does not. A probe of the streaming session across nine rig changes yields zero non-SysEx bytes — which two other places in the same docs already said.

So current_bank / current_rig_slot froze at their connect-time seed, and MetersApp computed every move from a stale index 0. Every reported symptom follows from that one fact:

pressed computed why
Bank Up 0 → 5 stuck at 0, so always bank 2
Bank Up again 0 → 5 hence "can't go past bank 2"
Bank Down 0 → 0 swallowed by the target != current guard
Rig Up at bank 2 0 → 1 bank 1 rig 2

The position is on the stream, under an undocumented request function. $46 reads an extended address and the device answers $06 in ~20 ms — and pushes an unsolicited $06 for whichever of bank (100701) / slot (100702) changed on every rig change, front panel included. Below 16384 it answers in the $01 page/number form instead, mirroring how $47 degrades to $03. The CC32+PC parsing is gone along with its vectors.

The morph

Tracked at page 0 / $0B, taken from a third-party mapping and never validated. $0B is a real address that answers a request with a constant 0 whether the rig is morphed or at base, and is never pushed — a client keyed on it looks like a rig that is never morphed.

The morph is two addresses: $77 (119) the position, $50 (80) the button, momentary. Each language carries a regression test asserting $0B moves nothing. The PySwitch attribution in CREDITS.md and docs/09 is corrected, since that address came from there.

The position is CBOR-only and push-only — neither $41 nor $46 draws a reply, and it never appears on the MIDI3 stream — so StateSnapshot reads it from the state dump and a new live CborSession streams it.

Both channels at once

Neither channel is a superset. Measured with both open across the same gestures:

MIDI3 CBOR
Meter block (11 values) all eleven one — the tuner strobe phase
Morph position (119) never pushed, ~40 Hz while ramping
Morph button ($00/$50) pushed never
Session-open state dump none ~1174 addresses

The device tolerates concurrent read-only sessions — its fragility is connection churn, not concurrency — so a client that wants the whole device holds both. DeviceState::apply_cbor folds a CBOR value into the same fields and events as apply; DeviceModel::apply_cbor is the seam a client pipes the second channel through. All three languages.

MetersApp

Holds both sockets, shows the morph position live, and can toggle it — CC 11, which names a destination, rather than CC 80, which ramps over two seconds and alternates direction per press. It no longer pre-fetches the CBOR snapshot to learn its position; $46 does that on the session it already has, removing a fragile connection and ~1.3 s from every connect.

Navigation is now serialized behind a settle gate. Two rig loads issued 8 ms apart are answered normally and then kill the device ~20 seconds later, costing a power cycle — the fuse being delayed means nothing in the response says harm was done. One move at a time, the load left alone before its read-back follows, so a burst of taps costs two loads however long it is.

Also a loopback command socket (KP_DEBUG_PORT, off by default, binds 127.0.0.1) that drives the navigator and dumps the store's position — how all of the below was verified.

Docs

New docs/11-channels-and-data-paths.md compares the two channels and lists what a holistic pass should resolve. It also fixes a framing trap: "CBOR vs MIDI3 vs SysEx" is three things but there are two channels — SysEx is the payload language inside MIDI3 framing, and that conflation is exactly what produced the position bug.

Three places said concurrent sessions were unsafe (docs/01's "only one controller may hold a session at a time", docs/06, and the Python fetch_state_snapshot docstring). All now distinguish concurrency (fine) from churn (fatal).

Spec 0.5.00.6.0: function $46, the morph addresses, morph_address.

Verification

Against hardware, not just tests: the originally reported sequence; bursts of 2, 3 and 10 rapid taps (each followed by a full delayed-fuse wait, zero drops); a rapid Bank Up + slot tap landing in the new bank; morph on/off; and both channels held open together without a drop.

All three suites green — 381 Python, 105 Rust, 98 Swift — plus lint, format, and the codegen/vector drift checks.

Worth a second look

The amp page $0A is absent from the CBOR dump while every neighbouring page is present. One observation, one device (a Player), one firmware — flagged in docs/11 rather than asserted.

🤖 Generated with Claude Code

https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S

gotwalt and others added 16 commits August 23, 2026 15:25
Three bugs with one shape: a value the docs said was on the wire, that never
actually arrived, so it silently never moved.

**The rig position.** docs/08 claimed the device announces every rig change as
a Bank Select LSB plus a Program Change. It does not — a probe of the streaming
session across nine rig changes yields zero non-SysEx bytes, which two other
places in the same docs already said. So `current_bank` / `current_rig_slot`
froze at their connect-time seed, and MetersApp's navigation computed every move
from a stale index 0: Bank Up always landed on bank 2, Bank Down computed 0 -> 0
and was swallowed by the `target != current` guard, and Rig Up from bank 2 went
to bank 1 rig 2.

The position is on the stream after all, under an undocumented request function.
`$46` reads an extended address and the device answers `$06` in ~20 ms — and
pushes an unsolicited `$06` for whichever of bank (100701) / slot (100702)
changed on every rig change, front panel included. Below 16384 it answers in the
`$01` page/number form instead, mirroring how `$47` degrades to `$03`. The
CC32+PC parsing is removed along with its vectors.

**The morph.** Tracked at page 0 / `$0B`, taken from a third-party mapping and
never validated. `$0B` is a real address that answers a request with a constant 0
whether the rig is morphed or at base, and is never pushed. The morph is two
addresses: `$77` (119) the position, `$50` (80) the button, momentary. Each
language carries a regression test asserting `$0B` moves nothing.

The position is CBOR-only and push-only — neither `$41` nor `$46` draws a reply,
and it never appears on the MIDI3 stream — so `StateSnapshot` reads it from the
state dump, and a new live `CborSession` streams it.

**Both channels at once.** Neither is a superset: the meter block is MIDI3-only,
the morph position is CBOR-only. The device tolerates concurrent read-only
sessions — its fragility is connection *churn*, not concurrency — so a client
that wants the whole device holds both. `DeviceState::apply_cbor` folds a CBOR
value into the same fields and events as `apply`, and `DeviceModel::apply_cbor`
is the seam a client pipes the second channel through.

**MetersApp** now holds both sockets, shows the morph position live, and can
toggle it (CC 11, which names a destination, rather than CC 80, which ramps and
alternates direction). It no longer pre-fetches the CBOR snapshot to learn its
position — `$46` does that on the session it already has, removing a fragile
connection and ~1.3 s from every connect.

Navigation is now serialized behind a settle gate. Two rig loads issued 8 ms
apart are answered normally and then kill the device ~20 seconds later, which
costs a power cycle; the fuse being delayed means nothing in the response says
harm was done. One move at a time, the load left alone before its read-back
follows, so a burst of taps costs two loads however long it is.

Also: a loopback command socket (`KP_DEBUG_PORT`, off by default) that drives the
navigator and dumps the store's position, which is how the above was verified;
docs/11 comparing the channels and listing what a holistic pass should resolve;
and corrections to three places that said concurrent sessions were unsafe.

Spec 0.5.0 -> 0.6.0: function `$46`, the morph addresses, `morph_address`.

Verified against hardware: the reported sequence, bursts of 2/3/10 taps, morph
on/off, and both channels held open without a drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
…y language

spec/state.toml is the single answer to "what does the tree do with address
N": one row per tracked address (or per-slot / per-span expansion) with its
field, decode kind, lane, wire authority, dedupe and connect-time request
flag. Addresses stay in [well_known]; the table references them by name.
Codegen emits Field/Kind/Lane/Wire enums and a flat, address-sorted
STATE_ROUTES table into all three generated modules. Data only: the
hand-written routers are untouched in this commit and consume it next.

gen_vectors.py now owns spec/vectors/cbor.json as well. Spec 0.7.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
A process-wide ledger keyed by (host, port) records the last open and the
last close to each peer; Session.connect waits out connection_cooldown_ms
from the later of the two before it dials, and stamps the attempt as it
dials so a refused retry is paced like any reopen. No caller — the model,
CborSession, the snapshot fetch, a test against :5727 — can churn the
device any more, which was the one hazard docs/11 could only warn about.
Rust gains Session::connect_to(ip, port), close() and a Drop that stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
…ed it

DeviceState.apply_update is now the single path into the tree: the MIDI3
decoder and the CBOR entry points produce Updates tagged with their source
and phase, and one ordered rule set — lookup in STATE_ROUTES, untracked
fallback, wire authority, kind, range, dump-phase guard, dedupe, store —
decides what happens. The three hand-written routers, the string-tag maps
and the CBOR-only address switch are gone; a bool, u14, u16, u7, bpm, text
or meter-frame row behaves the same in Rust, Python and Swift because the
table says so and 43 new transport-tagged vectors (steps of midi3 / cbor /
cbor_text / cbor_dump with expected events and snapshot counts) pin every
rule. The state dump's position and morph are read through the same fold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
A [safety] block carries the request timeout and in-flight cap, the dump
settle fallback, and the reconnect and control-reopen floors; [cbor] gains
dump_end_address, the run that measurably ends every state dump. All are
generated into the three libraries for the model to consume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
DeviceModel now opens the MIDI3 stream and, by default, the CBOR control
channel, feeds both into one funnel that is the single writer of the state
tree, and shows the app one Connection (Disconnected / Reconnecting /
Connected / Degraded) plus per-channel health, never a wire. Requests
become request/reply through a paced lane (16 in flight, 300 ms, no retry;
the morph is Unreadable without a byte sent); refresh() is the table's
request=true rows, so the connect burst is the same 46 requests in every
language. The dump folds under a dump phase that ends on the measured end
marker; live pushes outrank stale dump items. Reconnect is an explicit
policy — off by default, so tools still see Disconnected — and close()
is the only thing that finishes a stream. CborSession and the snapshot
fetch are rebuilt on the same control-link code and remain as tooling.
Each language gains a multi-connection, protocol-aware fake device and
integration tests for every transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
…constants

The amplifier's string tags start at 0x10, not 0x0A: the device answers a
request for 0x10 with the amp name and never answers 0x0A, and the state
dump lists the amp block at 16..26 beside the rig's 1..4 and the cabinet's
32... The old value survived because a fire-and-forget request cannot see
an answer that never comes; the request lane's timeout found it in the
first minute against a device.

[transport] gains handshake_timeout_ms (the greeting has taken 800 ms on a
device that has served a few sessions; the 30 ms read idle is for the gap
between chunks, not the first byte) and [safety] gains rig_load_settle_ms,
pending_window_ms and rig_load_controllers for the Navigator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
…the greeting

A rig load now goes through the model's Navigator — a pure, vector-pinned
state machine (spec/vectors/navigation.json) that aims, sends the bank
preselect + slot pair once, holds the move in flight for the measured
settle, retires the aim on the device's own position report and drops an
aim the device never confirms. A burst of taps costs two loads however
long it is, and two loads can never overlap: send_control refuses the
rig-load controls and Program Change / Bank Select, send_raw refuses the
same bytes, and select_rig / rig_up / rig_down / select_rig_index are
gone. There is no read-back after a load: the device pushes the landed rig
on both wires within ~400 ms. MetersApp forwards navigation to the model,
opts into the library's reconnect policy and loses its serializer, retry
loop and connection plumbing — about 300 lines.

Session.handshake now waits handshake_timeout_ms for the first byte of the
greeting and of the selection reply; the 30 ms read idle only bounds the
gap between chunks. A device that has served a few sessions has taken
800 ms to greet, which used to fail the whole connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
…mp's two sections

spec/captures/cbor-state-dump.json is one real state dump plus the live
items that arrived with it, every string replaced by a placeholder and
every opaque blob emptied; the three harnesses decode it, count its
shapes, and fold it into a tree that must read the same in Rust, Python
and Swift. Authoring it showed that the dump is two sections, each closed
by the run at 100800 — the system section first, then the rig section
that reopens with the position run — so [cbor] now says dump_end_runs = 2
and the model will end the phase on the second.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
A cross-language review compared the public surface, the event and
snapshot sequences of every connection transition, the runtime guards and
the test suites, and found the places where Rust, Python and Swift had
each read the same design a little differently. They now agree on: the
order of channel and connection events on loss and close; one snapshot
per composite transition; Degraded holding through a control redial; a
missing protocol-selection reply being a handshake timeout; the control
link's greeting getting the full handshake budget and requiring the CBOR
protocol to be offered; requests refused as Unreadable before the stream
is consulted, renders included, and a reply wider than 14 bits being no
reply; the dump phase ending on the second end run; select_slot ignoring
an out-of-range slot; refresh() issuing the table in table order; a
bounded command queue everywhere; reopen_control leaving a live link
alone; and close() returning only once the sockets are closed. Every
behaviour that had a test in one language now has it in all three, and
the fake devices can simulate the same faults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
…ice keeps them

The docs now describe one model with two links: docs/11 is the design
record with every measurement and the hazard-to-mechanism pairing, docs/06
treats the CBOR channel as the model's control link, docs/05 gains the
string-tag table and the request/reply semantics the lane relies on,
docs/08 documents the Navigator and the refusals, docs/03 the greeting
wait, and docs/01 the concurrency-versus-churn truth and a one-table API
summary; the four READMEs share one quick start. The string-tag registry
moves the amplifier block to 16.. where the device answers and the dump
lists it; the published list's 10.. draws no reply on this firmware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
Spec 0.8.0. The fold now reports positions to the Navigator even when
dedupe silences the event (a reload is confirmed by a push that changes
nothing), takes any block at the meter base as the frame (short reads
zero-fill instead of flooding generic events), and bounds the flat rig
index to sixteen bits instead of trapping or wrapping. The Navigator
refuses an index the bank preselect's seven bits cannot name and queues
its two-message load whole or not at all. Request replies at sensitive
addresses are redacted like every other reader. Swift's StreamLink
queue is actually synchronized; Python's CborSession replays the dump
backlog without overflowing, closes a lost control link to completion
(stamping the cooldown ledger), and spawns exactly one recovery per
socket loss. New conformance vector cases pin the positions contract,
the index bound, and the meter-frame tolerance in every language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
The CBOR walk keeps empty strings — a cleared tag is a value like any
other, and the tree could otherwise never unlearn the old text — with
the capture fixture's string list regenerated to match and unified on
the reader's redacted view in all three harnesses. The $06/$07 parsers
refuse a 35-bit address instead of wrapping it onto some other
parameter, pinned by new vector cases. Rust's legacy Connected event
now follows the rule Python and Swift hand-write: a session coming up,
not a control link recovering, and not a session that lands already
degraded. Swift cancels the NWConnection of a failed dial instead of
leaking one per reconnect attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
The targeted-refresh groups move out of three hand-kept field lists and
into a refresh column on spec/state.toml: the generator emits a Refresh
enum and each language's refresh_rig / refresh_bank / refresh_position
filters the table on it, so a row added to the spec joins its group in
all three implementations at once. And the Rust and Swift snapshot
fetchers fold each read incrementally instead of re-walking every item
received so far per completeness poll, which made the read quadratic in
the dump; Python already read it that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
The capture harness's redaction ternary collapses to the one line ruff
format wants, and MorphControl's title switch spells its optional
patterns explicitly — CI's older Swift toolchain does not promote the
bare literals over the optional and refused the switch as
non-exhaustive, which predates this branch's changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qfb4HRujs3yNJBYVAfrQi
@gotwalt
gotwalt merged commit a60ae7f into main Aug 24, 2026
5 checks passed
gotwalt added a commit that referenced this pull request Aug 24, 2026
Read the rig position and the morph from the channels that carry them
@gotwalt
gotwalt deleted the fix/live-rig-position branch August 24, 2026 19:20
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