Read the rig position and the morph from the channels that carry them - #4
Merged
Conversation
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
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
added a commit
that referenced
this pull request
Aug 24, 2026
Read the rig position and the morph from the channels that carry them
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/08claimed 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_slotfroze at their connect-time seed, and MetersApp computed every move from a stale index 0. Every reported symptom follows from that one fact:target != currentguardThe position is on the stream, under an undocumented request function.
$46reads an extended address and the device answers$06in ~20 ms — and pushes an unsolicited$06for whichever of bank (100701) / slot (100702) changed on every rig change, front panel included. Below 16384 it answers in the$01page/number form instead, mirroring how$47degrades 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.$0Bis 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$0Bmoves nothing. The PySwitch attribution inCREDITS.mdanddocs/09is corrected, since that address came from there.The position is CBOR-only and push-only — neither
$41nor$46draws a reply, and it never appears on the MIDI3 stream — soStateSnapshotreads it from the state dump and a new liveCborSessionstreams it.Both channels at once
Neither channel is a superset. Measured with both open across the same gestures:
$00/$50)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_cborfolds a CBOR value into the same fields and events asapply;DeviceModel::apply_cboris 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;
$46does 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.mdcompares 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 Pythonfetch_state_snapshotdocstring). All now distinguish concurrency (fine) from churn (fatal).Spec
0.5.0→0.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
$0Ais absent from the CBOR dump while every neighbouring page is present. One observation, one device (a Player), one firmware — flagged indocs/11rather than asserted.🤖 Generated with Claude Code
https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S