fix(host/input): serve Switch Pro SPI reads by range, not exact pairs #444

Merged
enricobuehler merged 1 commits from worktree-switchpro-spi-range into main 2026-08-29 15:18:15 +00:00
Owner

Reported on a macOS client streaming to a Bazzite host, with an 8BitDo Ultimate 2C over Bluetooth. In that mode the pad presents as a Switch Pro, so the host builds a virtual Switch Pro. First described as "the left joystick seems broken"; the real symptom, in the Steam Input tester, was both sticks stuck in the top-right corner, reaching centre only at full down-left.

Root cause

spi_flash_read served SPI-flash reads by matching an exact (address, length) pair, and those pairs were taken from hid-nintendo. But the kernel is not the only reader, and it is not the one a game sees.

Reader Factory stick calibration User-cal magic
hid-nintendo (kernel) 0x603D len 9 + 0x6046 len 9 0x8010 len 2
SDL / Steam 0x603D len 18 (both sticks, one read) 0x8010 len 22

Steam does not use the kernel driver for a Switch Pro. It uses SDL's own HIDAPI Switch driver — confirmed on the host in ~/.local/share/Steam/logs/controller.txt, which logs SDL Mapping for 57e/2009. SDL's constants are k_unSPIStickFactoryCalibration{Start,End}Offset = 0x603D..0x604E (18) and k_unSPIStickUserCalibration = 0x8010..0x8025 (22).

Neither of Steam's reads matched, so both fell through to the zero-fill path. That path echoes the requested address, so SDL's opData.unAddress == 0x603D check passes and it parses 18 zero bytes as the calibration. The failure is silent and self-confirming.

Why a zeroed calibration pins a stick to a corner

ApplyStickCalibration starts with sRawValue -= sCenter. With sCenter == 0 it subtracts nothing, so every value stays positive and only the sRawValue >= 0 branch ever runs. Against our raw axis range of 648 .. 2048 (centre) .. 3448:

Physical position Raw What Steam showed
Neutral 2048 +19464 — about 59% up and right
Full up / right 3448 +32767 — the corner
Full down / left 648 +6157 — still positive, never past centre

Symmetric across both sticks, because both calibrations were equally zero.

The IMU read was fine throughout: SDL's k_unSPIIMUScale is 0x6020..0x6037, i.e. length 24, which is exactly the arm we already served. That is why buttons and motion worked while only the sticks failed — a useful discriminator when triaging this class of report.

Evidence that the kernel path was never wrong

I captured both evdev nodes on the host while the reporter moved the sticks:

== Punktfunk Switch Pro Controller 0        (hid-nintendo, fed by our reports)
  ABS_X   min= -32767 max=  32767 events=202
  ABS_Y   min= -32767 max=  32767 events=185
  ABS_RY  min= -32767 max=  32767 events=22
  keys(code,count): [(304, 14), (305, 14)]

Full range on both sticks, resting at exactly 0, at the same moment Steam showed them corner-pinned. Client, wire and our report were all correct; only Steam's parse was broken.

Note that Steam Input's own re-emission (Microsoft X-Box 360 pad 0, empty Phys=/Uniq=) emits no axis events outside a game, so it cannot serve as the witness here — the Steam tester reading is the on-glass evidence.

Why the existing tests missed it

The Switch Pro backend was validated on evdev only. spi_blobs_valid asked for exactly the shapes hid-nintendo asks for, so it could never see this. Verified by reverting the function to exact-pair matching and re-running:

test switch_proto::tests::spi_blobs_valid ... ok            <- still passes
test switch_proto::tests::spi_serves_sdl_read_shapes ... FAILED
  left:  [0, 0, 0, 0, 0, 0, 0, 0, 0]
  right: [120, 133, 87, 0, 8, 128, 120, 133, 87]

The new test is therefore a real regression test, not a tautology.

The change

Serve reads by range from a modelled flash image (flash_blocks()(start, bytes) blocks, everything else zero) rather than by exact pair. Any read shape now gets the same correct bytes, so the kernel, SDL/Steam and any other consumer agree.

  • What hid-nintendo receives is byte-identical to before, so the kernel path cannot regress. spi_blobs_valid still pins it.
  • spi_flash_read returns Vec<u8> instead of Option<Vec<u8>> and always answers; the caller's zero-fill closure is deleted. Net effect is one fewer code path.
  • The trap is specific to Switch Pro. The DualSense/DS4/Edge pads answer GET_REPORT by report number with a fixed blob, and a feature report has one canonical length, so no shape mismatch is possible there.

