Wrapping the menu in a scroll view re-indented this line, so dart format
split the if statement across two lines and CI's --fatal-infos analyze
flagged curly_braces_in_flow_control_structures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings feat/1014-ios-app-review up to date with dev for PR #1049.
Conflicts: the app name in TESTFLIGHT_GUIDE.md and tools/ios-signing/README.md
keeps "Offband MeshCore" (#1050); channels_screen.dart takes dev's channel
menu (new radio sound tile) and re-applies the #1075 scroll wrapper.
testflight.yml is dispatch-only again, as the push trigger was only for the
owner's TestFlight pass (#1047). REVIEW-NOTES encryption line and PLAN
finding 14 updated to match what App Store Connect shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User report (Jeremy AA4KY, iOS; one codebase so the fix is
platform-wide): the Edit Channel dialog was type-blind, offering a
free-text PSK field and a random-PSK die for every channel. A hashtag
channel's key IS its name, so hand-editing it silently diverges the
channel from everyone else on that hashtag.
- hashtag: key read-only and copyable (copy icon replaces the die),
derived live from the name while renaming, exactly as adding that
hashtag would. Save refuses a name that stops being a hashtag or
carries a double '#' (a '##name' hashes differently), inline error.
- community hashtag: name and key both read-only (community-secret
derived), key copyable.
- private/public/communityPublic: unchanged.
- Save also refuses, with a persistent error, when the slot's live
occupant no longer matches the channel the dialog opened on, so a
background sync replacing the slot mid-edit (the 1060 class) cannot
be overwritten by a stale dialog (review finding).
Policy helpers are unit-tested; with both guards behaviorally
disabled 2 tests fail. Suite 1903 passed / 1 skipped. English strings
only; untranslated.json regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (gemini-2.5-pro) on the push candidate raised five
findings. Two were real and are fixed here; the other three are justified
in the PR body.
Storage is an untrusted source too. The guards already on this branch stop
an erased (all-0xFF) record being adopted from the WIRE, but a build from
before they existed could have persisted one, and loadCachedChannels
applied no filter. _cachedChannels is a fallback identity source for
_findChannelByIndex and is re-saved by the unread paths, so such a record
comes back as the channel for live traffic and outlives every radio. It is
dropped at the load boundary, which protects every consumer at once. It
carries nothing recoverable: the name is U+FFFD and the PSK is all-ones.
restoreChannelsToRadio sent its writes in an unpaced loop. On BLE sendFrame
prefers write-without-response, so its await returns when the frame reaches
the OS and not when the radio has taken it: a multi-channel restore could
overrun the radio and lose channels with no error raised anywhere. The USB
branch of sendFrame already spaces its own frames for exactly this reason.
Same 10ms gap between consecutive channel writes.
The restore result is documented as written and NOT confirmed. Counting
confirmations after the re-sync was tried and reverted: getChannels returns
once the first request is away rather than once the sync has finished, so
it would have read a list the radio had not answered for and reported zero
every time. The erased-slot banner is re-derived on every sync and stays up
if writes did not land, so it remains the authoritative signal.
Both new guards proved against planted defects rather than assumed:
removing the load filter turns 2 tests red, and removing the pacing yields
"Expected: a value greater than or equal to <20>, Actual: <0>".
flutter analyze clean, 1896 tests pass, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the owner's field report: a Channels screen full of
replacement characters, and a channel he had silenced chirping again.
An erased record is not an empty one. Erased flash reads back 0xFF and
Channel.isEmpty tests for 0x00, so a slot the radio had LOST was adopted
as a channel, rendered with U+FFFD, and then persisted over the client's
own good cached list. One missing validation gate, two symptoms.
- Channel.isErased / isUnusable: an all-0xFF record, or a name that did
not decode, is never adopted.
- A sync that sees an erased slot KEEPS the cached list, reusing the
fail-closed branch #1062 already had for unconfirmed holes.
- restoreChannelsToRadio() writes the cache back to the original slots
with their PSKs, behind a confirmed banner action.
Radio notify was keyed by slot, not identity. #544 landed after #1061 and
keyed its in-memory map by slot index; the slot-identity handler cleared
the slot's messages but not its notify mode, so a channel that moved
slots handed its mode to the new occupant and lost its own.
- _channelNotifyModes is keyed by PSK hex, matching channelNotifyModesByRadio
(#848) and the PSK-keyed history (#194).
- A reply whose slot does not resolve to a confirmed channel is dropped.
- setChannelNotify takes a Channel and resolves its CURRENT slot at send
time, so a stale handle cannot address the wrong one; unresolvable is a
visible refusal, not a silent write.
Both guards proved against planted defects: forcing isErased false turns
5 tests red including the cache overwrite, and restoring the slot-keyed
map yields Expected: null, Actual: RadioChannelNotify.off.
flutter analyze clean, 1885 tests pass. New strings English-only with
untranslated.json regenerated.
Task: meshcore-open-75d
Agent: CalmBay
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stock-parity gap: the companion radio persists a pin set over
CMD_SET_DEVICE_PIN (37) and reports the configured value in device
info bytes 4..7, but the client exposed neither.
- cmdSetDevicePin + buildSetDevicePinFrame ([cmd][pin x4 LE]).
- parseDevicePin reads the device-info field; deviceBlePin getter
(0 = firmware build default, null until reported), nulled on each
new connection handshake; setDevicePin validates with firmware's
own rule (0 or 100000-999999) and sends like the other config
writes.
- Settings tile in the device Actions card, pin shown in PLAIN text
(owner ruling 2026-11-06: no masking, parity with other mesh
clients). Edit dialog with inline validation and Reset to default;
persistent re-pair notice on success, persistent error when the
send fails. English strings only; untranslated.json regenerated.
Gemini gate on the diff: fixed the unsaveable prefill of CLI-set
out-of-range pins, the failed-send success lie, and a controller
leak; double-tap idempotence and the short-frame fallback justified
in the PR. Negative evidence: mutating the encoding, the parse
offset, or the validation fails 4 of the 7 new tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings:
- Held channel message frames are now keyed per radio: verifying the
unbounded-growth finding exposed that frames held from radio X would
have flushed into radio Y's conversations after a radio switch. A
parked radio's frames flush when that radio next completes a sync. No
cap is added: held frames are popped messages, dropping one is
guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame).
- _loadChannelMessages merges messages that arrived during the disk
read instead of clobbering the list, so the slot-identity reload
cannot hide a message the held-frame flush just filed (disk was never
at risk: saves merge by identity, #343).
- sendChannelMessage refuses the write for a slot this connection has
not confirmed (the persisted copy would fall back to the legacy
slot-index key and be orphaned), and the chat screen surfaces a
persistent error at every send path instead of greying anything.
New English UI string; untranslated.json regenerated (#1063).
Three integration tests cover the fixes (#1064); with the guards
behaviorally disabled exactly those three fail (+3 -3). Full suite
1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063):
the #1060 bug-validation scenario (wipe + re-import with shuffled slots:
list matches device, no misfiled or mis-rendered message, notify modes
evaluate against the right PSKs), a manual device-side reorder (history
follows the channel identity to its new slot), and a flaky sync
mid-reconnect (held traffic files only after a confirming sync).
Writing them exposed one gap: _flushHeldChannelFrames documented that a
frame for a still-unconfirmed slot re-holds, but the handler only gated
on the list-level barrier, so after a partial sync such a message would
file by raw index against an unknown identity and persist under the
legacy index key. _handleIncomingChannelMessage now re-holds it until a
sync confirms the slot.
Test plumbing: channelNotifyProbeForTest observes the notify decision
(slot, label, PSK, mode) without reaching the NotificationService
singleton; wireMessageStoreForTest wires the message store the way the
SELF_INFO handshake does.
Issue: #1064
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persisted channel history has been PSK-keyed since #194; the remnants
that could still cross-wire on a slot reassignment were in-memory and
legacy. Epic B of #1061, stacked on #1062:
- _completeChannelSync's identity diff now drops the slot's in-memory
conversation and reloads it from the store, which reads the NEW
identity's own history through the (now fail-closed) resolver.
- setChannel's reassignment clear purges only the LEGACY slot-index
keys (new clearLegacySlotHistory), so the outgoing occupant's
PSK-keyed history survives and comes back if the channel is re-added.
True deletes (deleteChannel) keep the full both-key clear.
- A WRITE that falls back to the slot-index key now logs an error with
the orphaning consequence spelled out; post-#1062 live ingress cannot
reach it.
- The #193-era comment claiming history is slot-keyed is corrected and
the setChannel/resolver interplay documented.
Tests: 2 connector tests (in-memory drop on identity change, kept on
same identity) + 5 store tests (legacy purge, PSK history survival,
no adoption onto the new occupant, true delete).
Issue: #1063
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read from firmware src/helpers/ChannelNotify.h on epic/606-channel-notify
rather than taken from Agent Mail: kChanNotifyUnset = 0, All = 1,
Mentions = 2, Off = 3, Count = 4. The client enum already matched byte for
byte, so nothing changes in behaviour; this records that it was checked
against the source, since taking numbers from mail is what caused the
collision in the first place.
The contract names the modes All / Self / None plus 'follow the device
setting'. That is the same encoding in different words: Self is mentions,
follow is unset. Worth saying so explicitly, because two vocabularies for one
wire byte is how a later session talks itself into a renumber.
Also records WHY the rotation collision was silent rather than refused:
firmware validates a SET with 'stored < kChanNotifyCount' and nothing else, so
every rotation value from 0 to 3 passes as a valid notify mode and writes to
the channel at that slot index. Slot 0 is Public.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Realigns the client to the merged contract. Firmware
docs/offband_info_frame_contract.md revision 2 and OffbandConfigProtocol.h,
both on firmware-base; the header is the registry and wins on any
disagreement. Wire numbers claimed by firmware PR #2000, merged 28785ebf at
2026-11-04 08:34:29Z. Nothing here was built before that merge, per contract
section 2.
Three features each computed "the next free byte" of upstream's device-info
frame as 87 and each was right in isolation. A collision at a frame offset is
silent misparsing, not a refused command: everything compiles, every test
passes, and it only shows up on hardware as one feature appearing broken.
#1095 the advert client
#1096 offset 87 removed, with a guard
#1097 channel notify re-gated
One request on connect, zero payload, after device-info has landed and so well
past MTU negotiation. The client asks and the device never pushes: _att_mtu is
23 until onMTUChange fires, so a pushed advert would be truncated to a 20-byte
payload as the normal case.
A tag's presence IS the capability. No bitmap, no probing each feature's
sub-code. len 0 means supported with nothing to report; an absent tag means the
board lacks the feature and is neither an error nor a zero value. Unknown tags
are kept and skipped by len, because a client built tonight has to stay correct
against firmware emitting tags it has never heard of. TAG_CONTINUE is honoured,
bounded at 8 rounds and refused a cursor that does not advance: an unbounded
request loop driven by device data is worse than a truncated capability list.
[0xC5][0x7F][0x05] to the zero-payload request means the radio predates the
advert, so capabilities fall back to the device-info offsets that shipped
before it. That reason is overloaded on firmware (malformed payload on a known
sub-code returns it too), which is exactly why the request carries no payload.
The refusal names no sub-code, so it is claimed only while an advert request is
outstanding AND no write is: a write owns its own refusal.
Channel notify gating is PRECEDENCE, not OR, and this part needed a ruling.
Both caps2 0x80 and TAG_CHAN_NOTIFY are merged claims and the contract does not
say which wins. Both single-source readings break on a real radio: tag-only
takes the control away from 4e5a6c85, already flashed on the T1000-E, which has
the bit and predates 0x1C; bit-only loses the control when the bit is retired
under contract section 4. So an advert, once it arrives, is authoritative and
the bit is ignored, and with no advert the bit answers, which is what shipped
before. Raised with FuchsiaCreek before coding rather than worked around
(Agent Mail 1449); one expression to invert if the ruling differs.
Rotation and ACK capacity gain their gates here but not their features: #1098
and #1099 follow. Rotation is tag-gated with no caps-bit fallback, deliberately
-- 0x1A/0x1B were claimed in the same merge as the advert, so no firmware can
rotate but not advertise -- and explicitly NOT on caps2 0x04 INDICATORS, which
means something else.
parseOffbandCaps3, offbandCaps3Offset and offbandCaps3MinFrameLength are gone
rather than left unused, so a later change cannot pick them back up. The
replacement is behavioural: a device-info frame with every bit set at offset 87
must produce identical capability answers to one that has no byte 87 at all,
the raw caps byte included. The getter list alone was a hole -- a leak into a
bit with no getter yet moved nothing the test could see -- found by planting
such a read and watching the test pass, then comparing the raw byte as well.
Each new guard was made to fail before being trusted: the unknown-tag skip
(parser dropping unknown tags, 2 red), the precedence rule (gate rewritten as
an OR, 1 red), the offset-87 guard (a read reintroduced, 1 red after the
strengthening above).
flutter analyze clean. 1869 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Epics B3 and C of #544, so there is something testable on hardware.
B3 reads the radio's mode for every reported slot, hooked to
_cleanupChannelSync(completed: true). Deliberately only on completed: the
failure branch restores a CACHED channel list, and addressing a slot from a
cache is the guessed-slot error this must not make. When #1061 lands its
stricter confirmed-list barrier this moves onto it.
C adds 'Radio sound' as its OWN control beside the phone's notifications,
on the Channels sheet and the channel chat menu, gated on caps2 0x80. The
existing control is relabelled 'Phone notifications' so neither is mistaken
for the other. Neither reads the other: that independence is pinned by the
A2 tests.
C3, the owner's call: the row shows what the channel will ACTUALLY do, not
the stored byte. A set mode decides for itself; an unset one reads 'Follows
the radio: <scope>'; and a device scope of None shows as silent even on a
channel set to All, because telling a user with a muted radio that a
channel is on 'All messages' is the confusion this feature exists to end.
Null composes to a reading state, never to an invented mode.
Proved that check can fail: removing the None-override branch fails the
test that asserts it. Restored.
Strings are English-only with untranslated.json regenerated, per the l10n
rule.
flutter analyze clean; full suite 1809 passed.
#1075: the contact, channel, message and channel-sender menus were plain
Columns in a sheet capped at 9/16 of the screen, so items past the cap were
clipped. A room server's Delete Contact was unreachable on an iPhone. They
now scroll, and the sheet can grow to the safe area.
#1074: a message with an @mention or contact card was built as rich text
whose plain segments were never linkified. Those segments now go through
LinkifySpan with the same options, style and confirm dialog as Linkify.
Three new widget tests fail without the fix and pass with it.
Epic #1078.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings:
- Held channel message frames are now keyed per radio: verifying the
unbounded-growth finding exposed that frames held from radio X would
have flushed into radio Y's conversations after a radio switch. A
parked radio's frames flush when that radio next completes a sync. No
cap is added: held frames are popped messages, dropping one is
guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame).
- _loadChannelMessages merges messages that arrived during the disk
read instead of clobbering the list, so the slot-identity reload
cannot hide a message the held-frame flush just filed (disk was never
at risk: saves merge by identity, #343).
- sendChannelMessage refuses the write for a slot this connection has
not confirmed (the persisted copy would fall back to the legacy
slot-index key and be orphaned), and the chat screen surfaces a
persistent error at every send path instead of greying anything.
New English UI string; untranslated.json regenerated (#1063).
Three integration tests cover the fixes (#1064); with the guards
behaviorally disabled exactly those three fail (+3 -3). Full suite
1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063):
the #1060 bug-validation scenario (wipe + re-import with shuffled slots:
list matches device, no misfiled or mis-rendered message, notify modes
evaluate against the right PSKs), a manual device-side reorder (history
follows the channel identity to its new slot), and a flaky sync
mid-reconnect (held traffic files only after a confirming sync).
Writing them exposed one gap: _flushHeldChannelFrames documented that a
frame for a still-unconfirmed slot re-holds, but the handler only gated
on the list-level barrier, so after a partial sync such a message would
file by raw index against an unknown identity and persist under the
legacy index key. _handleIncomingChannelMessage now re-holds it until a
sync confirms the slot.
Test plumbing: channelNotifyProbeForTest observes the notify decision
(slot, label, PSK, mode) without reaching the NotificationService
singleton; wireMessageStoreForTest wires the message store the way the
SELF_INFO handshake does.
Issue: #1064
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persisted channel history has been PSK-keyed since #194; the remnants
that could still cross-wire on a slot reassignment were in-memory and
legacy. Epic B of #1061, stacked on #1062:
- _completeChannelSync's identity diff now drops the slot's in-memory
conversation and reloads it from the store, which reads the NEW
identity's own history through the (now fail-closed) resolver.
- setChannel's reassignment clear purges only the LEGACY slot-index
keys (new clearLegacySlotHistory), so the outgoing occupant's
PSK-keyed history survives and comes back if the channel is re-added.
True deletes (deleteChannel) keep the full both-key clear.
- A WRITE that falls back to the slot-index key now logs an error with
the orphaning consequence spelled out; post-#1062 live ingress cannot
reach it.
- The #193-era comment claiming history is slot-keyed is corrected and
the setChannel/resolver interplay documented.
Tests: 2 connector tests (in-memory drop on identity change, kept on
same identity) + 5 store tests (legacy purge, PSK history survival,
no adoption onto the new occupant, true delete).
Issue: #1063
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Epic A of #1061, fixing the #1060 class: the client's slot-to-channel map could silently diverge from the device and misroute notifications and conversations.
- A slot the device never answers for is UNKNOWN: tracked, surfaced via a persistent channels-screen banner with Retry, and never backfilled from the previous list. Lookups for it fail closed, including notification evaluation, which now suppresses instead of defaulting to noisy.
- Ingress barrier: live channel traffic holds until this connection's list is confirmed, then flushes. Pull-based message frames are never dropped (the radio already popped them); lossy raw sniffs stay bounded. Raw-RX no longer matches the offline cache.
- Per-slot unread counts survive a sync only when the slot keeps the same PSK; identity changes are listed in a dismissible notice and logged.
- A sync with unconfirmed slots does not overwrite the last fully-confirmed offline cache; a sync that fails on a live connection retries itself.
New strings English-only per the #1021-era ruling; regenerated l10n + untranslated.json committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Epic A of #544, plus the B1 contract it needed to fail behaviourally rather
than as a compile error.
Owner directive: app and radio notifications are separate controls, separate
notifications, independent settings. So this adds a RADIO-side per-channel
mode and touches the app's per-channel setting not at all.
- offband_channel_notify.dart: 0x18 GET / 0x19 SET, RadioChannelNotify with
the 1-based encoding (0 unset, 1 all, 2 mentions, 3 off), frame builders,
and a parser that DROPS an unknown mode byte rather than guessing
- offbandCap2ChannelNotify = 0x80, the last bit in caps byte 2
- parseOffbandCaps3 at device-info offset 87, length-gated, never
version-gated (#1065). 87 and not 85: 85 is ui_led_enabled and 86 is
ui_display_mode, so reading 85 would parse the LED state as a caps mask
- connector: per-radio slot-to-mode state cleared in _clearDeviceUiState,
request/set shaped on requestChannelTune/setChannelTune so state follows
the device's re-read reply and never the request, reply handler, and
DeviceUiSurface.channelNotify for refusals
15 tests across three groups. The A2 independence group is the load-bearing
one: changing the app mode emits no radio frame, a radio reply saying Off
does not mute the phone, and the app setting still works on a radio with no
capability at all. That is what stops the two being quietly re-merged later.
Proved the checks can fail: flipping the caps-3 offset to 85 fails three
tests, including the one asserting the LED and display bytes are not
mistaken for byte 3. Reverted.
The radio control itself is Epic C; this pane explicitly does not own a
channelNotify refusal, recorded at the switch rather than left implicit.
Blocked downstream, recorded on #1069: B3's read-on-connect should hang off
#1061's confirmed-list barrier, and the slot-to-mode cache belongs in
#1061's PSK identity-diff reset set.
flutter analyze clean; full suite 1804 passed.
Owner chose Option C on 2026-11-03: keep companionRadioStats >=8 and
regionScope >=13, and file the removal as its own issue. That is #1066.
Comment-only. Records at all three sites that these two are KEPT, not
overlooked, and why they could not be converted:
- both gate STOCK companion-protocol features, and a stock reply is exactly
82 bytes, so it carries no capability byte at all; there is no bit to read
on a stock radio at any version and the fork cannot make upstream
advertise one
- the population classifier cannot substitute: stock 1.16 (no region) and
1.17 (region) both land in stockLegacy
- dropping them is not behaviour-neutral: stats would read true off a
malformed 3-byte frame, region scope would emit frames to a pre-1.17 radio
that ignores them, both pinned by existing tests
Also records WHY they are safe for now and when they stop being safe: 8 and
13 sit below the conflated 14-22 range, but a future renumbering through 13
switches region scope off silently on hardware that has it, which is the
#1065 failure again.
The note tells the next reader not to quietly delete them either, because
#1066 needs a decision (firmware caps bits making both features fork-only,
or probing instead of predicting), not a patch.
Full suite: 1739 passed.
Owner reaffirmed: do not depend on FIRMWARE_VER_CODE, depend on caps bits.
The 0xC0 config gate ANDed OFFBAND_CONFIG_MIN_VER (14) on top of the
OFFBAND_CAP_WIFI_OBSERVER bit, which left it passing at zero margin once the
code rolled back onto 14. Removed; supportsConfig now takes only offbandCaps.
The bit is the sounder signal: caps byte 1 exists only on fork replies, which
are the only replies that carry 0xC0 at all, and a build without the observer
backend compiled in leaves the bit clear whatever version it reports.
minVerCode is deleted rather than left unused, so it cannot be reintroduced
into a gate. Two call sites and four tests updated.
Four gates now depend on caps bits alone: block, caplog, packet hash,
observer config. Those are all of the readers in the conflated 14-22 range.
Full suite: 1739 passed.
The block list, caplog and packet-hash gates each checked the correct caps
bit and then ANDed a fork contract number on top (>=15, >=17, >=22). With
the code rolling back to 14 to resolve the 1.18.0 collision, all three
returned false on firmware that genuinely has the feature, while the
capability bit advertised support the whole time. Silent capability loss.
Owner ruling (AM 1401): no dependency rides on FIRMWARE_VER_CODE.
The floors were never what protected stock radios, which is why removing
them is safe:
- the connector parses both caps bytes ONLY when the population classifier
allows fork frames, and nulls them otherwise (meshcore_connector.dart
:7924-7938), so a stock reply yields null caps and every caps-anchored
gate is false regardless of any floor
- the classifier keys on the device-info frame SHAPE plus a dual-form
version string, never on the ver code (device_population.dart:107-129),
so a fork device at 14 still classifies as offband and works
- a caps bit is self-dating anyway: firmware predating a feature cannot set
that feature's bit, and caps byte 2 only exists from the version that
added offset 84
Drops the now-unused parameter from all three signatures rather than
ignoring it, and rewrites the POPULATION INVARIANT note to say no
caps-anchored gate may read the ver code.
Left alone deliberately: supportsCompanionRadioStats >=8 and
firmwareSupportsRegionScope >=13 have NO caps bit to fall back on and are
pre-split codes meaning the same thing upstream and in the fork; observer
config >=14 mirrors firmware's OFFBAND_CONFIG_MIN_VER and is the one floor
doing real work. Removing those would be a behaviour change, not a fix.
Tests: the three gates asserted capable at ver 14 (4 assertions failed
before this change), the bit still the whole gate, and the classifier
proven to carry the stock protection on its own. A structural signature
assertion was tried and removed: an optional positional parameter still
satisfies the function type, so it passed while a deliberately
reintroduced >=22 floor was in place. The behavioural assertions were
verified to fail against that floor.
Also adds the connector-level stock case at ver 14 to #877's test, which
previously only covered a hypothetical stock device at 22.
Full suite: 1738 passed.
iOS has no system Back. The chat had a hamburger in place of the back arrow, PopScope(canPop: false) switched off the edge swipe, and the keyboard covered the bottom bar with nothing to dismiss it.
Add a back arrow to the channel and the line-of-sight map, keep the edge swipe on pushed screens on iOS, and dismiss the keyboard by tapping the channel's messages or dragging either chat's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebrand wrote Offband Meshcore, against the owner's capitalization. Launcher label, title, strings, notifications, window titles, web, release titles and docs are corrected. ProductName in Runner.rc deliberately keeps the old spelling: path_provider builds the Windows data folder from it, so changing it would orphan every Windows user's data. Bundle IDs, past release texts and the certificate subject are unchanged.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision for the first App Store submission. Guideline 2.5.19 bars hidden features, and a personal sponsorship link is an unclear fit. With no unlock on iOS, CoreScope is unreachable there, so the privacy label becomes Data Not Collected, and the notes no longer need the disclosure. Other platforms are unchanged.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Store guideline 2.3.10 bars naming other marketplaces in an app. Other platforms keep the link.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android 11 and below the BLE plugin refuses to scan while system Location is off. The Scan button caught that error and only logged it, so the user saw nothing; one tapped Scan 172 times. A failed scan now shows a persistent message: a specific one when Location is off, a plain generic one for anything else, never the raw exception. The detail still goes to the log, and Scan stays pressable.
New owner-approved strings scanner_scanFailedLocationOff and scanner_scanFailedGeneric; other locales fall back to English via untranslated.json.
Tests: each message present on its failure, absent on success, still shown after 10 s, and Scan retries after a failure. Each check was made to fail first against a broken build (persist false; log-only; misclassification).
Plan by EmeraldBrook on #1033.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route into the app on return from settings only when the scanner is the current route again, the same condition the connection listener uses. A popping route is outside the navigator's present set, so the settings route does not block it.
The share tests now wait frame by frame on their own condition instead of a fixed 2-second window, and the persistence checks were re-proven to fail with persist: false.
Correct a stale comment: the Experimental unlock is on the Device Info Build row, not About.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A radio that will not connect is exactly when a user needs the log, and
the only way to it ran through device Settings, which needs a connected
radio. The scanner now has a settings icon that opens an app-only
settings screen: App Settings, Debug (Share logs, the logging switch,
both log viewers) and About.
Navigation only. How logs are written is untouched: Share logs calls the
same LogExport.shareLogs the log screens use, on the same on-disk file.
The device Settings screen keeps every category. Its About pane and the
app-level Debug rows moved into AboutView and DebugLogTiles, which both
screens now share; Device CLI, Serial capture and Mesh topology need the
radio and stay in device Settings only.
Export failures now persist until dismissed and are logged: "No log
available to export" was a 4-second snackbar, and a share or save that
threw had no handler at all. New owner-approved string:
debugLog_exportFailed. Other locales fall back to English per
untranslated.json.
Tests pin both sides with the radio disconnected: Share logs hands the
on-disk file to the share sheet, About shows the version, no device
category or radio row appears, both error messages persist, and device
Settings still has everything. Each check was made to fail against a
deliberately broken build first.
Part of Epic #693.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The companion "CLI console" entry was gated only on the target being a
companion. A pre-1.18 radio, including Offband before the absorb, refuses a
type-3 send: companion-v1.17.1 CMD_SEND_TXT_MSG accepts only plain and
CLI_DATA and answers anything else with ERR_CODE_UNSUPPORTED_CMD
(companion-v1.18.0 adds CLI_COMMAND). The connector only logs error
frames, so the command waited out its mesh timeout and the console showed
the #891 row, which blames the target for a limit of the user's own radio.
- Gate: DeviceCliScreen.offersRemoteConsole requires a companion target
AND a connected radio that speaks the 1.18 CLI (allowsCliFrame, the same
check that gates the local Device CLI; type-3 send and frame 66 shipped
together in 1.18). Disconnected or unclassified offers nothing. The chat
menu uses it.
- Fail fast: RemoteCliTransport fails the command at once with
CliConsoleUnsupported when the radio refuses the send. The window arms
only when the text frame is written (the onAttempt hook, after the path
update) and closes on the command's SENT, which on 1.18 carries an
expected-ack of 0. So an unrelated unknown-command refusal before the
write, or a DM's SENT, cannot be pinned on this command. Dispose
releases a command still waiting.
- Console: a refused send shows "<command>: not sent. Your radio's
firmware doesn't support remote CLI." (owner-approved), not the timeout
row.
Tests: the gate (positive and negative across populations, roles, and
disconnected); fail-fast on the refusal, ignoring other codes, errors
after the radio accepted the send, a DM's SENT, an error before the write,
and dispose; the console row.
Reviewed by a one-off Sonnet adversarial pass (Gemini budget down). Its
Medium finding, that the window armed at run() before the write, is fixed
here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A companion that has not granted us remote CLI queues our command as a
plain message and never answers, so from the console's side the silence
is as likely a permission answer as a lost packet. The console showed the
same bare "no reply in N s" row it uses for the local device, which reads
as "the radio is down".
The remote console now shows: "<command>: no reply in N s. This radio may
not allow remote CLI from you, or is unreachable. Not retried." The local
console keeps its plain frame-timeout row. The send itself was already a
single attempt (#999), so "Not retried" states what happened.
- device_cli_screen: the timeout row picks the remote string when the
console targets a contact. A transportOverride test seam lets a test
drive a deterministic timeout; the screen owns and disposes it.
- l10n: deviceCli_remoteNoReply (owner-approved wording).
Tests: the remote timeout renders the full interpolated string; the local
timeout renders its exact string and never the remote permission hint.
Reviewed by a one-off Sonnet adversarial pass; its test gaps are fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend the one CLI console (device_cli_screen, F2 #883/#884) to target a
permitted companion over the mesh (TXT type 3, #888/#889), reusing the
console rather than adding a screen or a chat-borne command path. Opened
from a "CLI console" action on a companion contact; command and reply
both live in the console.
- cli_console_transport.dart: CliConsoleTransport with a local and a
remote implementation, and one CliConsoleTimeout the console catches.
The remote transport sends with retries: 1 (no auto-retry - an
unpermitted companion queues the command and never replies, so a retry
only spams the mesh) and correlates the reply via the receivedFrames
listener, gated to CLI_DATA so a chat message or inbound command from
the same contact can't complete it (Sonnet review).
- device_cli_screen: optional remoteContact; "Remote CLI" + contact name.
- chat_screen: companion-only "CLI console" entry (Contact.isCompanion).
Also corrects #890: CLI and chat are separate surfaces. The receive
pipeline filed every contact frame into the conversation regardless of
TXT type, so a companion's CLI reply (type 1) and an inbound command
(type 3) leaked into the chat thread. A guard now drops CLI-typed
messages from the message pipeline - the console is their only surface -
and #890's in-chat command bubble is removed.
Review (Sonnet, one-off, Gemini budget down): redact secret reply text
in the remote path's late/unmatched log, matching LocalCliService;
dispose cancels the subscription; document that remote truncation is not
detectable post-reassembly.
Tests: transport round-trip / sender-filter / timeout / no-retry /
CLI_DATA gate; Contact.isCompanion; remote console title; and the
pipeline keeping CLI (type 1 and 3) out of the conversation while a plain
message still enters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upstream 1.18 lets a permitted contact send a remote-CLI command (TXT
type 3) to run on our radio. Before this, _parseContactMessage only knew
plain (0) and CLI_DATA (1), so a type-3 frame was warn-dropped as an
unknown type and never surfaced.
- meshcore_connector: accept txtTypeCliCommand in _parseContactMessage,
skip SMAZ decode for it (like CLI_DATA), and flag the Message
isCliCommand. Add @visibleForTesting parseContactMessageForTest.
- message model: new isCliCommand field threaded through the
constructor and copyWith.
- message_store: persist and read isCliCommand; skip SMAZ on read for it.
- chat_screen: label an inbound command bubble "Command for your radio"
and exclude it from the incoming-translate action.
- l10n: chat_remoteCliCommand.
test/connector/inbound_cli_command_test.dart: type-3 accepted+flagged,
type-1 isCli-not-command, plain-neither.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1 target split. The command service now picks the message type by
the target contact's role: a companion (chat) gets TXT_TYPE_CLI_COMMAND
(type 3), a repeater/room server keeps the universal CLI_DATA (type 1)
it has always used. One line in the send path; the reply handling,
correlation and timeout model (which already carries the 600 ms
companion reply hold) are unchanged and serve both.
Companion replies come back as CLI_DATA (verified: MyMesh.cpp companion),
so the existing contact-agnostic reply listener routes them to
handleResponse unchanged; a UI entry point to open the CLI console for a
companion is the remaining downstream piece.
Tests: a real send proves companion -> type 3 and repeater -> type 1,
driven deterministically off the send frame. Full suite green (1432);
Gemini review: test race fixed, the reply-path concern refuted against
the source-verified CLI_DATA reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. When sending a CLI command to a remote peer, the message type
is chosen by the contact's ROLE (owner ruling): a companion (chat) gets
the upstream-1.18 command type (3); a repeater or room server keeps the
universal CLI_DATA type (1) it has always used. Decided by role, not
firmware version: a Contact carries no version, the user types the
commands (passthrough), and a non-1.18 companion silently drops the
command type (surfaced as no reply, #891).
Adds txtTypeCliCommand (3) and cliTxtTypeForContactType(), and
parameterizes buildSendCliCommandFrame with the txt type (default
CLI_DATA, so the existing repeater path stays byte-identical). A later
refinement could pin a peer's version via a `ver` probe on connect.
Tests: the role mapping and the frame's type byte + command passthrough.
Full suite green (1430); Gemini 2.5-pro review: one naming nit taken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. Upstream 1.18 gates remote CLI (TXT type 3) on contact flag
0x10 (isRemoteCLIAllowed): the receiving radio checks it, so setting it
on a contact grants that contact permission to run CLI on this radio.
Adds the constant, Contact.remoteCliAllowed, a remoteCli param to
setContactFlags (same set/clear pattern as the telemetry flags, reading
a fresh device snapshot first), and an opt-in SwitchListTile in the
shared contact settings dialog (default off). Written with the existing
add/update-contact frame; the flags byte already rides it.
Tests: the 0x10 bit on the write-path frame (set/clear/coexist) and the
getter. Full suite green (1424); Gemini 2.5-pro review: no defects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner test 2026-10-26: Stop, then a BLE connect to another radio 6 s
later. The Stop disconnect was still releasing the T1000-E; every
connect path only refused connecting and connected, so the new connect
started during disconnecting. The disconnect's tail then cleared the
new link's device, name and state: the status stuck, and the frame
size stayed at 20 bytes with MTU 176, which left the channel composer
unusable.
- disconnect() tracks the one in progress; a connect (BLE, USB, TCP)
waits for it, and a second disconnect joins it. A manual caller
joining an automatic disconnect still suppresses the retry.
- A superseded attempt logs its own radio's name, not whatever name the
newer connect has written by then.
Watched failing first: the TCP connect started before "Disconnect
complete". Full suite 1720 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-25 the client retried an unreachable T1000-E about every
14 s for 7.7 hours, and no other connect could start meanwhile (#995).
- connect() reset the attempt counter at the start of every automatic
attempt, so the backoff never grew and nothing gave up. A
ReconnectPolicy now owns the count: 5 attempts or 3 minutes, and only
a user connect, a manual disconnect or a successful link resets it.
- A connect the user starts, BLE, USB or TCP, abandons an automatic
attempt in flight instead of being refused. A generation check after
each await in the BLE connect stops an abandoned attempt before it
writes shared state.
- Refusals are logged and shown, never silent (the USB screen dropped
taps without a trace).
- The scanner status bar shows the attempt with a Stop button, and
"Couldn't reach" with Retry once the retry gives up.
Tests watched failing first: the attempt count (1, expected 2), the
give-up, the TCP refusal, the silent BLE refusal, and the three screen
cases. Full suite 1719 pass, analyze clean.
Epic #996, design on #995.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three defects turned a 13-hop 3-byte-hash message into a 39-hop badge:
- log-RX ingress stored the width-stripped hop count; now stores the raw packed path-len byte, the same encoding fromFrame keeps
- the repeat merge substituted the path BYTE length into the packed field; mergePathLenRaw now compares decoded hop counts and never sees a byte length
- fromFrame read the V3 path_len field signed, so 3-byte-width values (>= 0x80) collapsed into the flood sentinel; now unsigned with 0xFF kept as the direct marker
Rendering now groups path hashes by the packet's own declared width (ChannelMessage.effectivePathHashWidth), with the radio's device-wide mode only as the legacy fallback, so the path screen, chat via-line and route-reply all agree with the badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_customNames was only ever added to. Rename contact X on radio A, switch
to radio B which also holds X, and B showed A's label, breaking the
owner's decision that custom names are per radio.
- The hydrate clears the map before loading the connected radio's names.
- disconnect() clears it with the contact list, so no label outlives
the link it belongs to (Gemini finding).
T37 (switch) and T37b (disconnect) both failed with the previous radio's
"Chuck" before the fix and pass after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the pre-PR adversarial gate, which earned its keep: this is the worst instance of the exact leak the epic exists to prevent, and I had missed it.
toShareUri and toChannelShare both read the .name getter, so sharing a renamed contact put the user's private label in the payload. A share payload becomes the RECEIVED name on every device that imports it, so the label would land as another operator's ground truth and overwrite a signed advert name. Worse than sending it to our own radio, because it propagates outward instead of stopping at one device.
Both now send receivedName. The static build helpers keep their name parameter, since the device also shares its OWN name through them with no Contact involved.
KNOWN LIMIT now documented in the guard, because the near-miss matters more than the fix. T14 matches the pattern name-colon-identifier-dot-name, so it structurally CANNOT see an intra-class read, where the getter is reached as a bare name with an implicit this. That is precisely where this bug was living. Reverting the fix was confirmed to leave T14 green while T36 failed, so the limit is measured, not assumed. Widening the regex to catch a bare name is not possible without flooding on the legitimate parameter pass-through in the same file, where the static helpers forward a name argument correctly. So cross-class reads are guarded by T14, intra-class reads behaviourally by T36 and T11, and contact.dart joins the watched list for the cross-class case.
Three tests added. T36 falsified before being trusted.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1505 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase brought in Feature #853's scoping gate, which requires every storage key in lib/ to carry a per-radio or global verdict with a reason. contact_customname_ was undeclared, so the gate failed the build. That gate did its job: a new key cannot land unnoticed.
Declared per-radio, prefix + device key + contact key, matching its neighbour in the same store. The reason records why it is not in contact_store, since that is the decision most likely to be undone by someone later: contact_store is rebuilt from radio contact-sync data, so a save after a sync would wipe the rename.
Also renames the fixture arg in #940's radio_isolation_matrix_test, which the 750.1 compile break caught the same way #754's test was caught two rebases ago.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1502 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.4 of the contact-naming plan: the UI. Rename sits on the per-contact sheet, above the destructive Block tile, and the sheet shows what the node calls itself whenever a custom name is set.
The dialog shows the node's own name at the moment of the decision, and only while it differs from what is typed, so the user can see the name they are overriding without having to remember it and the same string never appears twice. It also says plainly that the rename is local and that adverts keep the node's own name up to date, so nobody has to guess what renaming costs them.
Empty and whitespace-only are normalized to a clear in the dialog as well as in the connector and the store. Three layers enforce it independently so none is load-bearing and the dialog never hands a blank downstream to be cleaned up.
A 32-character cap keeps tiles readable. Counted in characters, not bytes: a custom name never crosses the wire, so #636's grapheme truncation does not apply and a byte cap would mean 'shorter for CJK' for no reason.
Thirteen tests. T29 and T30 came from the pre-PR review and I had them wrong first: they accepted an empty string, which IS the defect the review found, so they would have passed against a broken save path. Tightened to assert null strictly, then falsified: reverting the normalization makes both fail where before they passed.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1335 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.3 of the contact-naming plan. The custom name lives in contact_settings_store keyed by pubkey, not in contact_store, so the live Contact objects have to be reattached to it. Two paths drop it otherwise, and both fail silently.
Cache hydrate rebuilds every Contact from contact_store, which does not hold the custom name on purpose, so a restart would lose every rename the user made. Radio contact-sync replaces an existing Contact with one built from the device frame, where no custom name exists.
Adds a _customNames map as the in-memory mirror, _withCustomName to reattach, and _loadCustomNames to batch the load. Batched for the reason already documented on the cyr2lat loader: a notify per contact rebuilds the tree twice per contact and each rebuild walks the contact list.
Applied at five sites: cache hydrate and the four contact-sync rebuilds. Paths that copyWith from an EXISTING contact need nothing, because copyWith preserves customName unless clearCustomName is passed, which is why the advert handler from 750.1 already behaves.
setContactCustomName is the write path. Null, empty and whitespace all clear, matching Contact.name and the store, so a user who deletes the field's text cannot end up with a blank name and no way back.
Eleven tests. T35 was falsified before being trusted: dropping the hydrate mapping made it fail alone. T16 to T22 guard behaviour 750.1 already delivered and passed on arrival, so they are regression guards rather than watched failures, and the file says so.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1324 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.2 of the contact-naming plan: the radio boundary. The radio's contact table is the shared record of what a node calls itself, and firmware has a single char name[32] per contact with nowhere to put an override, so a private label must never reach it.
This was the hole the type system cannot see. After 750.1 renamed the field, every frame-builder call site still read .name and compiled, which meant they were silently sending the custom name. T11 demonstrated it live before the fix: the custom name was present in the emitted frame bytes.
Four connector sites and the stock config export now send receivedName. The export matters for a second reason: a stock config file is an interchange format other operators import, where a name reads as the node's own, so exporting one user's label would hand it to everyone who loads the file.
T14 is a source-reading guard, because nothing else can hold this. It fails if any watched file passes a Contact display name into a name field, and separately if the frame-builder call-site count changes. Its allowlist is keyed by path, not by line content, so an exception in one file cannot excuse the same line in another.
Correction to the plan's own count: analyze caught that stock_config_import_service reads a StockContact parsed from an imported file, not our Contact, so that site was never wrong. The real number of Contact-typed write sites is eight, not the nine recorded on #750. The reason is in the code and in the guard's allowlist.
Eight tests. T11, T33 and T14 were each falsified before being trusted: reverting the export line alone failed both T33 assertions and the source guard independently.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1313 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A long-lived session drifted hundreds of contacts above radio truth (#838):
the client promoted every overheard advert into the persisted contact list
using only the radio's auto-add type policy, applying neither the radio's hop
limit nor its capacity, and ignoring its eviction/full pushes. The firmware
fires NEW_ADVERT (0x8A) and the raw RX firehose (0x88) even for adverts it
rejects, so those became phantom contacts the next manual sync had to prune.
Option A, sync is truth: the persisted contact list is populated only by the
contact sync stream. Both live-advert paths now feed discovery only; a new
advert whose type the radio would auto-add nudges a debounced incremental
reconcile so a contact the radio actually stored still surfaces without a
manual sync, while one it rejected simply never returns in the stream. The
existing-contact path/GPS refresh and the reconnect full-sync prune are
unchanged. The reconcile debounce (and the sibling channels-changed debounce)
is cancelled on both disconnect paths so a timer scheduled against one radio
can't fire against the next (pre-PR Gemini review finding).
Epic #842. Build #850.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No preview is sent while a slot transfer runs, so neither can take the other's refusal. A final SLOT_SET reply that is not 0 is no longer read as a commit. Back during a read or save now says why it is held. Comments now cite meshcore-firmware f3d30a88 on firmware-base, which merged 3e788e2a with the contract files unchanged. The wider attribution fix is #991.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The badge's player reads d=, o=, b= positionally, in that order, so RTTTL from elsewhere that lists them otherwise (owner's Take On Me: o=5,d=8,b=160,b=160) is refused as a stray colon, and firmware's 21-column words do not say why. When the header is the only fault, the editor now says the badge reads d=, o=, b= in that order, each once, and offers Fix order. The fix refuses to pick between two different values for one key, and must itself pass rtttlProblem, so it never hides a second problem.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>