This is a partially breaking change that adds support for Steam Controller (2026) on Windows host and iPadOS client (a user-facing fact does change). It should improve the Android setup but I don't have a device to test it. The iOS side uses BluetoothHID signals, so it will only work over Bluetooth (and might be moot if Apple adds native controller support in OS 27). It includes rumble, gyro and touchpad support. (I’ve even been able to change the LED brightness in steam settings.)
The big breaking change bumps the ABI version up again. PunktfunkHidOutput goes from 19 to 85 bytes.
It also adds an additional leg to channel_proof to deal with the Steam Controller's identifier.
The controller is referred to Triton throughout the code here.
This is a partially breaking change that adds support for Steam Controller (2026) on Windows host and iPadOS client (a user-facing fact does change). It should improve the Android setup but I don't have a device to test it. The iOS side uses BluetoothHID signals, so it will only work over Bluetooth (and might be moot if Apple adds native controller support in OS 27). It includes rumble, gyro and touchpad support. (I’ve even been able to change the LED brightness in steam settings.)
The big breaking change bumps the ABI version up again. `PunktfunkHidOutput `goes from 19 to 85 bytes.
It also adds an additional leg to `channel_proof` to deal with the Steam Controller's identifier.
The controller is referred to Triton throughout the code here.
Adds the Triton (2026 Steam Controller, Valve 28DE:1302) device type to the
shared no_std driver protocol crate: the captured 372-byte HID report
descriptor, the per-id output-report length table, and the feature-report
query dance Steam's GetControllerInfo runs (0x83 attributes, 0xAE string,
0xF2 firmware info).
The reply must echo the last SET's command byte or Steam treats the pad as
broken and never adopts it. Identity values served to Steam are synthetic by
construction: the unit id is "TRI\0" | index, the serial is FVPF-prefixed so
the host's physical-controller conflict gate excludes it, and the firmware
build time is pinned to a recent date so Steam does not offer to "update" a
virtual pad.
Descriptor and per-id map were established against physical hardware on the
bench (2026-06-08/09).
Gives the pf-gamepad UMDF minidriver an eighth identity, pf_triton, with the
Triton USB/HID descriptors and an in-driver answer to Steam's feature query
dance, and adds the pf-inject backend that drives it over the existing
shared-memory ring.
Output frames are trimmed to their declared per-id length before forwarding,
so a 0x80 rumble goes out as 10 bytes rather than a 64-byte padded write.
Feature SETs are tagged in the ring's length word so the drain can tell them
from interrupt output.
Triton delivery is EVENT-DRIVEN: a read completes only when the input
latch actually changes, where every other identity keeps the 2 ms
every-tick cadence. The pad carries the physical controller BLE reports
and iOS floors the connection interval at ~15 ms, so re-serving the latch
every tick handed Steam ~7 identical reports followed by one holding a
full 15 ms of trackpad travel — read as a flick ~7x faster than the finger
made it (runaway trackball momentum, bench 2026-08-23). Real hardware NAKs
the interrupt IN when it has nothing new; leaving the read pended is this
stack equivalent.
Adds send_hid_report and the HidRaw host-to-client return path to the client
C ABI (ABI_VERSION 26 -> 27, a breaking bump), and stops the Windows host
folding a captured Steam Controller 2 down to an Xbox 360 pad — the SC2 kind
now reaches the Triton backend as-is.
Also adds a --triton bench pad to the host devtest harness, for driving the
claim path without physical hardware.
Captures an OS-paired Steam Controller 2 over its custom Valve GATT service
and forwards raw reports to the host's virtual pad, with a settings toggle and
an on-stream passthrough badge.
The custom service coexists with the OS's own HID-over-GATT binding, so no
unpairing is needed. Two framing rules matter: a characteristic value carries
no HID report-id byte, so 0x45 is re-prepended on the way up; and output
reports are routed per id to their own characteristic at 100F6C<id+0x35>, with
the id stripped from the payload.
The IMU block streams frozen at a stale non-zero resting sample until Steam
enables the gyro, which reads as constant rotation and flies the desktop
cursor — so the block passes through only while its timestamp is advancing.
All framing and table logic is CoreBluetooth-free and unit tested; only the
transport shim touches the radio.
Ports the IMU liveness gate to the Android client so both clients apply the
same policy: zero the IMU block while its timestamp is frozen, pass it through
once it advances. Pure JVM tests pin the truth table.
`sc2Capture` was declared inside the `#if os(iOS)` block, but the settings
row that binds `$sc2Capture` is gated `#if os(iOS) || os(macOS)`. The macOS
build therefore failed with "cannot find '$sc2Capture' in scope", taking the
whole PunktfunkClient target — and `swift test` with it — down.
Moved past the `#endif`, next to the other cross-platform storage. The
comment claimed the storage was already unconditional; its iOS-only
neighbours are why that read as true.
Two statements landed indented 8 spaces inside a 4-space block, so
`cargo fmt --all --check` failed. The fmt gate is repo-wide: one dirty file
blocks every commit, not only this one.
`Sc2Device.strippedOutputLen` said its parity with the host's
`triton::out_report_len` meant "a drift on either side fails CI". It does
not: the Swift test transcribes the host numbers into a literal, so it
catches an edit made in Swift and is blind to one made in Rust — which would
leave the client trimming its GATT writes to a stale length.
Say what the guard actually covers, on both sides, so whoever edits either
table knows it owes the other.
The embedder guide still described the HID-output pull as DualSense-only and
had no entry for `punktfunk_connection_send_hid_report`, so an out-of-tree
embedder reading it would not learn that `PunktfunkHidOutput` grew 19 to 85
bytes — the one change that can corrupt its memory if it skips the startup
version check.
The guide is the living document; CHANGELOG.md stays a per-release file and
picks the ABI row up at the next cut.
"Steam's controller list stays empty" offered a Linux udev rule as the only
answer. Windows has no udev and no permission gate — the pad is a UMDF
device — so the same symptom there means a missing or stale driver package,
and the page sent readers to a fix that cannot apply.
`sc2_capture` was also undocumented, including why it defaults on for
Android and off for Apple.
The caption ran 23 words across two clauses of what the setting does and
none of what it costs; the house rule is one of each, ~12 words for a
toggle. Its opening clause is now Android's word-for-word, with the
Apple-only cost — the Bluetooth prompt — as the rider, and the row label
matches Android's too.
The storage comment said the default mirrored Android's. Android defaults
ON. Record the prompt as the reason they differ, so the next reader does not
"fix" the mismatch.
The per-report characteristic fallback rotated Steam's output payloads
across every writable characteristic of the Valve service, including
100F6C34 — which parses what it receives as a settings command (0x87 writes
a firmware register). A swept output frame could therefore persist an
arbitrary setting on the user's own controller. Known-purpose
characteristics are now excluded from the rotation.
It also never converged: nothing observes whether a write produced haptics,
so there is no success signal to latch on. With N candidates the correct one
is live one second in every N, for the life of the session — so on an
unknown firmware it delivered broken haptics AND continuous misdirected
writes, rather than degrading. Gated behind `verbose`, which is what it is:
the bench tool that mapped the firmware in the first place.
Gating the sweep left the unknown-firmware case silent: the only log line
lived inside it, so a firmware that does not put report `id` at
100F6C<id+0x35> would drop every write for that actuator with nothing in the
bundle to say why.
Warn once per id instead, naming the id and the UUID that was missing.
Together with the connect-time GATT census that is enough to add the pad's
ids to `Sc2Device.outputCharUUID` from one user's logs — which is the only
reliable fix, since ten output characteristics share one property mask and
nothing on the device distinguishes rumble from a trackpad pulse.
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.
This is a partially breaking change that adds support for Steam Controller (2026) on Windows host and iPadOS client (a user-facing fact does change). It should improve the Android setup but I don't have a device to test it. The iOS side uses BluetoothHID signals, so it will only work over Bluetooth (and might be moot if Apple adds native controller support in OS 27). It includes rumble, gyro and touchpad support. (I’ve even been able to change the LED brightness in steam settings.)
The big breaking change bumps the ABI version up again.
PunktfunkHidOutputgoes from 19 to 85 bytes.It also adds an additional leg to
channel_proofto deal with the Steam Controller's identifier.The controller is referred to Triton throughout the code here.