One thing deliberately not changed

SDL seeds its stick extents at cal_range × 0.7 and then auto-expands them, so the first sweep is slightly over-scaled and exact thereafter. The kernel uses the calibration range directly, with no such factor. A single STICK_RANGE cannot satisfy both exactly. 1400 is our true deflection: correct for the kernel, self-correcting for SDL. Inflating it to pre-compensate SDL would stop the kernel-side pad ever reaching full deflection.

Gates

Run on the rebased tree, in a Linux container — pf-inject is cfg(target_os = "linux")-gated, so a macOS run collects 40 tests and executes zero Switch ones. The count is the tell, not the green tick.

  • cargo test -p pf-inject --lib — 155 passed, 0 failed, 8 ignored (163 collected)
  • cargo clippy -p pf-inject --all-targets -- -D warnings — clean
  • cargo fmt --all --check — clean

Still owed: on-glass confirmation in Steam. That needs a host rebuild and sysext deploy on the reporter's box, which interrupts a live session, so it has not been done. Everything above is code-level and evdev-level evidence.


User-facing fact changed? n/a — a bug fix, with no install step, knob, port, limit or documented behaviour changing. No wire, ABI or OpenAPI change.

Reported on a macOS client streaming to a Bazzite host, with an 8BitDo Ultimate 2C over Bluetooth. In that mode the pad presents as a Switch Pro, so the host builds a virtual Switch Pro. First described as "the left joystick seems broken"; the real symptom, in the **Steam Input tester**, was **both sticks stuck in the top-right corner**, reaching centre only at full down-left. ## Root cause `spi_flash_read` served SPI-flash reads by matching an exact **`(address, length)`** pair, and those pairs were taken from `hid-nintendo`. But the kernel is not the only reader, and it is not the one a game sees. | Reader | Factory stick calibration | User-cal magic | |---|---|---| | `hid-nintendo` (kernel) | `0x603D` len **9** + `0x6046` len **9** | `0x8010` len **2** | | SDL / Steam | `0x603D` len **18** (both sticks, one read) | `0x8010` len **22** | Steam does not use the kernel driver for a Switch Pro. It uses SDL's own HIDAPI Switch driver — confirmed on the host in `~/.local/share/Steam/logs/controller.txt`, which logs `SDL Mapping for 57e/2009`. SDL's constants are `k_unSPIStickFactoryCalibration{Start,End}Offset` = `0x603D..0x604E` (18) and `k_unSPIStickUserCalibration` = `0x8010..0x8025` (22). Neither of Steam's reads matched, so both fell through to the zero-fill path. That path **echoes the requested address**, so SDL's `opData.unAddress == 0x603D` check passes and it parses **18 zero bytes** as the calibration. The failure is silent and self-confirming. ### Why a zeroed calibration pins a stick to a corner `ApplyStickCalibration` starts with `sRawValue -= sCenter`. With `sCenter == 0` it subtracts nothing, so every value stays positive and only the `sRawValue >= 0` branch ever runs. Against our raw axis range of `648 .. 2048 (centre) .. 3448`: | Physical position | Raw | What Steam showed | |---|---|---| | Neutral | 2048 | **+19464** — about 59% up and right | | Full up / right | 3448 | **+32767** — the corner | | Full down / left | 648 | **+6157** — still positive, never past centre | Symmetric across both sticks, because both calibrations were equally zero. The IMU read was fine throughout: SDL's `k_unSPIIMUScale` is `0x6020..0x6037`, i.e. **length 24**, which is exactly the arm we already served. That is why buttons and motion worked while only the sticks failed — a useful discriminator when triaging this class of report. ## Evidence that the kernel path was never wrong I captured both evdev nodes on the host while the reporter moved the sticks: ``` == Punktfunk Switch Pro Controller 0 (hid-nintendo, fed by our reports) ABS_X min= -32767 max= 32767 events=202 ABS_Y min= -32767 max= 32767 events=185 ABS_RY min= -32767 max= 32767 events=22 keys(code,count): [(304, 14), (305, 14)] ``` Full range on both sticks, resting at exactly `0`, at the same moment Steam showed them corner-pinned. Client, wire and our report were all correct; only Steam's parse was broken. Note that Steam Input's own re-emission (`Microsoft X-Box 360 pad 0`, empty `Phys=`/`Uniq=`) emits **no axis events outside a game**, so it cannot serve as the witness here — the Steam tester reading is the on-glass evidence. ## Why the existing tests missed it The Switch Pro backend was validated on evdev only. `spi_blobs_valid` asked for exactly the shapes `hid-nintendo` asks for, so it could never see this. Verified by reverting the function to exact-pair matching and re-running: ``` test switch_proto::tests::spi_blobs_valid ... ok <- still passes test switch_proto::tests::spi_serves_sdl_read_shapes ... FAILED left: [0, 0, 0, 0, 0, 0, 0, 0, 0] right: [120, 133, 87, 0, 8, 128, 120, 133, 87] ``` The new test is therefore a real regression test, not a tautology. ## The change Serve reads **by range** from a modelled flash image (`flash_blocks()` — `(start, bytes)` blocks, everything else zero) rather than by exact pair. Any read shape now gets the same correct bytes, so the kernel, SDL/Steam and any other consumer agree. - What `hid-nintendo` receives is **byte-identical** to before, so the kernel path cannot regress. `spi_blobs_valid` still pins it. - `spi_flash_read` returns `Vec<u8>` instead of `Option<Vec<u8>>` and always answers; the caller's zero-fill closure is deleted. Net effect is one fewer code path. - The trap is specific to Switch Pro. The DualSense/DS4/Edge pads answer `GET_REPORT` by report **number** with a fixed blob, and a feature report has one canonical length, so no shape mismatch is possible there. ### One thing deliberately not changed SDL seeds its stick extents at `cal_range × 0.7` and then auto-expands them, so the first sweep is slightly over-scaled and exact thereafter. The kernel uses the calibration range directly, with no such factor. A single `STICK_RANGE` cannot satisfy both exactly. `1400` is our true deflection: correct for the kernel, self-correcting for SDL. Inflating it to pre-compensate SDL would stop the kernel-side pad ever reaching full deflection. ## Gates Run on the rebased tree, in a Linux container — `pf-inject` is `cfg(target_os = "linux")`-gated, so a macOS run collects 40 tests and executes **zero** Switch ones. The count is the tell, not the green tick. - `cargo test -p pf-inject --lib` — 155 passed, 0 failed, 8 ignored (163 collected) - `cargo clippy -p pf-inject --all-targets -- -D warnings` — clean - `cargo fmt --all --check` — clean **Still owed: on-glass confirmation in Steam.** That needs a host rebuild and sysext deploy on the reporter's box, which interrupts a live session, so it has not been done. Everything above is code-level and evdev-level evidence. --- **User-facing fact changed?** n/a — a bug fix, with no install step, knob, port, limit or documented behaviour changing. No wire, ABI or OpenAPI change.
enricobuehler added 1 commit 2026-08-29 14:57:57 +00:00
fix(host/input): serve Switch Pro SPI reads by range, not exact pairs
ci / docs-site (pull_request) Successful in 1m4s
ci / web (pull_request) Failing after 1m19s
ci / bun-nix (pull_request) Successful in 23s
ci / rust-arm64 (pull_request) Successful in 3m9s
ci / docs-drift (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 8m56s
ci / rust (pull_request) Successful in 10m39s
0f20e72d03
Both sticks on a virtual Switch Pro sat in the top-right corner under
Steam, reaching centre only at full down-left. Buttons and motion were
fine, and the kernel's own evdev node read correctly throughout.

The SPI-flash handler matched an exact (address, length) pair, and those
pairs came from hid-nintendo. Steam does not use the kernel driver here;
it uses SDL's HIDAPI Switch driver, which reads the same calibration as
18 bytes at 0x603D and 22 at 0x8010. Neither matched, so both fell to
the zero-fill path — which echoes the requested address, so SDL accepted
the reply and parsed a zeroed calibration. A zero centre is never
subtracted, leaving every raw axis value positive.

Serve reads by range from a modelled flash image, so every consumer gets
the same bytes whatever shape it asks for. What hid-nintendo receives is
byte-identical, and the IMU block already used SDL's length, so motion is
untouched. The caller loses its zero-fill fallback.
enricobuehler merged commit 1495ec2bd2 into main 2026-08-29 15:18:15 +00:00
enricobuehler deleted branch worktree-switchpro-spi-range 2026-08-29 15:18:16 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#444