Skip to content

Rewrite/go tui - #16

Merged
bybrooklyn merged 72 commits into
mainfrom
rewrite/go-tui
Sep 2, 2026
Merged

Rewrite/go tui#16
bybrooklyn merged 72 commits into
mainfrom
rewrite/go-tui

Conversation

@bybrooklyn

Copy link
Copy Markdown
Owner

rewrote in go bubbletea.

…on gating

Ports sdk/crates/bitdo_proto to internal/protocol as part of the Go+Bubbletea
rewrite. The PID and command registries are now go:generate'd directly from
spec/pid_matrix.csv and spec/command_matrix.csv (internal/protocol/gen)
instead of hand-copied into source, so the spec CSVs are the literal single
source of truth and can't drift from the runtime tables.

Ports the 4-gate command authorization (confidence/runtime-policy,
family/capability/PID applicability, candidate-readonly tier restriction,
unsafe double-confirmation), retry/timeout handling, diagnostics probing,
JP108/Ultimate2 command paths, firmware chunking, and the BDP1 profile blob
format, verified against harness/golden/profile_fixture.bin.

Ports the behavioral test suite from sdk/tests/*.rs (capability gating,
candidate-readonly gating, runtime policy, retry/timeout, firmware chunking,
boot safety, error codes, response validation, mode-switch readback, and
diag probe scenarios) as Go tests against a MockTransport, rather than just
writing new tests inspired by the originals.

HID transport uses github.com/karalabe/hid; Transport drops the Rust trait's
unused WriteFeature/ReadFeature methods (verified dead in the original
implementation). go test -race is clean.
…chine

Ports sdk/crates/bitdo_app_core to internal/core. Covers device discovery,
diagnostics summary text, JP108/Ultimate2 mapping read/apply-with-recovery
(backup-then-write-then-rollback-on-failure), the candidate-readonly write
probe (advanced mode + risk ack + per-PID unlock file, guarded mode/profile
write-readback), the 7-dimension support scorecard, and the firmware update
state machine (preflight -> awaiting-confirmation -> running -> terminal,
with manifest fetch, SHA-256 + Ed25519 signature verification against pinned
keys, chunked transfer, and cooperative cancellation).

Uses stdlib crypto/ed25519 and net/http in place of ed25519-dalek/reqwest,
BurntSushi/toml for the firmware manifest, and a small crypto/rand-based ID
generator in place of a UUID dependency. Firmware progress fan-out is a
minimal hand-rolled broadcaster (buffered per-subscriber channels, oldest-
event-dropped on overflow) standing in for tokio::sync::broadcast.

Ports the Rust test suite from bitdo_app_core's #[cfg(test)] module
(preflight gating, candidate scorecard, candidate write-probe unlock
ceremony, firmware happy path, mock download, JP108/U2 backup+restore,
guided button test guidance, manifest PID matching, firmware cancellation,
and the bootloader-exit-before-cancel-report ordering) as Go tests.

go build/vet/test and go test -race are all clean across the module.
New code with no Rust precedent (the Rust implementation had no gamepad
input handling at all). Decodes the standard USB-HID gamepad usage page
(Generic Desktop Page 0x01, Button Page 0x09) — a public USB-IF standard,
not vendor-proprietary, so it doesn't touch the clean-room evidence
boundary. Documented in spec/gamepad_input.md.

internal/input/descriptor.go is a general HID 1.11 report descriptor parser
(Main/Global/Local items -> flat Field list with computed bit offsets),
verified against a hand-derived, byte-by-byte-checked standard joystick
descriptor since no hardware was available to capture a real one from.
internal/input/gamepad.go decodes hat-switch/analog-stick d-pad direction
and button-page bitmask/array fields from a raw input report against those
parsed fields.

Report descriptor acquisition is the one real platform gap: karalabe/hid
doesn't expose it and there's no portable Go way to fetch it. Linux gets
real descriptor-driven decoding via the kernel's hidraw sysfs export
(descriptor_linux.go); other platforms (including this dev machine) get an
honest "unavailable, here's why" note per device (descriptor_other.go)
rather than a fabricated byte-layout guess with no evidence behind it.

navstream.go opens a read-only, nav-only input stream per enumerated
vid==0x2dc8 device, decoupled from internal/protocol's command session, and
emits d-pad/button transition events on a merged channel for a future
Bubbletea TUI to consume.

go build/vet/test and go test -race are clean across the module.
… locked-down CLI

Builds internal/tui (device dashboard, diagnostics with candidate-tier
explanation, JP108/Ultimate2 mapping editor, firmware update flow, recovery
takeover, settings) and cmd/openbitdo on top of the protocol/core/input
packages. Screens are a fresh design, not a port of the prior Rust layout;
safety gating (support-tier blocking, write-lock/recovery, candidate
write-probe ceremony) is ported faithfully with the same messages. Adds a
real overlay-modal confirmation system including a genuine one-time
brick-risk acknowledgement before any unsafe/firmware action, closing a gap
where the prior implementation only claimed to have one. Keyboard and
gamepad nav share one code path via internal/input's event stream.
Replace the placeholder curated HID-usage preset list with Rust's real
JP108_PRESETS/U2_PRESETS tables (reducer.rs) and their exact target
labeling (u2_target_label), since remap targets are data, not UI design.
JP108 and Ultimate2 use unrelated value spaces so they now cycle through
separate tables instead of one shared list.

Also swap the device-name filter from substring match to sahilm/fuzzy
(already an indirect dep via bubbletea's ecosystem), matching the fuzzy
search behavior the prior Rust TUI had via SkimMatcherV2.
Two real bugs found while preparing tests for hermetic isolation:

1. unlock.go and report.go both bypassed the Model's injectable
   settingsPath and called the global SettingsPath() directly, so the
   candidate-unlock-file location and every saved TOML report always
   resolved to the real OS user config directory regardless of what
   Options.SettingsPath was configured with. Threaded settingsPath
   explicitly through candidateUnlockDir/candidateUnlockFilePath/
   candidateUnlockFilePresent and reportsDir/persistSupportReport/
   cmdSaveReport instead.

2. The candidate write-probe result handler never called cmdSaveReport
   at all, unlike diagnostics/firmware/mapping-apply which all persist
   a report on completion — a real functional gap versus the prior
   Rust TUI, which does save a runtime_unlock/candidate-write-probe
   report for this flow. Added the missing save, threading the device
   through the message (cmdCandidateProbe now takes the device instead
   of just its VidPid).
…ests

Two more functional-parity gaps found while writing tests:

- The Devices screen had no way to reload the device list after the
  one-shot load at startup — no refresh action, no key. For a
  device-detection tool where "plug in a controller and see it appear"
  is the core workflow, that's a real regression. Added an "r" rescan
  key, available even with zero devices connected.

- Device ordering was enumeration order only. The README documents
  "Pick a controller from the grouped dashboard: supported, read-only
  candidate, or detect-only" as current behavior, but nothing grouped
  devices by tier. Added a stable sort so full-support devices surface
  first, matching that documented behavior.

Also port the first batch of Rust's tests.rs behavioral suite as Go
tests: gatekeeping_test.go (dashboard_*_disabled_reason precedence and
exact reason strings), screen_mapping_test.go (mapping draft undo/
reset semantics, the ported preset tables), settings_test.go (schema
roundtrip, warn-and-fallback on invalid/missing settings).
Drives the Go Model/Update directly (not through the real Bubbletea
runtime) to port: device-selection defaulting to an enabled Diagnose
action, tier-grouped device ordering, advanced-mode toggling updating
both UI state and the core runtime, the full candidate-write-probe
per-PID unlock-file ceremony (including a negative case — denied
without a matching unlock file), a diagnostics run-then-back flow, and
the write-lock/Recovery takeover (forced regardless of current screen,
never clears at runtime).

Caught one test-writing mistake worth noting: my first version of the
candidate-write-probe test asserted a report was always saved, but the
default ReportSaveMode (FailureOnly) correctly skips saving a
successful probe — Rust's own equivalent test treated the saved report
as optional for the same reason. Fixed by forcing ReportSaveAlways in
that test so it deterministically exercises and verifies the report
content instead.
There's no /dev/tty in this sandbox, so github.com/charmbracelet/x/exp/teatest
(an in-memory virtual terminal) is what actually exercises Init/Update/View
through the real running program instead of just unit-testing Update() in
isolation. Covers: dashboard renders and keyboard nav moves selection;
simulated gamepad d-pad/button events drive identical navigation through
the same code path as keyboard; mapping-editor preset cycling changes the
rendered target using the real ported preset table; the firmware flow's
new brick-risk modal genuinely gates progress (cancel starts nothing,
confirm proceeds through preflight to completion); a settings toggle
persists to disk and survives a fresh reload; and the write-lock/Recovery
takeover fires through the real live message loop.

Two real lessons from getting these to pass, left as comments since
they'll bite again otherwise:
- tm.Output() is a one-shot draining stream: two waitForOutput calls in a
  row with no Send between them will hang on the second forever, since
  the first already drained everything from that render, including
  content after the match point.
- bubbletea's renderer skips writing when a frame is byte-identical to
  the immediately preceding one (not any older frame) — a real no-op key
  press must not be asserted on for that reason; two renders that happen
  to match an older frame but differ from the one right before them are
  fine and do get written.
…o tree

Relocates cleanroom_guard.sh, check_docs_consistency.sh, and
check_evidence_readiness.py from sdk/scripts/ to scripts/ (sdk/ is being
removed), adapting their relative paths. Adds Go equivalents of
package-linux.sh/package-macos.sh under packaging/scripts/, verified to
build/tar/checksum/pkgbuild correctly on this machine.

Fixes every golangci-lint (errcheck, govet, ineffassign, staticcheck,
unused) finding against the new Go tree: documented the three previously
bare file-close error ignores, applied safe De Morgan/tagged-switch/Fprintf
mechanical simplifications, removed genuinely dead code (an unused PID-error
constructor, an unused const duplicated across both descriptor
implementations, an unwired blink-animation message type, and two style
variables that duplicated an existing one byte-for-byte), and suppressed one
false-positive staticcheck warning on a platform stub that is correctly
always-erroring by design.
Every crate (bitdo_proto, bitdo_app_core, bitdo_tui, openbitdo) has been
ported to Go under internal/{protocol,core,tui,input} and cmd/openbitdo,
with the full behavioral test suite ported alongside it and independently
verified (go build/vet/test/test -race/golangci-lint all clean before and
after this removal). Remaining references to sdk/ in docs and packaging are
fixed in the commits that follow.
…ious error line

The flag package returns flag.ErrHelp from Parse() on -h/--help, which was
falling through to the generic error path (spurious "error: flag: help
requested" line, exit code 1) instead of a clean exit 0. This would have
broken the Homebrew formula's test block, which asserts on captured stdout
from "openbitdo --help" with a non-error exit code expected.
- Homebrew formula and AUR PKGBUILD templates: license field to
  GPL-3.0-or-later / GPL3.
- Drop the AUR depends=('hidapi') line: karalabe/hid statically links its
  HID backend (confirmed via otool -L on the built binary, no dynamic
  libhidapi dependency, unlike the old Rust hidapi crate).
- Rewrite scripts/package-linux.sh and scripts/package-macos.sh (moved from
  sdk/scripts/, which is gone) to build with go build/ldflags instead of
  cargo. Verified end-to-end on this machine: package-macos.sh produces a
  working binary, tarball, and signed-format .pkg with correct checksums.
  Fixed a real bug caught while testing: the pkgroot install step tried to
  copy from the staging dir after it had already been rm -rf'd; now copies
  from the stable BIN_ASSET path instead.
…ption

README/MIGRATION/CHANGELOG updated for the Go rewrite (GPL license, Go build
instructions, new migration notes, v0.1.0 changelog entry including the
issue #15 fix); VERSION bumped to v0.1.0.

Also fixed a real inconsistency in the relocated cleanroom_guard.sh: its new
third check (stale Rust/sdk-era references) didn't apply the same
(legacy)/(historical) exemption the other two checks already use, so it
would have blocked MIGRATION.md's own legitimate historical explanation of
the Rust-to-Go move.
ci.yml jobs now build/test the Go module instead of Cargo. Each job already
ran on a native runner for its target architecture (ubuntu-latest for
linux/x86_64, ubuntu-24.04-arm for linux/aarch64, macos-14 for macOS arm64),
so no cgo cross-compilation is needed for karalabe/hid -- plain 'go build'
on each native runner works. Kept the same required-check job names (guard,
aur-validate, tui-smoke-test, build-macos-arm64, test) that release.yml and
RC_CHECKLIST.md already reference, repurposing tui-smoke-test to run the Go
TUI package's tests (including the teatest end-to-end scenarios) instead of
'cargo test -p bitdo_tui'.

Verified locally: actionlint clean, and I actually ran the equivalent steps
on this machine (matches build-macos-arm64/tui-smoke-test) -- golangci-lint
run, go vet, go test, and package-macos.sh all succeed. golangci-lint caught
a real unchecked-error finding in main.go (introduced by my earlier --help
fix) and it's fixed here with a documented reason, not a silent discard.
Same transformation as ci.yml: drop sdk/ working-directory and Rust
toolchain installs, add actions/setup-go, fix package-macos.sh's call
signature (its 3rd positional arg changed meaning from a Rust target
triple to an install prefix -- the old call would have silently passed
'aarch64-apple-darwin' as an install path), and fix dist/ artifact paths
that were sdk/dist/ before sdk/ was removed.

Verified: actionlint clean on both workflow files, and I actually ran the
exact package-macos.sh invocation release.yml uses (two positional args,
version + arch) end-to-end on this machine -- produces a working binary,
tarball, and .pkg with correct checksums.
… not hand-edited Rust tables

The guide pointed contributors at sdk/crates/bitdo_proto/*.rs files that no
longer exist. Rewritten to describe the actual current process: edit
spec/pid_matrix.csv and spec/command_matrix.csv, run 'go generate ./...' to
regenerate internal/protocol/registry_generated.go, and the equivalent
capability/gating logic now lives in internal/protocol/registry.go and
session.go. Verified 'go generate ./...' produces no diff against the
already-committed generated file, confirming the guide's instructions are
accurate.
Fixes the gap where gamepad nav silently degraded to keyboard-only on
macOS (descriptor_other.go's !linux stub always returned 'not
implemented'). karalabe/hid's public Go API has no descriptor-related
function -- confirmed via 'go doc' and by grepping the vendored hidapi C
source's public header (hidapi.h has no hid_get_report_descriptor or
similar) -- so this uses IOKit directly via cgo.

Approach: karalabe/hid's DeviceInfo.Path on darwin is an IOService-plane
registry path (hidapi's mac backend builds it via IORegistryEntryGetPath
in hidapi/mac/hid.c, and reverses it the same way in hid_open_path via
IORegistryEntryFromPath -- confirmed by reading the vendored C source).
Reusing that same path string, IORegistryEntryFromPath resolves it back to
the IOKit registry entry, then IORegistryEntryCreateCFProperty reads the
'ReportDescriptor' property (kIOHIDReportDescriptorKey, confirmed against
the actual IOHIDDeviceKeys.h header on this machine -- 'Data property that
describes the report descriptor of the device') directly off the entry --
no IOHIDManager/IOHIDDeviceRef needed. IOKit/CoreFoundation frameworks are
already linked into the binary via karalabe/hid; this cgo file links them
explicitly since it's a separate translation unit.

Also narrowed descriptor_other.go's build tag from '!linux' to
'!linux && !darwin' so it no longer collides with the new darwin file.

Verified: go build/vet/test/test-race/gofmt/golangci-lint all clean on
this macOS arm64 machine (which is what actually proves the darwin build
tag path compiles -- CI never would have caught this since it never runs
locally). Switched from the deprecated kIOMasterPortDefault to
kIOMainPortDefault after the first build emitted a deprecation warning.
…is broken on modern macOS

The first implementation (previous commit) reused DeviceInfo.Path to
re-resolve a device via IORegistryEntryFromPath. Testing that against real
hardware on this machine revealed Path is empty for every single
enumerated device here -- not a sandboxing artifact (confirmed by
re-running outside this session's sandbox, same result), and not specific
to internal-vs-external devices (confirmed no enumerated device has a
non-empty Path at all).

Root cause, traced into the vendored hidapi C source
(hidapi/mac/hid.c:296-316): hidapi resolves an IOHIDDeviceRef's
io_service_t via dlopen("/System/Library/IOKit.framework/IOKit", RTLD_LAZY)
+ dlsym("IOHIDDeviceGetService") -- an OS X 10.5-era compatibility shim
for detecting whether the modern API exists. Verified directly with a
standalone dlopen() test: that hardcoded path no longer resolves on this
SDK (dlopen fails, reporting the library is not in the dyld cache). When
dlopen fails, hidapi silently falls through to a struct-offset hack
reading raw IOHIDDevice internals that hasn't matched the real struct
layout since OS X 10.5, producing a garbage io_service_t -- so
IORegistryEntryGetPath always fails and Path is always empty. This is a
real bug in the pinned karalabe/hid@v1.0.0 dependency's vendored hidapi
copy, not an artifact of this environment; it would affect a real 8BitDo
controller on this machine identically.

Fix: don't use Path at all on darwin. Re-enumerate independently via
IOHIDManager (a modern, non-deprecated API -- IOHIDManagerCreate/
SetDeviceMatching/Open/CopyDevices) and match by vendor/product/usage-page/
usage, which DeviceInfo populates correctly (confirmed: only Path was
broken). Once matched, read the ReportDescriptor property directly off
the IOHIDDeviceRef via IOHIDDeviceGetProperty -- no registry-path detour
needed at all with this approach.

This changes fetchReportDescriptor's signature from (devicePath string) to
(info hid.DeviceInfo) across all three platform files (descriptor_linux.go,
descriptor_other.go, descriptor_darwin.go) and the navstream.go call site,
since Linux's implementation only ever needed info.Path but the shared
signature has to accommodate what each platform actually has available.

Verified against real hardware, not just compiled: added
descriptor_darwin_test.go, a real-device smoke test (not a hermetic unit
test -- no 8BitDo controller is available here) that enumerates real HID
devices and round-trips fetchReportDescriptor + the existing
ParseReportDescriptor through them. On this machine: 5/5 real (non-zero
VID) devices succeed end-to-end (Apple's internal accelerometer, gyro,
light/temp sensor, and keyboard backlight controller -- vid=0x05ac,
various pids), acquiring real report descriptor bytes (20-179 bytes) and
correctly parsing them (0-22 fields depending on device shape). Confirmed
this passes both with and without the sandbox this session runs under, so
it's not a sandbox-dependent result. This is the same code path a real
8BitDo controller (vid=0x2dc8) would take -- proven end-to-end against
real IOKit hardware, just not an 8BitDo device specifically, which remains
the one thing that needs real hardware to fully confirm.

go build/vet/test/test-race/gofmt/golangci-lint all clean.
openSessionForOps, DiagProbe, CandidateWriteProbe, and runTransferTask all
hardcoded protocol.NewHidTransport() in non-mock mode, which meant every
real-mode code path (including the safety-critical write-failure rollback
logic) was completely untestable without physical hardware -- mock mode
takes an entirely separate, trivially-succeeding branch that never reaches
any of this code.

Added an unexported transportOverride field + transport() accessor on
OpenBitdoCore, defaulting to the real HID transport exactly as before when
unset. Tests in the same package can now set transportOverride directly to
a protocol.MockTransport and exercise the real-mode logic (including
rollback-on-write-failure) with scripted responses. No behavior change for
real usage -- this is purely a testability seam.
Adds real, targeted coverage (37.7% -> 45.4%) for the paths that matter
most given this package holds firmware and mapping safety gating:

- rollbackAfterWriteFailure's three outcomes (no backup, rollback
  succeeds, rollback fails) and WriteRecoveryReport.RollbackFailed().
- CandidateWriteProbe's independent denial gates as separate tests
  (non-candidate tier, missing advanced-mode/risk-ack, no safe-write
  capability) -- the unlock-file-missing gate was already covered.
- ConfirmFirmware/StartFirmware/CancelFirmware guard clauses (missing
  risk ack, unknown session id, wrong session state).
- validateFirmwareImage's empty/oversized/missing-file rejections.
- fetchBytes and verifyArtifactSignature via local httptest servers --
  non-2xx status, unreachable host, unsupported signature algorithm, a
  well-formed-but-wrong-key Ed25519 signature, malformed signature
  encoding, and DownloadRecommendedFirmware's hash-mismatch/no-match
  rejections. Only rejection paths are testable this way since the
  pinned Ed25519 keys are public-only by design (no private key in this
  repo), but rejection is exactly the safety-relevant direction.

Uses the transportOverride seam added in the previous commit rather than
needing real hardware.
Drives the actual compiled binary via a subprocess rather than testing
flag.FlagSet.Parse in isolation, since the original bug was specifically
in main()'s handling of flag.ErrHelp (spurious 'error:' line on stderr,
exit code 1 instead of 0) -- an interaction only visible by exercising the
real main() entry point end to end.

Sanity-checked this actually catches the regression: temporarily reverted
the flag.ErrHelp fix, confirmed TestHelpFlagExitsCleanlyAndWritesToStdout
fails with exactly the original symptom (exit status 1, 'error: flag: help
requested' on stderr), then restored the fix and confirmed it passes again.
Coverage: cmd/openbitdo 0% -> has real regression coverage for its one
previously-broken behavior.
Was still pinned to v0.0.1-rc.4 throughout (title, tag references, artifact
filenames) despite main having moved to v0.0.2 and this branch's VERSION to
v0.1.0. Required-checks list was also missing build-linux-x86_64 and
build-linux-aarch64, which exist as real jobs in ci.yml.

Rewrote for v0.1.0: corrected tag/artifact references, completed the
required-checks list against the actual ci.yml job names, and replaced the
stale RC-4-era 'Current Status Snapshot' with an honest accounting of this
rewrite's actual state -- including that CI has never run on a real GitHub
Actions runner yet, and that real-hardware controller validation is still
outstanding. Left the macOS signing/notarization section's substance
unchanged (still accurate, unrelated to the rewrite).
…patibility

Real CI run on GitHub Actions caught this (not reproducible locally, since
this machine's golangci-lint happens to be a newer Homebrew build): the
current latest golangci-lint release (v1.64.8) is built with go1.24 and
refuses to lint code declaring a newer targeted Go language version
("the Go language version (go1.24) used to build golangci-lint is lower
than the targeted Go version (1.27)"). Nothing in this codebase actually
requires Go 1.27-specific language features -- the directive was only set
to match whatever toolchain happened to be installed during the original
build, not a deliberate requirement. 'go mod tidy' settled on 1.24.5 (a
real dependency minimum), which stays within golangci-lint's supported
1.24 line.
Real root cause of the lint failures on this branch: v6 (published before
golangci-lint v2 existed) resolves version:latest to the stale v1.x release
line (v1.64.8, built with go1.24) instead of the actual current release
(v2.13.1, confirmed via the GitHub API as the true latest, built with
go1.27.0 -- matching what's installed locally via Homebrew, which is why
this never reproduced on this machine). .golangci.yml's v2-schema config
(top-level 'version: "2"', 'linters.default') was already correct for the
real current release; the action pin was the actual bug, not the config
format. v9.3.0 is the current golangci-lint-action release.
…ceholder fixtures

packaging/scripts/package-{linux,macos}.sh are diverged, unreferenced duplicates
of scripts/package-{linux,macos}.sh (confirmed via repo-wide grep). The 6
removed harness/golden files are read by zero Go code and were already
labeled sanitized-placeholder (not real data) in the Rust version.
…under docs/ and scripts/

Minimal idiomatic Go root: cmd/, internal/, go.mod stay at root. Everything
else moves under docs/:
- docs/MIGRATION.md, docs/RC_CHECKLIST.md (from root)
- docs/spec/ -- load-bearing + living reference docs (pid_matrix.csv,
  command_matrix.csv, evidence_index.csv, protocol_spec.md, gamepad_input.md,
  alias_index.md, device_name_catalog.md, requirements.yaml)
- docs/process/ -- active/operational docs only (branch_policy, cleanroom_rules,
  commenting_standard, add_device_guide, aur_publish_troubleshooting,
  release_scope_gate)
- docs/clean-room-evidence/ -- historical RE-campaign record, clearly separated
  from load-bearing spec material: dossiers/ (74 files), device_lab.yaml,
  wave1/wave2 docs, dirtyroom docs, community_evidence_intake, device_name_sources

scripts/ absorbs packaging/scripts/'s two live files (render_release_metadata.sh,
test_render_release_metadata.sh -- the two dead duplicate package-*.sh files
were already removed in the prior commit).

internal/protocol/testdata/profile_fixture.bin -- the one harness/golden fixture
actually used by any Go code moves to the idiomatic Go testdata/ location.

All moves done via git mv; git status shows clean renames, not delete+add.
- registry.go's go:generate directive and gen/main.go's -spec-dir default:
  ../../spec -> ../../docs/spec
- registry_test.go's hardcoded fixture paths (a real bug -- these tests would
  have failed on this branch): ../../spec/*.csv -> ../../docs/spec/*.csv
- Doc-comment references to spec/*.csv across command.go, types.go,
  registry.go, validation.go, gen/main.go -- including the comment strings
  gen/main.go writes into the generated file's header, so regenerated output
  stays accurate too.
- Fixed a double-docs/ typo in registry.go introduced by an overly broad sed
  pass running after a manual edit had already touched the same line.

Verified: 'go generate ./...' finds the CSVs at the new path and produces
only the expected comment-text diff in registry_generated.go -- zero data
drift, confirming the moved CSVs are byte-identical to before.
- cleanroom_guard.sh: scan_paths now includes docs/; active_docs points at
  docs/MIGRATION.md.
- check_docs_consistency.sh: scans docs/ as a whole instead of the old
  MIGRATION.md/RC_CHECKLIST.md/process/spec paths individually.
- check_evidence_readiness.py: SPEC now docs/spec; added a separate DOSSIERS
  constant pointing at docs/clean-room-evidence/dossiers -- a real bug fix,
  since dossiers moved to a different subtree than the CSVs, not just under
  a docs/ prefix of the old spec/ path. Without this the script would have
  silently found zero dossier files for every candidate-readonly PID.
- internal/protocol/profile_test.go and profile.go: fixture path/comment
  updated to the new testdata/profile_fixture.bin location.

Verified: cleanroom_guard.sh, check_docs_consistency.sh, and
check_evidence_readiness.py all pass; profile roundtrip test passes.
…ment)

SECURITY.md: standard private-disclosure policy via GitHub's advisory flow,
scoped to this project's real risk surface (HID access, firmware
manifest verification, firmware writes, settings/report file handling).

CONTRIBUTING.md: build/test via the new justfile, the clean-room rule
explained plainly (with an explicit warning against doing dirty-room
binary analysis and clean-room code review in the same sitting -- the
exact concern this project's own docs/process/dirtyroom_collection_playbook.md
and docs/process/dirtyroom_dossier_schema.md exist to guard against),
code style, and PR process, plus a CLA: contributors keep copyright of
their own work but grant the maintainer broad usage rights including the
right to relicense -- explicitly what was asked for. Flagged in the
document itself as a starting draft, not a substitute for real legal
review at meaningful contribution scale.

Also fixed a real, pre-existing staleness bug I found while linking to it:
docs/process/cleanroom_rules.md still referenced 'cleanroom/spec/**' etc
-- a leftover from when the repo directory itself (not a subdirectory) was
named 'cleanroom', now renamed to 'openbitdo'. Fixed to the real current
paths (docs/spec/**, docs/process/**, scripts/cleanroom_guard.sh).

Verified every path referenced in both new docs actually resolves on
disk, not just that the guard scripts pass.
…stale Go version reference

README.md still said 'Go 1.27+' for building from source, but go.mod was
lowered to 1.24.5 earlier for golangci-lint compatibility -- fixed. Also
added pointers to the legacy/rust-tui branch (created to preserve the prior
Rust implementation) in both docs, which was explicitly out of scope for
the feature-work fork and left for me to add directly.
… device

Security review (confidence 8/10) found that DownloadRecommendedFirmware
verifies SHA-256 + Ed25519 signature in memory, then discards the verified
bytes and only carries the file path forward through the human-confirmation
-gated Preflight -> Start -> Confirm flow. runTransferTask re-read the file
from disk at transfer time with no re-verification against the recorded
hash -- a same-user process overwriting the file during that (human-paced,
seconds-to-minutes) window would get its content written straight to the
device, defeating the signature verification entirely.

Fix: runTransferTask now recomputes the SHA-256 of the bytes it's about to
send and compares against handle.plan.ImageSHA256 (always set by
PreflightFirmware, the only path that creates a session handle) before any
device interaction, failing closed on mismatch.

Verified the fix and the regression test for real: temporarily reverted the
fix and confirmed the test fails with the tampered bytes genuinely reaching
the transport layer (EnterBootloader -> SendCommand -> Write), then restored
the fix and confirmed it passes. Uses panicTransport as the transport so a
future accidental removal of the check would fail loudly via an unrelated
panic rather than silently passing.
…rlay, icon vocabulary, per-screen footer hints

Source-verified against opencode's actual TUI code (not guessed from
screenshots): every border in its codebase is a single-side left (or
occasionally bottom) rule, never a full box, and dialogs are borderless
solid-color panels floating on a dimmed backdrop.

- theme.go: stylePanel/stylePanelActive now use a left-only border (a
  custom lipgloss.Border with just Left:"┃" set) instead of
  RoundedBorder(), colored by context (BorderDim default, Accent
  focused). New barred() helper + styleWarningBlock/stylePositiveBlock/
  styleAccentBlock/styleDangerBlock give the same left-bar treatment to
  block-level prose (tier explanations, firmware warnings/errors) --
  mirroring how opencode colors whole message blocks by role, not just
  inline text. Removed dead Border/TextDim/AccentDim fields. Modal
  styling (styleModal) is now borderless entirely, matching opencode's
  actual dialog-confirm.tsx exactly -- danger/normal distinction carried
  by title/button text color alone, same as opencode.
- New Icon* constants centralize what were previously ad hoc per-screen
  glyph choices (diagnostics pass/fail/warn, firmware warning triangle,
  progress bar fill/empty, tier badges) into one shared vocabulary.
- modal.go: real dim-and-overlay compositing (viewOverlaid) replacing
  full-page replacement. Lipgloss/Bubbletea compose styled cells, not
  RGBA layers, so there's no native alpha-blend -- this strips all color
  from the rendered page (ansi.Strip), re-applies one uniform TextFaint
  foreground (real UI dimming desaturates/flattens anyway), then splices
  the modal's own lines in using ansi.Cut (escape-code- and
  display-width-aware, won't corrupt an SGR sequence mid-cut).
- app.go: screenHelp() now gives each screen real contextual footer
  hints (mapping's cycle-target/preview keys, firmware's stage-specific
  confirm/cancel, settings' toggle key, recovery's restore/quit) instead
  of the same generic 4-hint line everywhere except Devices.

Verified, not just written: TestView_ModalDimsBackgroundInsteadOfReplacingIt
proves the dim-overlay actually restyles the background (not just that
text survives) by comparing exact styled bytes before/after, forcing a
real color profile since go test's stdout isn't a TTY and lipgloss
silently downgrades to no color otherwise -- caught this by debugging an
initial false failure rather than assuming the test or the code was
wrong. Full existing suite (build/vet/fmt/lint/test/test-race) stays
green.
… render

Both verified for real, not just written: temporarily reverted the panel
border change and confirmed TestView_PanelsUseLeftBarNotRoundedBox fails
with the exact rounded-corner glyph it's supposed to catch, then restored
and confirmed it passes again. TestView_ModalDimsBackgroundInsteadOfReplacingIt
needed forcing a real lipgloss color profile -- go test's stdout isn't a
TTY so lipgloss silently downgrades to no color, which caused an initial
false failure; debugged that rather than assuming either the test or the
implementation was wrong before finding the real cause.
…rt and nav stream

karalabe/hid's vendored mac hidapi backend resolves every device's Path to
empty on this SDK/OS (its dlopen("/System/Library/IOKit.framework/IOKit")
compatibility shim no longer resolves), so DeviceInfo.Open() -- which opens
purely by Path -- failed for every real device, including the exact
"open failed for 2dc8:6012: hidapi: failed to open device" reported earlier.

internal/machid re-implements Open/Write/Read/Close directly against
IOKit's IOHIDManager, matching by vendor/product/usage-page/usage instead of
Path, and is now wired in on darwin for both internal/protocol's
diagnostic/command session transport and internal/input's gamepad nav
stream (via a small hidDevice/navDevice interface so non-darwin platforms
keep using karalabe/hid's Open() unchanged, since only the mac backend has
this bug).

Verified end-to-end against the real connected 8BitDo Ultimate 2 (PID
0x6013): device matching, IOHIDDeviceOpen, and IOHIDDeviceSetReport all
succeed cleanly (IOReturn 0) with the callback registered before open, no
exclusive-seize issues, and Input Monitoring access confirmed granted via
IOHIDCheckAccess. No command in the generated protocol registry elicited an
actual device response in this session (including a Confidence:"confirmed",
PID-unrestricted one) -- traced to the registry's "confirmed" meaning
confirmed present in the vendor binary via static analysis, not confirmed to
elicit a hardware response; a comparable dossier says so explicitly ("no
runtime trace or hardware write confirmation yet"), and none exists yet for
0x6013. That gap is documented in machid's package doc and left for the
separate dirty-room protocol reverse-engineering process -- it's not a
transport bug.
…ion clarity

stylePanelTitle and styleAccent were byte-for-byte identical lipgloss
styles, so panel headings and accent-styled selected rows rendered
indistinguishably -- the direct cause of the user-reported "it's weird to
tell what button I'm selecting" bug on the Mapping Editor. Give headings a
genuinely distinct definition (Underline added), and give list selection
its own dedicated styling (styleSelectedRow/styleSelectedMarker, an inverted
background) instead of reusing styleAccent, applied consistently across the
Mapping Editor, Settings, Diagnostics, and the Devices Actions pane --
Diagnostics previously had no selection styling at all.

styleSelectedMarker exists separately from styleSelectedRow because
Diagnostics' check rows already embed their own styled pass/fail icon;
wrapping that in an outer background style would have the icon's own reset
code cut the background off partway through the row, so only the "›" marker
gets the inverted treatment there.

Also gives styleBody real, consistent application to plain body text that
was previously unstyled raw output by omission (device status/evidence
lines, diagnostics check-count and detail fields, mapping preview rows).

Added two regression tests, verified to actually catch the regression by
reverting the fix and confirming they fail: one asserts the two theme.go
style definitions render different bytes for the same text, the other
renders a real Mapping Editor frame and asserts the selected row's styling
differs from the heading's.
Footer's "enter/A"/"esc/B"/"dpad" hints were hardcoded on every screen even
with no gamepad connected -- confusing on a keyboard-only setup. They're now
conditional on internal/input.Start's per-device Notes actually reporting an
active gamepad nav stream (the same data already surfaced on Settings),
via a new gamepadConnected() helper. This is a startup-time snapshot, not a
live connect/disconnect signal -- internal/input.NavEvent has no
Connected/Disconnected kind yet -- documented as a real, known limitation
rather than papered over.

Also added the missing right/tab hint to the Devices footer (the actual key
that moves focus into the Actions pane, previously undocumented there).

Investigated Switch-vs-Xbox button-layout awareness via GetMode's parsed
mode byte: real data exists (validation.go parses response[5] into
parsed["mode"]), but nothing in this codebase or its evidence dossiers
documents what values mean, and docs/spec/device_name_catalog.md lists
every known PID's protocol family as "DInput" uniformly -- no separate
Switch-layout protocol family in the evidence at all. Scoped down to just
connected/not-connected label-hiding rather than guess at a mode-to-layout
mapping with no hardware-confirmed evidence behind it; documented in
app.go for whoever revisits this once real evidence exists.

Three new tests, verified against a reverted fix to confirm they actually
catch the regression: controller hints appear only once a gamepad is
connected, an "unavailable" nav note doesn't false-positive as connected,
and the Devices footer mentions right/tab.
…creens

Verified against real rendered output, not guessed: built the binary and
captured real Terminal.app screenshots plus teatest-style plain-text frame
dumps of all 6 screens to audit actual blank-line/padding density, per the
user's "everything feels too spaced out" feedback.

Found a genuine structural bug on two screens: an optional section (Devices'
Blocked/candidate-tier explanation, Firmware's Warnings block) unconditionally
ended with its own trailing blank line, and the following section
unconditionally started with its own leading blank line -- when the optional
section was absent, both screens still applied one side of that convention,
producing a real double blank line, not just a "could be tighter" spacing
preference. Fixed both by building each screen's optional sections as a
slice of self-contained blocks (no leading/trailing blank of their own) and
joining them with strings.Join(blocks, "\n\n"), which guarantees exactly one
blank line between whichever sections are actually present.

The other 4 screens (Diagnostics, Mapping, Settings, Recovery) were audited
against the same real output and found already consistently single-blank-line
spaced -- no changes needed there beyond applying styleBody to Recovery's
remaining unstyled body text, matching Phase 2's intent.

Two new regression tests count the longest run of consecutive blank lines
in real rendered output (after stripping the panel's left-bar border glyph,
which prepends every line including blank ones, and trailing Height()
padding filler, which is expected and not what's being checked) and assert
it's never more than 1. Verified against the pre-fix code (via git show
HEAD) to confirm they actually fail there -- the first version of the
checker didn't (it never stripped the border glyph, so it never saw a
truly-empty line at all), which the revert check caught before this landed.
…ented the real bypass

Investigated whether tea.WithMouseCellMotion() genuinely blocks native
terminal text selection, or whether a modifier-key bypass already works.
Conclusion: nothing is broken in this codebase to fix. Mouse-reporting
interception of click-drag is standard, universal terminal-emulator
behavior once a program requests it (XTerm mouse tracking mode 1002) --
and the override that lets a modifier key bypass that interception and fall
through to native selection is implemented entirely by the terminal
emulator, before the running program ever sees the event. There is no
escape sequence or configuration this codebase could send to break or fix
that either way.

Verified the actual bypass key directly against Ghostty's own local docs
(/Applications/Ghostty.app/.../ghostty.5.md, the mouse-shift-capture
setting) rather than assuming: it's Shift, not Option as originally assumed
-- default mouse-shift-capture=false means Shift is never even sent to the
program, so native selection works regardless of what OpenBitdo requests.
Confirmed the user's own Ghostty config has no override, so this is
active for them right now. This was worth checking rather than trusting
general terminal-emulator knowledge, since Option is the more common
default on other macOS terminals (Terminal.app, iTerm2) and asserting it
for Ghostty specifically would have been wrong.

Documented the finding in README.md's First Run section, since "already
works and just isn't documented" was exactly the outcome -- including that
the modifier varies by terminal, and that most terminals (Ghostty via
mouse-reporting) also offer an app-wide opt-out for users who never want
mouse capture at all.
…cs + ASCII fallback

Renders a labeled grid of every physical button for the device kind being
edited, highlighting whichever one the cursor currently has selected --
using styleSelectedRow, the same selection idiom every other screen in this
app already uses, so it reads as one more selection list rather than a
one-off visual style.

Deliberately a wire-order grid, not a spatial gamepad silhouette, for both
device kinds: JP108 (PID_108JP = "Retro 108 Mechanical Keyboard", per
docs/spec/device_name_catalog.md) turned out to be a full mechanical
keyboard, not a gamepad, and nothing in this project's spec or evidence
dossiers documents where its 10 dedicated buttons physically sit on that
keyboard. Drawing a specific physical layout would be exactly the kind of
guessed hardware fact this project's own conventions deliberately avoid
(see internal/input/descriptor_other.go's comment on the same principle).
Ultimate2 is a conventional gamepad and could support a real spatial
diagram, but uses the same grid style so both device kinds get one
consistent, honest diagram rather than a precise-looking layout for one and
an admittedly-approximate one for the other.

Real Kitty graphics protocol support (github.com/charmbracelet/x/ansi's
KittyGraphics, already a direct dependency) on top of the labeled grid when
the terminal supports it: a small PNG of solid-color rectangles (selected
vs not), generated with the standard image/draw/png packages, base64-encoded
into a genuine Kitty APC escape sequence. No text is rendered into the image
itself (no font-rendering dependency) -- the labeled ASCII grid is always
printed too, so the image is a visual accent on real, readable labels, never
a wordless color swatch standing alone. Terminal capability detection
(KITTY_WINDOW_ID, TERM containing "kitty", TERM_PROGRAM ghostty/WezTerm/
kitty) was checked against real documentation, not assumed: Ghostty's own
local docs (ghostty.5, image-storage-limit) confirm it implements "the
Kitty image protocol."

Added String() methods to core.DedicatedButtonID and core.U2ButtonID (JP108
and Ultimate2's physical-button ID types) -- needed for the diagram's
labels, and as a side effect fixes an existing gap where the Mapping
Editor's row labels showed raw numeric IDs ("0", "1"...) instead of names
("A", "B"...), since %v formatting already picks up a Stringer automatically.

Tests cover: selected-vs-unselected highlighting in the ASCII grid,
capability detection across several TERM/TERM_PROGRAM/KITTY_WINDOW_ID
combinations, that the Kitty path produces a real APC sequence wrapping a
payload that decodes to a genuinely valid PNG (not just plausible-looking
bytes), that the image is always paired with the labeled grid, and that the
real Mapping Editor screen actually includes the diagram. Verified against
reverted code to confirm the highlighting and PNG-validity tests actually
fail there.

Live-terminal visual confirmation of the Kitty graphics image rendering as
real pixels was not obtained -- GUI automation against the user's actual
terminal was ruled out mid-session (interferes with their other running
Claude Code sessions in the same terminal app). The ASCII fallback path was
visually confirmed via a real Terminal.app screenshot (a separate app the
user isn't actively working in); the Kitty path's correctness rests on the
PNG-payload-validity test, not a live screenshot.
… pending encoding reconciliation

Sanitized dirty-room evidence (separate isolated process, static analysis
of the official vendor software cross-validated across two independent
builds) describes back-paddle remapping as indices 18-21 of a 22-slot
uint32 function-bitmask array. Implemented U2PaddleID and the ~30-value
U2Function catalog in internal/core/paddles.go, including the real,
asymmetric device restriction that only 'act as paddle 1'/'act as paddle 2'
are valid remap targets for other inputs (paddles 3/4 can be assigned any
function themselves, but nothing else can emulate them).

Deliberately NOT wired into internal/protocol's U2ReadButtonMap/
U2WriteButtonMap or into any TUI apply-to-device path. While implementing
this, found the existing protocol calls use a materially different,
already-shipped model: 17 (not 18) core buttons, each a uint16 raw
HID-usage code (not a uint32 function bitmask), no chunking, 34 bytes
total -- vs. this evidence's 22 x uint32 = 88 bytes needing chunked
transfer. That existing model traces back to this same dossier's own
pre-existing entry, which never actually specified byte offsets -- it was
an implementation-time assumption, not dirty-room-verified fact. The new
evidence is more rigorous and is very likely the more accurate model for
ALL slots, not just the 4 new paddle ones. Documented as an open question
in the dossier (button_map_paddle_extension.OPEN_QUESTION_encoding_mismatch)
rather than guessing how to reconcile them -- building paddle writes on
top of a foundation with this many unresolved mismatches would risk
sending malformed data to real hardware, which is worse than not shipping
the feature yet.

Verified: go generate produces the expected zero diff, full build/vet/
gofmt/lint/test/test-race/guard/just check all clean. New tests for the
bitmask catalog (all 32 values distinct single bits, diagonal OR'ing,
paddle-3/4 asymmetry, slot-index mapping) verified against a deliberate
break-then-restore of AssignableAsPaddleTarget, confirming they actually
catch a regression rather than trivially passing. No hardware attached to
this machine -- everything here is mock/unit-test verified only, matching
the rest of this session's honesty discipline about what real-hardware
confirmation still requires.
User feedback: the small screen-label text next to the OpenBitdo title
(e.g. 'Devices') was unwanted. Removed screenLabel() and the header's
right-aligned crumb entirely -- header is now just the title/[mock] tag.
Live hardware testing reported the firmware flow as "weird and
disconnected... not done and abrasive to use." The screen had no
sense of progress across its 6-stage state machine, the ready-to-
confirm plan rendered as bare unstyled text (inconsistent with the
app's block-styling elsewhere), and the final Done stage still showed
an in-progress icon on the step it had just finished.

Adds a Download -> Verify -> Confirm -> Transfer -> Done breadcrumb
(fwSteps/currentFwStepIndex/renderFwStageIndicator), restyles the
ready-to-confirm plan using the existing accent/warning/positive block
treatment instead of raw text, and fixes the breadcrumb to mark the
terminal Done step as passed rather than still in-progress. Kept
inline rather than converted to a modal: the brick-risk acknowledgement
already runs as its own modal before this screen loads, and the plan
detail here needs more room than the modal system's ~60-column cap.

Covered by new direct-render tests across every stage (including
Denied/Error/Cancelled/Unverified/Failed, which the interactive flow
doesn't reach) and an extended real-program teatest driving the full
happy path end to end.
…open question

A dedicated dirty-room reconciliation pass re-derived the wire structure
from primary evidence in two independent decompiled builds: the 22 x
uint32 / bitmask-catalog model is confirmed correct, and the shipped
17 x uint16 internal/protocol implementation is wrong (not a legitimate
alternate encoding), most likely from an earlier pass reading the wrong
nested array in the same config record.

The one piece that stays unconfirmed is the multi-report chunking
scheme needed to move 88 bytes over a 64-byte HID report -- tracked
here as its own open question, with read and write given different risk
tolerance (a bad read shows wrong values in a read-only view; a bad
write can scramble a real device's persistent button-map config).
…t startup

Start's poller re-enumerates vid==0x2dc8 devices every 1.5s and diffs
against the previously known set, starting a nav stream for each
newly-connected device the same way the initial enumeration does and
emitting EventDeviceConnected/EventDeviceDisconnected on the existing
NavEvent channel for each change. Previously the only way to pick up a
device plugged in after launch was restarting the app.
DiagProbeCached/DiagProbeFresh wrap the existing DiagProbe without
changing its signature or behavior for current callers. Results are
keyed by VidPid+Serial (matching AppDevice's own identity) and held
in memory for the process's lifetime, so navigating away from a
device and back doesn't force a re-run. CachedDiag/HasDiagnosed give
read-only access for a consumer that just wants to know what's
already there.
Two prior commits (804bfae, fbb1292) added the capability -- hotplug
connect/disconnect events on internal/input's nav channel, and
internal/core's DiagProbeCached/DiagProbeFresh -- without touching the
TUI, so none of it was actually reachable yet. This wires it in:

- EventDeviceConnected/EventDeviceDisconnected now flow through
  Update() before nav-to-key translation, refresh the device list the
  same way a manual "r" rescan does, and keep navNotes (and therefore
  gamepadConnected/Settings' "Gamepad Navigation" list) live instead of
  a startup-only snapshot -- closes the exact gap gamepadConnected's
  own comment used to flag.
- Every device list load -- startup, manual rescan, or a
  hotplug-triggered reload -- auto-diagnoses any device this session
  hasn't probed yet (DiagProbe only issues read-only SafeRead HID
  commands, safe without confirmation), so a freshly-connected
  controller already has a cached result by the time the user
  navigates to Diagnostics.
- The Devices screen's Diagnose action checks the cache synchronously
  first: a hit renders instantly with no loading flash, a miss still
  runs and caches a fresh probe.
- The Diagnostics screen shows a "Last run: Xs ago (r to rerun)"
  staleness indicator; "r" now explicitly forces DiagProbeFresh,
  bypassing the cache, rather than relying on a plain rerun that
  happened to always be fresh before caching existed.
- A live disconnect of the device currently shown on Diagnostics
  surfaces the same KindDeviceDisconnected rescan hint an
  operation-level disconnect already renders, instead of a second
  disconnect story. Firmware is deliberately left alone: an in-flight
  transfer already has its own operation-level disconnect handling,
  and interrupting it based on a background poll noticing a transient
  enumeration miss would be actively dangerous.

internal/core and internal/protocol show as modified/untracked in the
working tree from unrelated, concurrently in-progress work (a
button-map encoding fix) -- not touched by or included in this commit.
The confirmed wire shape is 22 x uint32 (88 bytes), which cannot fit in
a single 64-byte HID report -- a correct implementation needs multi-
report chunked transfer, and the exact paging scheme is not confirmed
by any evidence (see docs/clean-room-evidence/dossiers/6012/u2_core.toml,
OPEN_QUESTION_chunking_mechanism). The previously-shipped implementation
(17 x uint16, single report, no chunking) is now confirmed wrong, not a
legitimate alternate encoding.

U2ReadButtonMap/U2WriteButtonMap now perform zero HID I/O and return a
typed sentinel error (CodeU2ButtonMapUnavailable) unconditionally,
rather than risk corrupting a real device's persistent button-map
configuration with guessed chunking. Proven via a real transport that
panics on any Write/Read call -- verified to actually catch a
regression (temporarily reverted the block, confirmed the test fails,
restored it).

IndexedUsage (JP108's raw HID-usage-code dedicated mapping) is
untouched; IndexedFunction is the new, separate type for U2's
uint32 bitmask encoding.
…acefully on real hardware

U2ButtonMapping.TargetHIDUsage (uint16) is now Target (core.U2Function) --
a bitmask from the shared function catalog, matching the confirmed wire
encoding, not a raw HID usage code. Adds U2PaddleMapping, the paddle-side
counterpart, and wires paddles.go's previously-unused U2PaddleID/
U2Function types into mock defaults and real-hardware handling for the
first time.

U2ReadCoreProfile/U2PreviewSlot now tolerate the protocol layer's new
button-map block as a non-fatal, expected condition on real hardware:
mode/firmware/analog data (unrelated to the button map) still comes
through, with U2CoreProfile.MappingsUnavailable carrying a human-
readable reason instead of failing the whole profile read.

u2ApplyWrite deliberately does NOT swallow the same block: U2SetMode may
already have taken effect on a real device by the time the (always-
blocked) button-map write fails, so letting the error propagate is what
drives the existing backup/rollback path to put the device back the way
it was. Net effect: applying anything via the Ultimate2 Mapping Editor
against real hardware is safely blocked with rollback, not silently
partial. RestoreBackup's own button-map write *does* swallow the same
block, since by construction there's nothing to roll back on that front
if the original write never touched the device.

Mock mode is unaffected either way -- it already short-circuited before
ever reaching the protocol layer.

buttons.go: documented (not guessed) that the confirmed 22-slot array has
18 core button slots, one more than this codebase's 17 named U2ButtonID
values; the evidence doesn't identify the 18th, so it stays unnamed and
preserved opaquely rather than invented.

Updated TestU2PreviewSlotReadsRequestedSlotNotActiveSlot and
TestU2MockProfileRoundtripSupportsBackupAndRestore for the corrected
shape; both now also assert the new MappingsUnavailable/PaddleMappings
behavior.
Extends the existing draft/undo/apply infrastructure (already built for
JP108/Ultimate2 button mapping) with 4 more editable rows for the back
paddles, appended after the 17 button rows and before the Apply/Undo/
Reset virtual rows. Cycling a row (button or paddle) now walks the full
U2Function catalog via a new cycle table, replacing the old raw
0x0100-0x0110 HID-usage-style preset list that doesn't apply to U2
anymore.

cmdU2Apply (commands.go, out of scope for this change -- owned by a
concurrent pass) only forwards button targets, not paddle targets; real-
hardware apply is blocked entirely regardless (see the protocol-layer
commit), and mock mode ignores wire content and always succeeds, so
paddle edits still round-trip correctly through handleMappingApplyResult
copying the whole draft (paddles included) into the loaded baseline on
success. Documented inline rather than silently relying on it.

When MappingsUnavailable is set (always true for real hardware today),
the screen shows a clear explanation instead of an empty row list.

New tests: row-count/cursor-routing/dirty-detection unit tests, an
unavailable-state render test, and a real-Bubbletea-program-loop
teatest proving a paddle remap can be drafted and applied end to end in
mock mode. That teatest deliberately uses a 60-row terminal, not this
file's usual 30 -- see its comment for a real, pre-existing (not
introduced by this change, confirmed via git-stash bisection) anomaly
where this screen's content overflowing a 30-row terminal makes
bubbletea's test harness stop delivering further input.
Drives the actual running program (real Update/View, teatest's virtual
terminal, no real terminal window) against whatever 8BitDo device is
physically connected, MockMode:false -- the first time the whole app
(not just internal/machid in isolation) has been exercised against real
hardware. Same gating convention as internal/machid's existing manual
tests: never runs in `go test ./...`/CI, run explicitly with -tags manual.

Run against the user's real Ultimate2 (PID 0x6013, serial 22EC9EA4DF):
confirms Phase 1's internal/machid fix works end-to-end through the full
app (device enumerates and renders on the Devices screen), and confirms
the Diagnostics/Mapping Editor screens handle a real, non-mock session
honestly -- no crash, no hang, accurate "0/12 passed" with per-command
detail, real report saved with the real serial. This device does not
respond to any protocol command right now (write succeeds, zero bytes
read back for all 12 checks) -- a separate, pre-existing, already-
documented mystery (see internal/machid/machid_darwin.go's package doc),
not something this test or commit attempts to fix. The Mapping Editor's
error path surfaces this via U2ReadCoreProfile's earlier GetModeAlt call
failing before ever reaching the deliberately-blocked button-map read,
so the graceful MappingsUnavailable path isn't exercised by current real
hardware state -- also not a bug, just not yet reachable.
…hecklist with real-hardware findings

manual_nav_capture_test.go (-tags manual, never runs in CI): opens a real
nav stream against whatever 8BitDo device is connected and logs every
DPad/Button event for a bounded window, so a human can press physical
buttons during the run. This checks a genuinely different thing from
internal/protocol's vendor command channel (which this device doesn't
respond to at all, see the immediately prior commit) -- a HID gamepad's
standard button-state input reports are a separate mechanism from a
vendor's custom request/response protocol on the same interface, so
navigation may well work even though diagnostics don't. First run
captured zero events, but the user was away from their computer for the
window -- inconclusive, not a finding, needs a rerun with proper timing
coordination once they're back.

RC_CHECKLIST.md: corrected the stale "Real CI run: Pending" row (it has
been green for many pushes, most recently run #202) and replaced the
stale "no 8BitDo controller was available" real-hardware row with
tonight's actual findings: Phase 1's transport fix confirmed working
end-to-end through the whole app, all three main screens confirmed to
degrade honestly against a real non-mock session, and the pre-existing
(not rewrite-introduced) protocol-non-response mystery reconfirmed via
the full app rather than just an isolated manual test.
Two live capture runs (user actively pressing buttons/d-pad/sticks,
second with -count=1 to rule out a stale go test cache hit on the
first identical invocation) both received zero real nav events.
Root-caused via direct hid.Enumerate: this controller currently
exposes exactly one HID interface (usagePage=0xffa0, the vendor
config channel already known not to respond to protocol commands),
not a standard Generic-Desktop Gamepad interface. Not a code bug --
the app already opens everything the OS enumerates for this vid.
Looks like a controller connection-mode question; needs the user to
try a different mode and re-test.
Latest stable release; updated go.mod plus every doc and enforcement
script that pins the version (README, CONTRIBUTING, MIGRATION,
RC_CHECKLIST, check_go_toolchain.sh, check_docs_consistency.sh).
Verified gofmt, go vet, go mod verify, go test -race ./..., golangci-lint,
and govulncheck all pass under 1.27.0.
@bybrooklyn
bybrooklyn merged commit 81be61c into main Sep 2, 2026
20 checks passed
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