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:
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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_readserved SPI-flash reads by matching an exact(address, length)pair, and those pairs were taken fromhid-nintendo. But the kernel is not the only reader, and it is not the one a game sees.hid-nintendo(kernel)0x603Dlen 9 +0x6046len 90x8010len 20x603Dlen 18 (both sticks, one read)0x8010len 22Steam 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 logsSDL Mapping for 57e/2009. SDL's constants arek_unSPIStickFactoryCalibration{Start,End}Offset=0x603D..0x604E(18) andk_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 == 0x603Dcheck 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
ApplyStickCalibrationstarts withsRawValue -= sCenter. WithsCenter == 0it subtracts nothing, so every value stays positive and only thesRawValue >= 0branch ever runs. Against our raw axis range of648 .. 2048 (centre) .. 3448:Symmetric across both sticks, because both calibrations were equally zero.
The IMU read was fine throughout: SDL's
k_unSPIIMUScaleis0x6020..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:
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, emptyPhys=/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_validasked for exactly the shapeshid-nintendoasks for, so it could never see this. Verified by reverting the function to exact-pair matching and re-running: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.hid-nintendoreceives is byte-identical to before, so the kernel path cannot regress.spi_blobs_validstill pins it.spi_flash_readreturnsVec<u8>instead ofOption<Vec<u8>>and always answers; the caller's zero-fill closure is deleted. Net effect is one fewer code path.GET_REPORTby 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.7and 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 singleSTICK_RANGEcannot satisfy both exactly.1400is 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-injectiscfg(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— cleancargo fmt --all --check— cleanStill 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.