diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e0b92e15..1143140e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -229,7 +229,7 @@ jobs:
Windows (PowerShell)
```powershell
- irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex
+ irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex
```
Installs / updates to `%LOCALAPPDATA%\VIIPER\viiper.exe`
@@ -238,12 +238,12 @@ jobs:
Linux
```bash
- curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh
+ curl -fsSL https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.sh | sh
```
Installs / updates to `/usr/local/bin/viiper`
files: release_files/*
prerelease: false
- draft: true
+ draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml
index 915825b2..062b380b 100644
--- a/.github/workflows/snapshots.yml
+++ b/.github/workflows/snapshots.yml
@@ -125,7 +125,7 @@ jobs:
Windows (PowerShell)
```powershell
- irm https://alia5.github.io/VIIPER/main/install.ps1 | iex
+ irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex
```
Installs / updates to `%LOCALAPPDATA%\VIIPER\viiper.exe`
@@ -134,7 +134,7 @@ jobs:
Linux
```bash
- curl -fsSL https://alia5.github.io/VIIPER/main/install.sh | sh
+ curl -fsSL https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.sh | sh
```
Installs / updates to `/usr/local/bin/viiper`
diff --git a/README.md b/README.md
index 72136e0b..9a8a0b00 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ The simplest and recommended path is through a VIIPER-capable DS4Windows build:
2. Open **DS4Windows > Settings**.
3. Under **VIIPER Virtual Controller Support**, click **Install / Repair VIIPER**.
4. Accept the administrator prompt and restart Windows if `usbip-win2` was installed or updated.
-5. In a profile, choose a VIIPER output such as **DualSense (VIIPER)**.
+5. In a profile, choose an output such as **DualSense**.
DS4Windows installs VIIPER to `%LOCALAPPDATA%\VIIPER\viiper.exe`, installs the
required Windows USBIP driver when necessary, registers startup, and checks that
@@ -47,11 +47,13 @@ irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 |
The script:
-1. Downloads the latest release from `hbashton/VIIPER`.
+1. Downloads the pinned `v0.0.6` release from `hbashton/VIIPER` and verifies
+ the release asset SHA-256 digest.
2. Accepts either the packaged Windows ZIP or the `viiper.exe` asset used by current releases.
3. Installs VIIPER to `%LOCALAPPDATA%\VIIPER\viiper.exe`.
-4. Installs or updates `usbip-win2` when required.
-5. Registers VIIPER for startup and starts the local server.
+4. Asks for confirmation, then verifies or installs the pinned signed
+ `usbip-win2` 0.9.7.7 package.
+5. Registers and starts VIIPER only after the live USBIP ABI probe passes.
You can also download `viiper.exe` manually from the
[latest hbashton release](https://github.com/hbashton/VIIPER/releases/latest).
diff --git a/cmd/viiper/meta.go b/cmd/viiper/meta.go
index d61c62bf..0f5fcecf 100644
--- a/cmd/viiper/meta.go
+++ b/cmd/viiper/meta.go
@@ -23,7 +23,7 @@ var descriptionTemplate = `
Virtual Input over IP EmulatoR
Version: %s (%s)
%s
- Source: https://github.com/Alia5/VIIPER
+ Source: https://github.com/hbashton/VIIPER
License: GPLv3
`
diff --git a/cmd/viiper/meta_test.go b/cmd/viiper/meta_test.go
new file mode 100644
index 00000000..c5206090
--- /dev/null
+++ b/cmd/viiper/meta_test.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDescriptionUsesHbashtonSource(t *testing.T) {
+ description := Description()
+ if !strings.Contains(description, "Source: https://github.com/hbashton/VIIPER") {
+ t.Fatalf("Description() does not contain the hbashton source URL: %q", description)
+ }
+ if strings.Contains(strings.ToLower(description), "alia5") {
+ t.Fatalf("Description() retains the Alia5 source URL: %q", description)
+ }
+}
diff --git a/device/device_attach_test.go b/device/device_attach_test.go
index 25f350a7..48443774 100644
--- a/device/device_attach_test.go
+++ b/device/device_attach_test.go
@@ -2,6 +2,8 @@ package device_test
import (
"context"
+ "sort"
+ "strings"
"testing"
"time"
@@ -16,6 +18,21 @@ import (
_ "github.com/Alia5/VIIPER/internal/registry" // Register devices
)
+func TestDualSenseRegistryOnlyExposesPadSenseV5(t *testing.T) {
+ var got []string
+ for _, deviceType := range api.ListDeviceTypes() {
+ if strings.HasPrefix(deviceType, "dualsense") {
+ got = append(got, deviceType)
+ }
+ }
+ sort.Strings(got)
+ assert.Equal(t, []string{
+ "dualsenseaudioonlyduplexv5",
+ "dualsensecombinedaudioduplexv5",
+ "dualsenseedgecombinedaudioduplexv5",
+ }, got)
+}
+
func TestDeviceAttach(t *testing.T) {
deviceTypes := api.ListDeviceTypes()
diff --git a/device/dualsense/audio_control_test.go b/device/dualsense/audio_control_test.go
index b9e33670..206ff60f 100644
--- a/device/dualsense/audio_control_test.go
+++ b/device/dualsense/audio_control_test.go
@@ -292,35 +292,35 @@ func TestDualSenseAudioLifecycleDropsInactiveAndStalePCM(t *testing.T) {
const bytesPerHapticsReport = (BluetoothHapticsSampleSize / 2) *
USBHapticsAudioDownsample * USBHapticsAudioFrameSize
half := make([]byte, bytesPerHapticsReport/2)
- speakerCallbacks := 0
speakerResets := 0
feedbackCallbacks := 0
- dev.SetSpeakerCallback(func([]byte) { speakerCallbacks++ })
dev.SetSpeakerResetCallback(func() { speakerResets++ })
dev.SetOutputCallback(func(OutputState) { feedbackCallbacks++ })
dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half)
- if speakerCallbacks != 0 || len(dev.hapticsPCM) != 0 {
+ if len(dev.hapticsPCM) != 0 || len(dev.v5SpeakerPCM) != 0 {
t.Fatal("inactive render endpoint accepted PCM")
}
dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half)
- if speakerCallbacks != 1 || len(dev.hapticsPCM) != len(half) {
+ if len(dev.hapticsPCM) != len(half) ||
+ len(dev.v5SpeakerPCM) != len(half)/USBHapticsAudioFrameSize*dualSenseV5SpeakerFrameSize {
t.Fatal("active render endpoint did not retain its partial current generation")
}
dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 0)
dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half)
- if feedbackCallbacks != 0 || len(dev.hapticsPCM) != len(half) {
+ if feedbackCallbacks != 0 || len(dev.hapticsPCM) != len(half) ||
+ len(dev.v5SpeakerPCM) != len(half)/USBHapticsAudioFrameSize*dualSenseV5SpeakerFrameSize {
t.Fatal("stale haptics PCM crossed an interface close/reopen boundary")
}
dev.ResetEndpoint(EndpointHapticsAudioOut)
- if len(dev.hapticsPCM) != 0 || speakerResets != 4 {
- t.Fatalf("speaker endpoint reset did not clear transport state: buffered=%d resets=%d",
- len(dev.hapticsPCM), speakerResets)
+ if len(dev.hapticsPCM) != 0 || len(dev.v5SpeakerPCM) != 0 || speakerResets != 4 {
+ t.Fatalf("speaker endpoint reset did not clear transport state: haptics=%d speaker=%d resets=%d",
+ len(dev.hapticsPCM), len(dev.v5SpeakerPCM), speakerResets)
}
dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go
index 8c655e05..1ee67993 100644
--- a/device/dualsense/bthaptics.go
+++ b/device/dualsense/bthaptics.go
@@ -6,15 +6,11 @@ import (
)
const (
- BluetoothHapticsReportID = 0x32
- BluetoothHapticsReportSize = 141
BluetoothHapticsSampleSize = 64
BluetoothHapticsSampleRate = 3000
- // BluetoothCombinedHapticsReportID is the transport used by vDS for
- // controller audio/haptics. Unlike the legacy 0x32 packet, it carries the
- // current output state and haptics sample in one HID transaction. The fixed
- // 398-byte size is part of the DualSense Bluetooth framing.
+ // BluetoothCombinedHapticsReportID carries the current output state and
+ // haptics sample in one HID transaction. V5 uses only this atomic carrier.
BluetoothCombinedHapticsReportID = 0x36
BluetoothCombinedHapticsReportSize = 398
BluetoothCombinedStateSize = 63
@@ -57,35 +53,7 @@ var defaultBluetoothCombinedState = [BluetoothCombinedStateSize]byte{
0x00, 0x0a, 0x07, 0x00, 0x00, 0x02, 0x01, 0x00, 0xff, 0xd7, 0x00,
}
-// BuildBluetoothHapticsReport builds the DualSense Bluetooth HID report used by
-// SAxense to stream 3 kHz stereo 8-bit haptics PCM to a paired controller.
-func BuildBluetoothHapticsReport(sequence uint8, intervalIndex uint8, sample []byte) ([]byte, error) {
- if len(sample) != BluetoothHapticsSampleSize {
- return nil, ErrInvalidBluetoothHapticsSample
- }
-
- report := make([]byte, BluetoothHapticsReportSize)
- report[0] = BluetoothHapticsReportID
- report[1] = (sequence & 0x0F) << 4
-
- // Packet 0x11: haptics stream control. The last byte is incremented for
- // each 64-byte PCM interval by SAxense.
- report[2] = 0x91
- report[3] = 0x07
- report[4] = 0xFE
- report[9] = 0xFF
- report[10] = intervalIndex
-
- // Packet 0x12: 64 bytes of signed 8-bit stereo PCM.
- report[11] = 0x92
- report[12] = BluetoothHapticsSampleSize
- copy(report[13:13+BluetoothHapticsSampleSize], sample)
-
- binary.LittleEndian.PutUint32(report[BluetoothHapticsReportSize-4:], dualSenseBluetoothCRC32(report[:BluetoothHapticsReportSize-4]))
- return report, nil
-}
-
-// BuildBluetoothCombinedHapticsReport builds the vDS-compatible 0x36 report.
+// BuildBluetoothCombinedHapticsReport builds the V5 combined carrier.
//
// Real DualSense Bluetooth traffic combines state, haptics, and speaker data
// into this report. VIIPER owns the virtual USB haptics stream, while
diff --git a/device/dualsense/bthaptics_test.go b/device/dualsense/bthaptics_test.go
index d3217915..2e116309 100644
--- a/device/dualsense/bthaptics_test.go
+++ b/device/dualsense/bthaptics_test.go
@@ -6,53 +6,6 @@ import (
"testing"
)
-func TestBuildBluetoothHapticsReportMatchesSAxenseLayout(t *testing.T) {
- sample := make([]byte, BluetoothHapticsSampleSize)
- for i := range sample {
- sample[i] = byte(i)
- }
-
- report, err := BuildBluetoothHapticsReport(0x0A, 0x37, sample)
- if err != nil {
- t.Fatalf("BuildBluetoothHapticsReport failed: %v", err)
- }
-
- if len(report) != BluetoothHapticsReportSize {
- t.Fatalf("unexpected report size: got %d want %d", len(report), BluetoothHapticsReportSize)
- }
-
- if report[0] != BluetoothHapticsReportID {
- t.Fatalf("unexpected report ID: got %#x want %#x", report[0], BluetoothHapticsReportID)
- }
- if report[1] != 0xA0 {
- t.Fatalf("unexpected tag/sequence byte: got %#x want 0xA0", report[1])
- }
- if report[2] != 0x91 || report[3] != 0x07 {
- t.Fatalf("unexpected packet 0x11 header: % x", report[2:4])
- }
- if !bytes.Equal(report[4:11], []byte{0xFE, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x37}) {
- t.Fatalf("unexpected packet 0x11 body: % x", report[4:11])
- }
- if report[11] != 0x92 || report[12] != BluetoothHapticsSampleSize {
- t.Fatalf("unexpected packet 0x12 header: % x", report[11:13])
- }
- if !bytes.Equal(report[13:13+BluetoothHapticsSampleSize], sample) {
- t.Fatalf("sample payload was not copied into packet 0x12")
- }
-
- gotCRC := binary.LittleEndian.Uint32(report[BluetoothHapticsReportSize-4:])
- wantCRC := dualSenseBluetoothCRC32(report[:BluetoothHapticsReportSize-4])
- if gotCRC != wantCRC {
- t.Fatalf("unexpected crc: got %#x want %#x", gotCRC, wantCRC)
- }
-}
-
-func TestBuildBluetoothHapticsReportRejectsWrongSampleSize(t *testing.T) {
- if _, err := BuildBluetoothHapticsReport(0, 0, make([]byte, BluetoothHapticsSampleSize-1)); err == nil {
- t.Fatal("expected short sample to fail")
- }
-}
-
func TestBuildBluetoothCombinedHapticsReportMatchesVDSLayout(t *testing.T) {
sample := make([]byte, BluetoothHapticsSampleSize)
for i := range sample {
diff --git a/device/dualsense/const.go b/device/dualsense/const.go
index e0954b72..c3690a37 100644
--- a/device/dualsense/const.go
+++ b/device/dualsense/const.go
@@ -8,6 +8,12 @@ const (
DefaultPIDDS = 0x0CE6
)
+const (
+ DeviceTypeCombinedAudioDuplexV5 = "dualsensecombinedaudioduplexv5"
+ DeviceTypeAudioOnlyDuplexV5 = "dualsenseaudioonlyduplexv5"
+ DeviceTypeEdgeCombinedAudioDuplexV5 = "dualsenseedgecombinedaudioduplexv5"
+)
+
const (
DefaultMACAddressDSEdge = "A5:FE:9C:CF:92:00" // Steam reads this as serial? // TODO: not detected by all apps
DefaultSerialNumberDSEdge = "E55E00GTD1190A500" // Byte 6 (00) is "color code" will be replaced by MetaState
@@ -50,28 +56,17 @@ const (
InputReportSize = 64
OutputReportSize = 48
InputStateSize = 33
- OutputStateSize = 6
- StreamFrameHeaderSize = 8
- StreamFrameV2HeaderSize = 16
+ StreamFrameHeaderSize = 16
StreamFrameMagic0 = 0x56
StreamFrameMagic1 = 0x50
StreamFrameMagic2 = 0x43
StreamFrameMagic3 = 0x4D
- StreamFrameVersion = 0x01
- StreamFrameVersionV2 = 0x02
- StreamFrameVersionV3 = 0x03
- StreamFrameVersionV4 = 0x04
StreamFrameVersionV5 = 0x05
StreamFrameInputState = 0x01
StreamFrameMicrophonePCM = 0x02
StreamFrameOutputState = 0x81
- StreamFrameSpeakerPCM = 0x82
- // V4 keeps the native feedback generated from one 512-frame USB audio
- // generation beside the matching front-channel speaker PCM. The bridge can
- // therefore publish one physical Bluetooth report without independently
- // scheduled speaker and haptics lanes drifting at a load boundary.
- //
- // V5 retains atomic delivery, but separates the endpoint's two clocks. Its
+ // V5 follows the proven PadSense contract. It retains atomic delivery, but
+ // separates the endpoint's two clocks. Its
// speaker tail is exactly 480 raw front-stereo S16LE frames (10 ms at the
// native 48 kHz source clock). Rear channels independently produce one
// 64-byte 3 kHz haptics interval for every 512 source frames. Each V5 media
@@ -90,21 +85,10 @@ const (
USBMicrophoneClientFrameSize = USBMicrophoneClientFrameFrames *
USBMicrophoneChannels * USBMicrophoneBytesPerSample
- // OutputStateCompatExtSize is VIIPER's legacy compact server-to-client
- // feedback packet: 6 base bytes plus two 11-byte DualSense trigger effect
- // blocks. OutputStateExtSize appends the native USB output report and one
- // optional Bluetooth haptics report so clients can forward DualSense
- // haptics/control flags without reducing them to generic rumble.
- OutputStateCompatExtSize = 28
- OutputStateRawReportOffset = OutputStateCompatExtSize
- OutputStateBluetoothHapticsOffset = OutputStateRawReportOffset + OutputReportSize
- OutputStateExtSize = OutputStateBluetoothHapticsOffset + BluetoothHapticsReportSize
- // OutputStateCombinedExtSize is versioned independently from the legacy
- // 0x32 haptics extension. New clients opt into it through the
- // dualsensecombinedext device type, so older clients cannot lose stream
- // framing when they connect to a newer VIIPER server.
+ OutputStateTriggerBlocksSize = 28
+ OutputStateRawReportOffset = OutputStateTriggerBlocksSize
OutputStateCombinedBluetoothOffset = OutputStateRawReportOffset + OutputReportSize
- OutputStateCombinedExtSize = OutputStateCombinedBluetoothOffset + BluetoothCombinedHapticsReportSize
+ OutputStateV5Size = OutputStateCombinedBluetoothOffset + BluetoothCombinedHapticsReportSize
)
const (
diff --git a/device/dualsense/device.go b/device/dualsense/device.go
index f981ffdd..93d5b8e2 100644
--- a/device/dualsense/device.go
+++ b/device/dualsense/device.go
@@ -3,14 +3,12 @@ package dualsense
import (
"context"
"encoding/binary"
- "encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net"
- "os"
"sync"
"time"
@@ -25,24 +23,17 @@ const (
microphoneMaximumClientFrames = 20 // 200 ms emergency ceiling for full-duplex BT bursts; steady state remains about 55 ms.
)
-var rawOutputLogEnabled = os.Getenv("VIIPER_DUALSENSE_RAW_OUTPUT_LOG") == "1"
-
type DualSense struct {
+ deviceType string
inputCh chan InputState
inputState InputState
metaState *MetaState
- speakerFunc func([]byte)
- atomicAudioHapticsFunc func(OutputState, []byte)
- speakerResetFunc func()
- outputFunc func(OutputState)
- outputState OutputState
- descriptor usb.Descriptor
- extendedFeedback bool
- combinedBluetoothFeedback bool
- microphoneInput bool
- speakerOutput bool
- streamFrameVersion byte
+ atomicAudioHapticsFunc func(OutputState, []byte)
+ speakerResetFunc func()
+ outputFunc func(OutputState)
+ outputState OutputState
+ descriptor usb.Descriptor
subcommand [2]byte
@@ -60,10 +51,9 @@ type DualSense struct {
speakerInterfaceActive bool
microphoneInterfaceActive bool
corruptUSBInputReports int
- usbInputReportCount uint64
// hapticsPCMStartedAt identifies the oldest PCM frame waiting to make a
- // complete 10.667 ms Bluetooth haptics sample. It is only used by the
- // opt-in traffic capture to expose queueing delay without affecting timing.
+ // complete 10.667 ms Bluetooth haptics sample. It feeds ordinary stream
+ // health telemetry without affecting presentation timing.
hapticsPCMStartedAt time.Time
timestampBase time.Time
@@ -124,6 +114,7 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) {
}
d := &DualSense{
+ deviceType: DeviceTypeCombinedAudioDuplexV5,
descriptor: makeDescriptor(edge),
metaState: metaState,
speakerAudioFeature: newSpeakerAudioFeatureState(),
@@ -137,6 +128,9 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) {
),
microphoneSignal: make(chan struct{}, 1),
}
+ if edge {
+ d.deviceType = DeviceTypeEdgeCombinedAudioDuplexV5
+ }
if o != nil {
if o.IDVendor != nil {
@@ -161,6 +155,13 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) {
return d, nil
}
+// VIIPERDeviceType preserves the exact registered transport contract for
+// stream dispatch. Multiple V5 endpoint variants share the DualSense concrete
+// type, so reflection on the package name cannot distinguish them.
+func (d *DualSense) VIIPERDeviceType() string {
+ return d.deviceType
+}
+
func (d *DualSense) SetMetaState(meta MetaState) {
d.mtx.Lock()
defer d.mtx.Unlock()
@@ -173,18 +174,9 @@ func (d *DualSense) SetOutputCallback(f func(OutputState)) {
d.mtx.Unlock()
}
-// SetSpeakerCallback installs a synchronous consumer for the native virtual
-// USB audio payload. The callback must copy any bytes it retains after it
-// returns; the framed V3 writer does so into its fixed buffer pool.
-func (d *DualSense) SetSpeakerCallback(f func([]byte)) {
- d.mtx.Lock()
- d.speakerFunc = f
- d.mtx.Unlock()
-}
-
-// SetAtomicAudioHapticsCallback installs the V4/V5 transport consumer. Each
-// callback contains native feedback and one front-channel PCM generation. V4
-// retains 512 native speaker frames. V5 follows PadSense: it emits exactly 480
+// SetAtomicAudioHapticsCallback installs the V5 transport consumer. Each
+// callback contains native feedback and one front-channel PCM generation.
+// The PadSense contract emits exactly 480
// raw 48 kHz speaker frames and consumes one independently completed rear
// haptics sample, or silence when that 512-frame lane has not completed yet.
func (d *DualSense) SetAtomicAudioHapticsCallback(f func(OutputState, []byte)) {
@@ -194,8 +186,8 @@ func (d *DualSense) SetAtomicAudioHapticsCallback(f func(OutputState, []byte)) {
}
// SetSpeakerResetCallback installs the transport-side queue reset paired with
-// SetSpeakerCallback. USB interface close/reopen and endpoint reset must discard
-// queued speaker PCM from the previous presentation generation.
+// SetAtomicAudioHapticsCallback. USB interface close/reopen and endpoint reset
+// must discard queued speaker PCM from the previous presentation generation.
func (d *DualSense) SetSpeakerResetCallback(f func()) {
d.mtx.Lock()
d.speakerResetFunc = f
@@ -380,9 +372,6 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o
}
if dir == usbip.DirOut && epNumber == EndpointOut&0x0F {
- recordTrafficBytes("host->device", "interrupt-out",
- out,
- "summary", fmt.Sprintf("ep=%d", ep))
if d.handleOutputReport(out) {
return nil
}
@@ -409,9 +398,6 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) {
d.microphoneBuffer.QueueFrame(frame)
d.mtx.Unlock()
- recordTrafficSummary("client->device", "microphone-pcm-queued", len(frame),
- "summary", describeMicrophonePCMFrame(frame))
-
select {
case d.microphoneSignal <- struct{}{}:
default:
@@ -478,10 +464,6 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) {
}
receivedAt := time.Now()
- recordTrafficBytes("host->device", "audio-haptics-out",
- out,
- "summary", fmt.Sprintf("ep=%d bytes=%d", EndpointHapticsAudioOut, len(out)))
-
d.mtx.Lock()
if !d.speakerInterfaceActive {
d.mtx.Unlock()
@@ -489,81 +471,34 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) {
}
processed, release := d.speakerAudioFeature.applyPCM(out, USBHapticsAudioChannels)
- speakerFunc := d.speakerFunc
atomicAudioHapticsFunc := d.atomicAudioHapticsFunc
- var reports []pendingBluetoothHapticsReport
- if d.streamFrameVersion == StreamFrameVersionV5 {
- reports = d.consumeDualSenseV5AudioLocked(processed, receivedAt)
- } else {
- if len(d.hapticsPCM) == 0 {
- d.hapticsPCMStartedAt = receivedAt
- }
- d.hapticsPCM = append(d.hapticsPCM, processed...)
- reports = d.drainBluetoothHapticsReportsLocked(receivedAt)
- }
+ reports := d.consumeDualSenseV5AudioLocked(processed, receivedAt)
// The callback is deliberately completed under the device lock. This makes
// an alternate-setting or endpoint reset a hard generation barrier: once the
// reset acquires the lock, no pre-reset callback can enqueue stale PCM after
// the transport queue has been flushed.
- if speakerFunc != nil && atomicAudioHapticsFunc == nil {
- speakerFunc(processed)
- }
d.mtx.Unlock()
if release != nil {
release()
}
for _, pending := range reports {
- report := pending.data
- if pending.hasFeedback {
- report = pending.feedback.BluetoothCombinedOutputReport[:]
- }
+ report := pending.feedback.BluetoothCombinedOutputReport[:]
if len(report) == 0 {
continue
}
- trafficSource := "saxense-hid-0x32"
- if d.combinedBluetoothFeedback {
- trafficSource = "vds-hid-0x36"
- }
-
- recordTrafficBytes("device->physical", trafficSource,
- report,
- "reportType", "output",
- "reportID", fmt.Sprintf("0x%02X", report[0]),
- "summary", fmt.Sprintf("from audio ep=%d bytes=%d assemblyMs=%.3f",
- EndpointHapticsAudioOut, BluetoothHapticsSampleSize,
- float64(pending.assemblyDelay.Microseconds())/1000.0))
-
d.mtx.Lock()
outputFunc := d.outputFunc
atomicAudioHapticsFunc = d.atomicAudioHapticsFunc
if outputFunc != nil || atomicAudioHapticsFunc != nil {
feedback := pending.feedback
- if !pending.hasFeedback {
- feedback = d.outputState
- if d.combinedBluetoothFeedback {
- copy(feedback.BluetoothCombinedOutputReport[:], report)
- } else {
- copy(feedback.BluetoothHapticsOutputReport[:], report)
- }
- }
d.mtx.Unlock()
- dispatchStarted := time.Now()
if atomicAudioHapticsFunc != nil {
atomicAudioHapticsFunc(feedback, pending.speakerPCM)
} else {
outputFunc(feedback)
}
- recordTrafficEvent(TrafficEvent{
- Direction: "device->bridge",
- Source: "feedback-dispatch",
- Length: len(report),
- Summary: fmt.Sprintf("report=0x%02X callbackMs=%.3f assemblyMs=%.3f",
- report[0],
- float64(time.Since(dispatchStarted).Microseconds())/1000.0,
- float64(pending.assemblyDelay.Microseconds())/1000.0),
- })
} else {
d.mtx.Unlock()
}
@@ -571,11 +506,9 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) {
}
type pendingBluetoothHapticsReport struct {
- data []byte
speakerPCM []byte
assemblyDelay time.Duration
feedback OutputState
- hasFeedback bool
}
type dualSenseV5HapticsGeneration struct {
@@ -635,7 +568,6 @@ func (d *DualSense) consumeDualSenseV5AudioLocked(src []byte,
speakerPCM: d.v5SpeakerPCM,
assemblyDelay: assemblyDelay,
feedback: feedback,
- hasFeedback: true,
})
}
d.v5SpeakerPCM = nil
@@ -687,60 +619,6 @@ func (d *DualSense) buildDualSenseV5FeedbackLocked() (OutputState,
return feedback, assemblyDelay, true
}
-func (d *DualSense) drainBluetoothHapticsReportsLocked(now time.Time) []pendingBluetoothHapticsReport {
- const inputBytesPerReport = (BluetoothHapticsSampleSize / 2) *
- USBHapticsAudioDownsample *
- USBHapticsAudioFrameSize
-
- if len(d.hapticsPCM) < inputBytesPerReport {
- return nil
- }
-
- reports := make([]pendingBluetoothHapticsReport, 0, len(d.hapticsPCM)/inputBytesPerReport)
- for len(d.hapticsPCM) >= inputBytesPerReport {
- sample := make([]byte, BluetoothHapticsSampleSize)
- generationPCM := d.hapticsPCM[:inputBytesPerReport]
- copyUSBHapticsChannelsToBluetoothSample(sample, generationPCM)
- speakerPCM := make([]byte, (inputBytesPerReport/USBHapticsAudioFrameSize)*
- 2*USBHapticsAudioBytesPerSample)
- copyDualSenseSpeakerChannels(speakerPCM, generationPCM)
-
- seq := d.hapticsSeq
- interval := d.hapticsInterval
- d.hapticsSeq++
- d.hapticsInterval++
-
- var report []byte
- var err error
- if d.combinedBluetoothFeedback {
- report, err = BuildBluetoothCombinedHapticsReport(seq, interval, sample, d.outputState.RawOutputReport[:])
- } else {
- report, err = BuildBluetoothHapticsReport(seq, interval, sample)
- }
- if err != nil {
- slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err)
- } else {
- assemblyDelay := now.Sub(d.hapticsPCMStartedAt)
- if d.hapticsPCMStartedAt.IsZero() || assemblyDelay < 0 {
- assemblyDelay = 0
- }
- reports = append(reports, pendingBluetoothHapticsReport{
- data: report,
- speakerPCM: speakerPCM,
- assemblyDelay: assemblyDelay,
- })
- }
-
- copy(d.hapticsPCM, d.hapticsPCM[inputBytesPerReport:])
- d.hapticsPCM = d.hapticsPCM[:len(d.hapticsPCM)-inputBytesPerReport]
- if len(d.hapticsPCM) == 0 {
- d.hapticsPCMStartedAt = time.Time{}
- }
- }
-
- return reports
-}
-
func copyUSBHapticsChannelsToBluetoothSample(dst []byte, src []byte) {
const framesPerOutputSample = BluetoothHapticsSampleSize / 2
@@ -785,14 +663,6 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex,
if wLength > 0 && int(wLength) < len(b) {
b = b[:wLength]
}
- recordTrafficBytes("device->host", "control-get-report",
- b,
- "request", "GET_REPORT",
- "reportType", describeReportType(reportType),
- "reportID", fmt.Sprintf("0x%02X", reportID),
- "value", fmt.Sprintf("0x%04X", wValue),
- "index", fmt.Sprintf("0x%04X", wIndex),
- "summary", "input report")
return b, true
}
if reportType == reportTypeFeature {
@@ -801,14 +671,6 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex,
if wLength > 0 && int(wLength) < len(b) {
b = b[:wLength]
}
- recordTrafficBytes("device->host", "control-get-report",
- b,
- "request", "GET_REPORT",
- "reportType", describeReportType(reportType),
- "reportID", fmt.Sprintf("0x%02X", reportID),
- "value", fmt.Sprintf("0x%04X", wValue),
- "index", fmt.Sprintf("0x%04X", wIndex),
- "summary", "feature report")
return b, true
}
}
@@ -819,13 +681,6 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex,
}
case hidClassOUT:
if bRequest == hidSetReport {
- recordTrafficBytes("host->device", "control-set-report",
- data,
- "request", "SET_REPORT",
- "reportType", describeReportType(reportType),
- "reportID", fmt.Sprintf("0x%02X", reportID),
- "value", fmt.Sprintf("0x%04X", wValue),
- "index", fmt.Sprintf("0x%04X", wIndex))
switch {
case reportType == reportTypeFeature && reportID == featureIDCommand && len(data) >= 3:
d.subcommand[0] = data[1]
@@ -857,17 +712,11 @@ func (d *DualSense) handleOutputReport(out []byte) bool {
if !ok {
return false
}
- logRawOutputReport(report)
d.mtx.Lock()
outputFunc := d.outputFunc
if outputFunc != nil {
feedback := d.mergeOutputReport(report)
d.mtx.Unlock()
- recordTrafficBytes("host->device", "parsed-output-report",
- report,
- "reportType", describeReportType(reportTypeOutput),
- "reportID", fmt.Sprintf("0x%02X", report[0]),
- "decodedOutput", describeOutputState(feedback))
outputFunc(feedback)
} else {
d.mtx.Unlock()
@@ -875,47 +724,6 @@ func (d *DualSense) handleOutputReport(out []byte) bool {
return true
}
-func logRawOutputReport(report []byte) {
- if !rawOutputLogEnabled {
- return
- }
-
- attrs := []any{
- "len", len(report),
- "hex", hex.EncodeToString(report),
- }
- if len(report) > 0 {
- attrs = append(attrs, "reportID", fmt.Sprintf("0x%02X", report[0]))
- }
- if len(report) > 4 {
- attrs = append(attrs,
- "flags0", fmt.Sprintf("0x%02X", report[1]),
- "flags1", fmt.Sprintf("0x%02X", report[2]),
- "rumbleSmall", report[3],
- "rumbleLarge", report[4])
- }
- if len(report) > 31 {
- attrs = append(attrs,
- "r2", hex.EncodeToString(report[11:21]),
- "l2", hex.EncodeToString(report[22:32]))
- }
-
- slog.Info("DualSense raw host output report", attrs...)
-}
-
-func describeReportType(reportType uint8) string {
- switch reportType {
- case reportTypeInput:
- return "input"
- case reportTypeOutput:
- return "output"
- case reportTypeFeature:
- return "feature"
- default:
- return fmt.Sprintf("0x%02X", reportType)
- }
-}
-
func normalizeOutputReport(out []byte) ([]byte, bool) {
if len(out) == 0 {
return nil, false
@@ -946,7 +754,6 @@ var featureGetHandlers = map[byte]func(*DualSense) []byte{
func (d *DualSense) mergeOutputReport(out []byte) OutputState {
feedback := d.outputState
- clear(feedback.BluetoothHapticsOutputReport[:])
clear(feedback.BluetoothCombinedOutputReport[:])
if len(out) >= OutputReportSize {
copy(feedback.RawOutputReport[:], out[:OutputReportSize])
@@ -1111,9 +918,6 @@ func (d *DualSense) featureReportCommandResponse() []byte {
func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte {
b := make([]byte, InputReportSize)
- d.usbInputReportCount++
- reportCount := d.usbInputReportCount
-
b[0] = ReportIDInput
b[1] = uint8(int16(s.LX) + 128)
@@ -1188,16 +992,9 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte {
"count", count,
"reason", corruptReason)
}
- recordTrafficBytes("device->host", "usb-input-report-before-reset",
- b,
- "summary", describeUSBInputReport(b, reportCount, corruptReason))
resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery)
}
- recordTrafficBytes("device->host", "usb-input-report",
- b,
- "summary", describeUSBInputReport(b, reportCount, corruptReason))
-
return b
}
@@ -1209,67 +1006,6 @@ func inputStateControlsInvalid(s *InputState) bool {
s.DPad&^validDualSenseInputDPad != 0
}
-func describeUSBInputReport(b []byte, count uint64, resetReason string) string {
- if len(b) < 54 {
- return fmt.Sprintf("count=%d len=%d resetReason=%s", count, len(b), resetReason)
- }
-
- ts := binary.LittleEndian.Uint32(b[28:32])
- return fmt.Sprintf(
- "count=%d reportId=0x%02X seq=%d lx=%d ly=%d rx=%d ry=%d l2=%d r2=%d raw8=0x%02X raw9=0x%02X raw10=0x%02X dpadUsb=0x%X touch1=0x%02X touch2=0x%02X ts=%d battery=0x%02X fullMagic=%t markerFrag=%t micLeak=%t resetReason=%s",
- count,
- b[0],
- b[7],
- b[1],
- b[2],
- b[3],
- b[4],
- b[5],
- b[6],
- b[8],
- b[9],
- b[10],
- b[8]&DPadMask,
- b[33],
- b[37],
- ts,
- b[53],
- containsStreamMagic(b),
- containsStreamMarkerFragment(b, len(b)),
- containsMicTransportLeakPattern(b[16:41]),
- resetReason)
-}
-
-func describeMicrophonePCMFrame(frame []byte) string {
- const sampleWidth = 2
- if len(frame) < sampleWidth {
- return fmt.Sprintf("len=%d", len(frame))
- }
-
- var sumAbs uint64
- var peak uint16
- sampleCount := 0
- for i := 0; i+1 < len(frame); i += sampleWidth {
- raw := binary.LittleEndian.Uint16(frame[i : i+2])
- sample := int32(int16(raw))
- if sample < 0 {
- sample = -sample
- }
- if uint16(sample) > peak {
- peak = uint16(sample)
- }
- sumAbs += uint64(sample)
- sampleCount++
- }
-
- avg := uint64(0)
- if sampleCount > 0 {
- avg = sumAbs / uint64(sampleCount)
- }
-
- return fmt.Sprintf("pcmLen=%d samples=%d peak=%d avgAbs=%d", len(frame), sampleCount, peak, avg)
-}
-
func resetUSBInputReportToNeutral(b []byte, seq uint8, timestamp uint32, battery byte) {
for i := range b {
b[i] = 0
diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go
index c9b7b957..99e3659b 100644
--- a/device/dualsense/device_output_test.go
+++ b/device/dualsense/device_output_test.go
@@ -318,23 +318,19 @@ func TestDualSenseOutputReportFromEndpoint(t *testing.T) {
}
}
-func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) {
+func TestDualSenseHapticsAudioOutBuildsPadSenseV5Carrier(t *testing.T) {
dev, err := New(nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
-
var got OutputState
dev.SetOutputCallback(func(out OutputState) {
got = out
})
dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
- SetTrafficDiagnosticsEnabled(true, true)
- defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true)
-
- pcm := make([]byte, (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample*USBHapticsAudioFrameSize)
- for outputFrame := 0; outputFrame < BluetoothHapticsSampleSize/2; outputFrame++ {
+ pcm := make([]byte, dualSenseV5SpeakerFrames*USBHapticsAudioFrameSize)
+ for outputFrame := 0; outputFrame < dualSenseV5SpeakerFrames/USBHapticsAudioDownsample; outputFrame++ {
for frame := 0; frame < USBHapticsAudioDownsample; frame++ {
frameStart := (outputFrame*USBHapticsAudioDownsample + frame) * USBHapticsAudioFrameSize
binary.LittleEndian.PutUint16(pcm[frameStart+4:frameStart+6], uint16(int16(outputFrame*256)))
@@ -342,65 +338,6 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) {
}
}
- dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, pcm)
- events := TrafficDiagnosticsSnapshot()
-
- var sawAudioOut bool
- var sawSAxense bool
- for _, event := range events {
- switch event.Source {
- case "audio-haptics-out":
- sawAudioOut = true
- if event.Length != len(pcm) {
- t.Fatalf("unexpected audio event length: got %d want %d", event.Length, len(pcm))
- }
- case "saxense-hid-0x32":
- sawSAxense = true
- if event.ReportID != "0x32" {
- t.Fatalf("unexpected generated report ID: %s", event.ReportID)
- }
- if event.Length != BluetoothHapticsReportSize {
- t.Fatalf("unexpected generated report length: got %d want %d", event.Length, BluetoothHapticsReportSize)
- }
- }
- }
- if !sawAudioOut {
- t.Fatal("expected audio-haptics-out diagnostic event")
- }
- if !sawSAxense {
- t.Fatal("expected generated SAxense HID 0x32 diagnostic event")
- }
- if got.BluetoothHapticsOutputReport[0] != BluetoothHapticsReportID {
- t.Fatalf("expected callback haptics report ID 0x%02x, got 0x%02x",
- BluetoothHapticsReportID,
- got.BluetoothHapticsOutputReport[0])
- }
-}
-
-func TestDualSenseCombinedHapticsAudioOutBuildsVDSReports(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
- dev.combinedBluetoothFeedback = true
-
- var got OutputState
- dev.SetOutputCallback(func(out OutputState) {
- got = out
- })
- dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
-
- pcm := make([]byte, (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample*USBHapticsAudioFrameSize)
- for outputFrame := 0; outputFrame < BluetoothHapticsSampleSize/2; outputFrame++ {
- for frame := 0; frame < USBHapticsAudioDownsample; frame++ {
- frameStart := (outputFrame*USBHapticsAudioDownsample + frame) * USBHapticsAudioFrameSize
- binary.LittleEndian.PutUint16(pcm[frameStart+4:frameStart+6], uint16(int16(outputFrame*256)))
- binary.LittleEndian.PutUint16(pcm[frameStart+6:frameStart+8], uint16(int16((outputFrame+1)*-256)))
- }
- }
-
- SetTrafficDiagnosticsEnabled(true, true)
- defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true)
dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, pcm)
if got.BluetoothCombinedOutputReport[0] != BluetoothCombinedHapticsReportID {
@@ -413,19 +350,6 @@ func TestDualSenseCombinedHapticsAudioOutBuildsVDSReports(t *testing.T) {
t.Fatalf("combined report did not contain vDS state, haptics, and speaker blocks: % x",
got.BluetoothCombinedOutputReport[11:144])
}
-
- var sawCombined bool
- for _, event := range TrafficDiagnosticsSnapshot() {
- if event.Source == "vds-hid-0x36" {
- sawCombined = true
- if event.ReportID != "0x36" || event.Length != BluetoothCombinedHapticsReportSize {
- t.Fatalf("unexpected combined traffic event: %#v", event)
- }
- }
- }
- if !sawCombined {
- t.Fatal("expected vds-hid-0x36 diagnostic event")
- }
}
func TestDualSenseAudioEndpointSamplingFrequencyControls(t *testing.T) {
@@ -644,8 +568,9 @@ func TestDualSenseUSBInputReportPreservesArbitraryMotionBytes(t *testing.T) {
state.Buttons = ButtonCross
report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged})
- if !containsStreamMagic(report) {
- t.Fatalf("expected arbitrary motion bytes to survive: % x", report)
+ if report[20] != StreamFrameMagic0 || report[21] != StreamFrameMagic1 ||
+ report[22] != StreamFrameMagic2 || report[23] != StreamFrameMagic3 {
+ t.Fatalf("expected arbitrary motion bytes to survive: % x", report[16:24])
}
if dev.corruptUSBInputReports != 0 {
t.Fatalf("valid motion was incorrectly rejected, resets=%d", dev.corruptUSBInputReports)
@@ -681,50 +606,6 @@ func TestDualSenseUSBInputReportNeutralizesInvalidControlBits(t *testing.T) {
}
}
-func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) {
- out := OutputState{
- RumbleSmall: 0x11,
- RumbleLarge: 0x22,
- TriggerR2Mode: 0x21,
- TriggerR2StartResistance: 0x33,
- TriggerR2PressedStrength: 0x44,
- TriggerR2Frequency: 0x55,
- TriggerL2Mode: 0x25,
- TriggerL2StartResistance: 0x66,
- TriggerL2PressedStrength: 0x77,
- TriggerL2Frequency: 0x88,
- }
- out.RawOutputReport[0] = ReportIDOutput
- out.RawOutputReport[1] = 0x02
- out.RawOutputReport[3] = 0x99
-
- data, err := out.MarshalExtendedBinary()
- if err != nil {
- t.Fatalf("MarshalExtendedBinary returned error: %v", err)
- }
- if len(data) != OutputStateExtSize {
- t.Fatalf("unexpected extended feedback length: %d", len(data))
- }
- if data[6] != 0x21 || data[7] != 0x33 || data[12] != 0x44 || data[15] != 0x55 {
- t.Fatalf("unexpected R2 trigger block: % x", data[6:17])
- }
- if data[17] != 0x25 || data[18] != 0x66 || data[23] != 0x77 || data[26] != 0x88 {
- t.Fatalf("unexpected L2 trigger block: % x", data[17:28])
- }
-
- var decoded OutputState
- if err := decoded.UnmarshalBinary(data); err != nil {
- t.Fatalf("UnmarshalBinary returned error: %v", err)
- }
- if decoded.TriggerR2Frequency != out.TriggerR2Frequency ||
- decoded.TriggerL2Frequency != out.TriggerL2Frequency {
- t.Fatalf("unexpected decoded frequencies: R2=%#x L2=%#x", decoded.TriggerR2Frequency, decoded.TriggerL2Frequency)
- }
- if !bytes.Equal(decoded.RawOutputReport[:], out.RawOutputReport[:]) {
- t.Fatalf("raw output report did not round-trip:\n got: % x\nwant: % x", decoded.RawOutputReport, out.RawOutputReport)
- }
-}
-
func TestDualSenseCombinedExtendedFeedbackRoundTrips(t *testing.T) {
out := OutputState{}
out.RawOutputReport[0] = ReportIDOutput
@@ -733,17 +614,17 @@ func TestDualSenseCombinedExtendedFeedbackRoundTrips(t *testing.T) {
out.BluetoothCombinedOutputReport[76] = 0x92
out.BluetoothCombinedOutputReport[142] = 0x93
- data, err := out.MarshalCombinedExtendedBinary()
+ data, err := out.MarshalV5Binary()
if err != nil {
- t.Fatalf("MarshalCombinedExtendedBinary returned error: %v", err)
+ t.Fatalf("MarshalV5Binary returned error: %v", err)
}
- if len(data) != OutputStateCombinedExtSize {
- t.Fatalf("unexpected combined feedback length: got %d want %d", len(data), OutputStateCombinedExtSize)
+ if len(data) != OutputStateV5Size {
+ t.Fatalf("unexpected combined feedback length: got %d want %d", len(data), OutputStateV5Size)
}
var decoded OutputState
- if err := decoded.UnmarshalBinary(data); err != nil {
- t.Fatalf("UnmarshalBinary returned error: %v", err)
+ if err := decoded.UnmarshalV5Binary(data); err != nil {
+ t.Fatalf("UnmarshalV5Binary returned error: %v", err)
}
if !bytes.Equal(decoded.RawOutputReport[:], out.RawOutputReport[:]) {
t.Fatalf("raw output report did not round-trip:\n got: % x\nwant: % x", decoded.RawOutputReport, out.RawOutputReport)
diff --git a/device/dualsense/diagnostics.go b/device/dualsense/diagnostics.go
deleted file mode 100644
index 0b66bcd0..00000000
--- a/device/dualsense/diagnostics.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package dualsense
-
-import (
- "encoding/hex"
- "fmt"
- "log/slog"
- "sync"
- "sync/atomic"
- "time"
-)
-
-const trafficEventLimit = 65536
-
-type TrafficEvent struct {
- TimeUTC string `json:"timeUtc"`
- Direction string `json:"direction"`
- Source string `json:"source"`
- ReportType string `json:"reportType,omitempty"`
- ReportID string `json:"reportId,omitempty"`
- Request string `json:"request,omitempty"`
- Value string `json:"value,omitempty"`
- Index string `json:"index,omitempty"`
- Length int `json:"length"`
- Hex string `json:"hex,omitempty"`
- Summary string `json:"summary,omitempty"`
- DecodedOutput string `json:"decodedOutput,omitempty"`
-}
-
-var trafficDiagnosticsEnabled atomic.Bool
-
-var trafficDiagnostics = struct {
- sync.Mutex
- events []TrafficEvent
-}{}
-
-func init() {
- trafficDiagnosticsEnabled.Store(rawOutputLogEnabled)
-}
-
-func SetTrafficDiagnosticsEnabled(enabled bool, clear bool) {
- trafficDiagnosticsEnabled.Store(enabled)
- if clear {
- ClearTrafficDiagnostics()
- }
- slog.Info("DualSense traffic diagnostics toggled", "enabled", enabled, "clear", clear)
-}
-
-func TrafficDiagnosticsEnabled() bool {
- return trafficDiagnosticsEnabled.Load()
-}
-
-func ClearTrafficDiagnostics() {
- trafficDiagnostics.Lock()
- trafficDiagnostics.events = nil
- trafficDiagnostics.Unlock()
-}
-
-func TrafficDiagnosticsSnapshot() []TrafficEvent {
- trafficDiagnostics.Lock()
- defer trafficDiagnostics.Unlock()
-
- events := make([]TrafficEvent, len(trafficDiagnostics.events))
- copy(events, trafficDiagnostics.events)
- return events
-}
-
-func recordTrafficEvent(event TrafficEvent) {
- if !TrafficDiagnosticsEnabled() {
- return
- }
-
- if event.TimeUTC == "" {
- event.TimeUTC = time.Now().UTC().Format(time.RFC3339Nano)
- }
-
- trafficDiagnostics.Lock()
- if len(trafficDiagnostics.events) >= trafficEventLimit {
- copy(trafficDiagnostics.events, trafficDiagnostics.events[1:])
- trafficDiagnostics.events[len(trafficDiagnostics.events)-1] = event
- } else {
- trafficDiagnostics.events = append(trafficDiagnostics.events, event)
- }
- trafficDiagnostics.Unlock()
-
- attrs := []any{
- "direction", event.Direction,
- "source", event.Source,
- "len", event.Length,
- }
- if event.ReportType != "" {
- attrs = append(attrs, "reportType", event.ReportType)
- }
- if event.ReportID != "" {
- attrs = append(attrs, "reportID", event.ReportID)
- }
- if event.Request != "" {
- attrs = append(attrs, "request", event.Request)
- }
- if event.Summary != "" {
- attrs = append(attrs, "summary", event.Summary)
- }
- if event.DecodedOutput != "" {
- attrs = append(attrs, "decodedOutput", event.DecodedOutput)
- }
- if event.Hex != "" {
- attrs = append(attrs, "hex", event.Hex)
- }
- slog.Info("DualSense host traffic", attrs...)
-}
-
-func recordTrafficBytes(direction, source string, data []byte, fields ...any) {
- event := TrafficEvent{
- Direction: direction,
- Source: source,
- Length: len(data),
- Hex: hex.EncodeToString(data),
- }
- recordTrafficEventWithFields(event, fields...)
-}
-
-func recordTrafficSummary(direction, source string, length int, fields ...any) {
- event := TrafficEvent{
- Direction: direction,
- Source: source,
- Length: length,
- }
- recordTrafficEventWithFields(event, fields...)
-}
-
-func recordTrafficEventWithFields(event TrafficEvent, fields ...any) {
- for i := 0; i+1 < len(fields); i += 2 {
- key, _ := fields[i].(string)
- value := fmt.Sprint(fields[i+1])
- switch key {
- case "reportType":
- event.ReportType = value
- case "reportID":
- event.ReportID = value
- case "request":
- event.Request = value
- case "value":
- event.Value = value
- case "index":
- event.Index = value
- case "summary":
- event.Summary = value
- case "decodedOutput":
- event.DecodedOutput = value
- }
- }
- recordTrafficEvent(event)
-}
-
-func describeOutputState(out OutputState) string {
- return fmt.Sprintf(
- "rumbleSmall=%d rumbleLarge=%d led=%d,%d,%d playerLeds=0x%02X r2=%02X/%02X/%02X/%02X/%02X/%02X/%02X/%02X l2=%02X/%02X/%02X/%02X/%02X/%02X/%02X/%02X",
- out.RumbleSmall,
- out.RumbleLarge,
- out.LedRed,
- out.LedGreen,
- out.LedBlue,
- out.PlayerLeds,
- out.TriggerR2Mode,
- out.TriggerR2StartResistance,
- out.TriggerR2EffectForce,
- out.TriggerR2RangeForce,
- out.TriggerR2NearReleaseStrength,
- out.TriggerR2NearMiddleStrength,
- out.TriggerR2PressedStrength,
- out.TriggerR2Frequency,
- out.TriggerL2Mode,
- out.TriggerL2StartResistance,
- out.TriggerL2EffectForce,
- out.TriggerL2RangeForce,
- out.TriggerL2NearReleaseStrength,
- out.TriggerL2NearMiddleStrength,
- out.TriggerL2PressedStrength,
- out.TriggerL2Frequency)
-}
diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go
index d3a277d5..8fe2c9cd 100644
--- a/device/dualsense/ds_handler.go
+++ b/device/dualsense/ds_handler.go
@@ -16,26 +16,12 @@ import (
)
func init() {
- api.RegisterDevice("dualsense", &dshandler{})
- api.RegisterDevice("dualsenseext", &dshandler{extendedFeedback: true})
- api.RegisterDevice("dualsensecombinedext", &dshandler{combinedBluetoothFeedback: true})
- api.RegisterDevice("dualsensecombinedmicext", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion})
- api.RegisterDevice("dualsensecombinedmicv2", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2})
- api.RegisterDevice("dualsensecombinedaudioduplexv3", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV3})
- api.RegisterDevice("dualsensecombinedaudioduplexv4", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV4})
- api.RegisterDevice("dualsensecombinedaudioduplexv5", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV5})
- api.RegisterDevice("dualsenseaudioonlyduplexv3", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, audioOnly: true, streamFrameVersion: StreamFrameVersionV3})
- api.RegisterDevice("dualsenseaudioonlyduplexv4", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, audioOnly: true, streamFrameVersion: StreamFrameVersionV4})
- api.RegisterDevice("dualsenseaudioonlyduplexv5", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, audioOnly: true, streamFrameVersion: StreamFrameVersionV5})
+ api.RegisterDevice(DeviceTypeCombinedAudioDuplexV5, &dshandler{})
+ api.RegisterDevice(DeviceTypeAudioOnlyDuplexV5, &dshandler{audioOnly: true})
}
type dshandler struct {
- extendedFeedback bool
- combinedBluetoothFeedback bool
- microphoneInput bool
- speakerOutput bool
- audioOnly bool
- streamFrameVersion byte
+ audioOnly bool
}
func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) {
@@ -101,171 +87,86 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) {
if err != nil {
return nil, err
}
- dse.extendedFeedback = h.extendedFeedback
- dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback
- dse.microphoneInput = h.microphoneInput
- dse.speakerOutput = h.speakerOutput
if h.audioOnly {
dse.descriptor = makeAudioOnlyDescriptor(false)
+ dse.deviceType = DeviceTypeAudioOnlyDuplexV5
}
- dse.streamFrameVersion = h.streamFrameVersion
return dse, nil
}
func (h *dshandler) StreamHandler() api.StreamHandlerFunc {
- return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error {
- defer func() {
- if devPtr == nil || *devPtr == nil {
- return
- }
- dse, ok := (*devPtr).(*DualSense)
- if !ok {
- slog.Warn("device is not DualSenseEdge on disconnect")
- return
- }
- dse.mtx.Lock()
- serial := dse.metaState.SerialNumber
- mac := dse.metaState.MACAddress
- dse.mtx.Unlock()
- delete(serials, serial)
- delete(macs, mac)
- slog.Debug("DualSenseEdge disconnected, serial/mac released", "serial", serial, "mac", mac)
- }()
+ return dualSenseV5StreamHandler("DualSense")
+}
+func dualSenseV5StreamHandler(deviceName string) api.StreamHandlerFunc {
+ return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error {
+ defer releaseDualSenseIdentity(devPtr, deviceName)
if devPtr == nil || *devPtr == nil {
return fmt.Errorf("nil device")
}
dse, ok := (*devPtr).(*DualSense)
if !ok {
- return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType)
+ return fmt.Errorf("%w: expected %s", device.ErrWrongDeviceType, deviceName)
}
- microphoneInput := h.microphoneInput || dse.microphoneInput
- speakerOutput := h.speakerOutput || dse.speakerOutput
- streamFrameVersion := h.streamFrameVersion
- if dse.streamFrameVersion != 0 {
- streamFrameVersion = dse.streamFrameVersion
- }
- logger.Info("DualSense input stream configured",
- "microphoneInput", microphoneInput,
- "speakerOutput", speakerOutput,
- "frameVersion", streamFrameVersion)
+ logger.Info(deviceName+" PadSense V5 stream configured",
+ "microphoneInput", true,
+ "speakerOutput", true,
+ "frameVersion", StreamFrameVersionV5)
marshalFeedback := func(feedback OutputState) ([]byte, error) {
- var data []byte
- var err error
- if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback {
- data, err = feedback.MarshalCombinedExtendedBinary()
- } else if h.extendedFeedback || dse.extendedFeedback {
- data, err = feedback.MarshalExtendedBinary()
- } else {
- data, err = feedback.MarshalBinary()
- }
- if err != nil {
- return nil, err
- }
- return data, nil
+ return feedback.MarshalV5Binary()
}
-
- if speakerOutput {
- if streamFrameVersion != StreamFrameVersionV3 &&
- streamFrameVersion != StreamFrameVersionV4 &&
- streamFrameVersion != StreamFrameVersionV5 {
- return fmt.Errorf("DualSense speaker output requires framed stream version 0x%02X, 0x%02X, or 0x%02X",
- StreamFrameVersionV3, StreamFrameVersionV4, StreamFrameVersionV5)
+ writer := newDualSenseOutputWriter(conn, dse.beginSpeakerStream(), logger)
+ go writer.Run()
+ dse.SetOutputCallback(func(feedback OutputState) {
+ data, err := marshalFeedback(feedback)
+ if err != nil {
+ logger.Error("failed to marshal V5 feedback", "error", err)
+ return
}
-
- writer := newDualSenseOutputWriter(conn, streamFrameVersion,
- dse.beginSpeakerStream(), logger)
- go writer.Run()
- dse.SetOutputCallback(func(feedback OutputState) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal feedback", "error", err)
- return
- }
- writer.EnqueueControl(StreamFrameOutputState, data)
- })
- if streamFrameVersion == StreamFrameVersionV4 ||
- streamFrameVersion == StreamFrameVersionV5 {
- dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal atomic audio/haptics feedback", "error", err)
- return
- }
- writer.EnqueueAtomicAudioHaptics(data, speakerPCM)
- })
- } else {
- dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB)
+ writer.EnqueueControl(StreamFrameOutputState, data)
+ })
+ dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) {
+ data, err := marshalFeedback(feedback)
+ if err != nil {
+ logger.Error("failed to marshal V5 atomic audio/haptics feedback", "error", err)
+ return
}
- dse.SetSpeakerResetCallback(writer.ResetSpeaker)
- defer func() {
- dse.SetOutputCallback(nil)
- dse.SetSpeakerCallback(nil)
- dse.SetAtomicAudioHapticsCallback(nil)
- dse.SetSpeakerResetCallback(nil)
- writer.Stop()
- }()
- } else {
- dse.SetOutputCallback(func(feedback OutputState) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal feedback", "error", err)
- return
- }
- if _, err := conn.Write(data); err != nil {
- logger.Error("failed to send feedback", "error", err)
- }
- })
- defer dse.SetOutputCallback(nil)
- }
+ writer.EnqueueAtomicAudioHaptics(data, speakerPCM)
+ })
+ dse.SetSpeakerResetCallback(writer.ResetSpeaker)
+ defer func() {
+ dse.SetOutputCallback(nil)
+ dse.SetAtomicAudioHapticsCallback(nil)
+ dse.SetSpeakerResetCallback(nil)
+ writer.Stop()
+ }()
- return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion)
+ return readDualSenseV5InputStream(conn, dse, logger)
}
}
-func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger, microphoneInput bool) error {
- return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, StreamFrameVersion)
-}
-
-func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog.Logger, microphoneInput bool, frameVersion byte) error {
- if !microphoneInput {
- buf := make([]byte, InputStateSize)
- for {
- if _, err := io.ReadFull(conn, buf); err != nil {
- if err == io.EOF {
- logger.Info("client disconnected")
- return nil
- }
- return fmt.Errorf("read input state: %w", err)
- }
-
- var state InputState
- if err := state.UnmarshalBinary(buf); err != nil {
- return fmt.Errorf("unmarshal input state: %w", err)
- }
- dse.UpdateInputState(&state)
- }
+func releaseDualSenseIdentity(devPtr *usb.Device, deviceName string) {
+ if devPtr == nil || *devPtr == nil {
+ return
}
-
- if frameVersion != StreamFrameVersion &&
- frameVersion != StreamFrameVersionV2 &&
- frameVersion != StreamFrameVersionV3 &&
- frameVersion != StreamFrameVersionV4 &&
- frameVersion != StreamFrameVersionV5 {
- return fmt.Errorf("unsupported DualSense framed stream version 0x%02X",
- frameVersion)
+ dse, ok := (*devPtr).(*DualSense)
+ if !ok {
+ slog.Warn("unexpected device type on disconnect", "expected", deviceName)
+ return
}
+ dse.mtx.Lock()
+ serial := dse.metaState.SerialNumber
+ mac := dse.metaState.MACAddress
+ dse.mtx.Unlock()
+ delete(serials, serial)
+ delete(macs, mac)
+ slog.Debug(deviceName+" disconnected, serial/mac released", "serial", serial, "mac", mac)
+}
- headerSize := StreamFrameHeaderSize
- if frameVersion == StreamFrameVersionV2 ||
- frameVersion == StreamFrameVersionV3 ||
- frameVersion == StreamFrameVersionV4 ||
- frameVersion == StreamFrameVersionV5 {
- headerSize = StreamFrameV2HeaderSize
- }
- header := make([]byte, headerSize)
+func readDualSenseV5InputStream(conn net.Conn, dse *DualSense, logger *slog.Logger) error {
+ header := make([]byte, StreamFrameHeaderSize)
input := make([]byte, InputStateSize)
microphonePCM := make([]byte, USBMicrophoneClientFrameSize)
var expectedSequence uint32
@@ -286,8 +187,9 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog
return fmt.Errorf("invalid DualSense framed stream magic %02X %02X %02X %02X",
header[0], header[1], header[2], header[3])
}
- if header[4] != frameVersion {
- return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", header[4])
+ if header[4] != StreamFrameVersionV5 {
+ return fmt.Errorf("DualSense requires PadSense V5 stream version 0x%02X; got 0x%02X",
+ StreamFrameVersionV5, header[4])
}
frameType := header[5]
@@ -313,30 +215,22 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog
return fmt.Errorf("read framed packet type 0x%02X: %w", frameType, err)
}
- if frameVersion == StreamFrameVersionV2 ||
- frameVersion == StreamFrameVersionV3 ||
- frameVersion == StreamFrameVersionV4 ||
- frameVersion == StreamFrameVersionV5 {
- sequence := binary.LittleEndian.Uint32(header[8:12])
- if sequenceInitialized && sequence != expectedSequence {
- return fmt.Errorf("DualSense framed stream sequence mismatch: got %d expected %d", sequence, expectedSequence)
- }
- expectedSequence = sequence + 1
- sequenceInitialized = true
+ sequence := binary.LittleEndian.Uint32(header[8:12])
+ if sequenceInitialized && sequence != expectedSequence {
+ return fmt.Errorf("DualSense V5 stream sequence mismatch: got %d expected %d", sequence, expectedSequence)
+ }
+ expectedSequence = sequence + 1
+ sequenceInitialized = true
- receivedCRC := binary.LittleEndian.Uint32(header[12:16])
- calculatedCRC := framedStreamCRC(header[4:12], payload)
- if receivedCRC != calculatedCRC {
- return fmt.Errorf("DualSense framed stream CRC mismatch for sequence %d: got %08X expected %08X", sequence, receivedCRC, calculatedCRC)
- }
+ receivedCRC := binary.LittleEndian.Uint32(header[12:16])
+ calculatedCRC := framedStreamCRC(header[4:12], payload)
+ if receivedCRC != calculatedCRC {
+ return fmt.Errorf("DualSense V5 stream CRC mismatch for sequence %d: got %08X expected %08X", sequence, receivedCRC, calculatedCRC)
}
switch frameType {
case StreamFrameInputState:
corruptReason := inputStatePayloadCorruptionReason(input)
- recordTrafficBytes("client->device", "framed-input-state",
- input,
- "summary", describeInputStatePayload(input, corruptReason))
if corruptReason != "" {
return fmt.Errorf("invalid framed input state: %s", corruptReason)
}
@@ -346,8 +240,6 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog
}
dse.UpdateInputState(&state)
case StreamFrameMicrophonePCM:
- recordTrafficSummary("client->device", "framed-microphone-pcm", len(microphonePCM),
- "summary", describeMicrophonePCMFrame(microphonePCM))
dse.QueueMicrophonePCMFrame(microphonePCM)
}
}
@@ -375,123 +267,6 @@ func inputStatePayloadCorruptionReason(input []byte) string {
return ""
}
-func describeInputStatePayload(input []byte, corruptReason string) string {
- if len(input) < InputStateSize {
- return fmt.Sprintf("len=%d corruptReason=%s", len(input), corruptReason)
- }
-
- buttons := binary.LittleEndian.Uint32(input[4:8])
- dpad := input[8]
- gyroX := int16(binary.LittleEndian.Uint16(input[21:23]))
- gyroY := int16(binary.LittleEndian.Uint16(input[23:25]))
- gyroZ := int16(binary.LittleEndian.Uint16(input[25:27]))
- accelX := int16(binary.LittleEndian.Uint16(input[27:29]))
- accelY := int16(binary.LittleEndian.Uint16(input[29:31]))
- accelZ := int16(binary.LittleEndian.Uint16(input[31:33]))
-
- return fmt.Sprintf(
- "lx=%d ly=%d rx=%d ry=%d buttons=0x%08X dpad=0x%02X l2=%d r2=%d touch1Status=0x%02X touch2Status=0x%02X gyro=%d,%d,%d accel=%d,%d,%d fullMagic=%t markerFragControls=%t micLeak=%t corruptReason=%s",
- int8(input[0]),
- int8(input[1]),
- int8(input[2]),
- int8(input[3]),
- buttons,
- dpad,
- input[9],
- input[10],
- input[15],
- input[20],
- gyroX,
- gyroY,
- gyroZ,
- accelX,
- accelY,
- accelZ,
- containsStreamMagic(input),
- containsStreamMarkerFragment(input, len(input)),
- containsMicTransportLeakPattern(input[11:]),
- corruptReason)
-}
-
-func containsStreamMagic(data []byte) bool {
- const magicLength = 4
- if len(data) < magicLength {
- return false
- }
-
- for i := 0; i+magicLength <= len(data); i++ {
- if data[i] == StreamFrameMagic0 &&
- data[i+1] == StreamFrameMagic1 &&
- data[i+2] == StreamFrameMagic2 &&
- data[i+3] == StreamFrameMagic3 {
- return true
- }
- }
-
- return false
-}
-
-func containsStreamMarkerFragment(data []byte, length int) bool {
- const markerLength = 3
- if len(data) < markerLength || length < markerLength {
- return false
- }
-
- end := min(length, len(data))
- for i := 0; i+markerLength <= end; i++ {
- if data[i] == StreamFrameMagic0 &&
- data[i+1] == StreamFrameMagic1 &&
- data[i+2] == StreamFrameMagic2 {
- return true
- }
- if data[i] == StreamFrameMagic1 &&
- data[i+1] == StreamFrameMagic2 &&
- data[i+2] == StreamFrameMagic3 {
- return true
- }
- }
-
- return false
-}
-
-func containsMicTransportLeakPattern(data []byte) bool {
- return containsStrongMicTransportLeakPattern(data) ||
- containsWeakMicTransportLeakPattern(data)
-}
-
-func containsStrongMicTransportLeakPattern(data []byte) bool {
- return containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) ||
- containsByteSequence(data, []byte{StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) ||
- containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}) ||
- containsByteSequence(data, []byte{StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2})
-}
-
-func containsWeakMicTransportLeakPattern(data []byte) bool {
- return containsByteSequence(data, []byte{0x01, 0x01, hidClassOUT}) ||
- containsByteSequence(data, []byte{0x80, 0x87, StreamFrameMagic2})
-}
-
-func containsByteSequence(data []byte, sequence []byte) bool {
- if len(sequence) == 0 || len(data) < len(sequence) {
- return false
- }
-
- for i := 0; i+len(sequence) <= len(data); i++ {
- found := true
- for j := range sequence {
- if data[i+j] != sequence[j] {
- found = false
- break
- }
- }
- if found {
- return true
- }
- }
-
- return false
-}
-
func isPowerOfTwo(value int) bool {
return value > 0 && value&(value-1) == 0
}
diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go
index 094eb958..53a1dc56 100644
--- a/device/dualsense/ds_handler_test.go
+++ b/device/dualsense/ds_handler_test.go
@@ -1,62 +1,26 @@
package dualsense
import (
- "context"
"encoding/binary"
"hash/crc32"
"io"
"log/slog"
"net"
- "slices"
"strings"
"testing"
-
- "github.com/Alia5/VIIPER/usbip"
)
-func makeStreamFrame(t *testing.T, frameType byte, payload []byte) []byte {
- t.Helper()
- if len(payload) > 0xFFFF {
- t.Fatalf("payload too large: %d", len(payload))
- }
-
+func makeV5StreamFrame(frameType byte, sequence uint32, payload []byte) []byte {
frame := make([]byte, StreamFrameHeaderSize+len(payload))
- copy(frame, makeStreamFrameHeader(frameType, len(payload)))
- copy(frame[StreamFrameHeaderSize:], payload)
- return frame
-}
-
-func makeStreamFrameHeader(frameType byte, payloadLength int) []byte {
- frame := make([]byte, StreamFrameHeaderSize)
frame[0] = StreamFrameMagic0
frame[1] = StreamFrameMagic1
frame[2] = StreamFrameMagic2
frame[3] = StreamFrameMagic3
- frame[4] = StreamFrameVersion
- frame[5] = frameType
- binary.LittleEndian.PutUint16(frame[6:8], uint16(payloadLength))
- return frame
-}
-
-func makeStreamFrameV2(frameType byte, sequence uint32, payload []byte) []byte {
- return makeStreamFrameWithCRC(StreamFrameVersionV2, frameType, sequence, payload)
-}
-
-func makeStreamFrameV3(frameType byte, sequence uint32, payload []byte) []byte {
- return makeStreamFrameWithCRC(StreamFrameVersionV3, frameType, sequence, payload)
-}
-
-func makeStreamFrameWithCRC(version, frameType byte, sequence uint32, payload []byte) []byte {
- frame := make([]byte, StreamFrameV2HeaderSize+len(payload))
- frame[0] = StreamFrameMagic0
- frame[1] = StreamFrameMagic1
- frame[2] = StreamFrameMagic2
- frame[3] = StreamFrameMagic3
- frame[4] = version
+ frame[4] = StreamFrameVersionV5
frame[5] = frameType
binary.LittleEndian.PutUint16(frame[6:8], uint16(len(payload)))
binary.LittleEndian.PutUint32(frame[8:12], sequence)
- copy(frame[StreamFrameV2HeaderSize:], payload)
+ copy(frame[StreamFrameHeaderSize:], payload)
hash := crc32.NewIEEE()
_, _ = hash.Write(frame[4:12])
_, _ = hash.Write(payload)
@@ -64,517 +28,170 @@ func makeStreamFrameWithCRC(version, frameType byte, sequence uint32, payload []
return frame
}
-func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) {
+func newV5ReaderTest(t *testing.T) (*DualSense, net.Conn, <-chan error) {
+ t.Helper()
dev, err := New(nil)
if err != nil {
- t.Fatalf("New returned error: %v", err)
+ t.Fatalf("New: %v", err)
}
dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
-
server, client := net.Pipe()
- defer server.Close()
-
errCh := make(chan error, 1)
go func() {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStream(server, dev, logger, true)
+ errCh <- readDualSenseV5InputStream(server, dev, logger)
}()
-
- state := NewInputState()
- state.LX = 42
- state.R2 = 99
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- microphonePayload := make([]byte, USBMicrophoneClientFrameSize)
- for i := range microphonePayload {
- microphonePayload[i] = byte(i)
- }
-
- if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- for range microphoneTargetClientFrames {
- if _, err := client.Write(makeStreamFrame(t, StreamFrameMicrophonePCM, microphonePayload)); err != nil {
- t.Fatalf("write microphone frame: %v", err)
- }
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
-
- if err := <-errCh; err != nil {
- t.Fatalf("readDualSenseInputStream returned error: %v", err)
- }
-
- dev.mtx.Lock()
- gotInput := dev.inputState
- gotMicrophoneState := dev.microphoneBuffer.State()
- dev.mtx.Unlock()
-
- if gotInput.LX != 42 || gotInput.R2 != 99 {
- t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2)
- }
- if gotMicrophoneState.QueuedBytes != USBMicrophoneClientFrameSize*microphoneTargetClientFrames ||
- !gotMicrophoneState.Primed {
- t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState)
- }
+ return dev, client, errCh
}
-func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
- dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2)
- }()
-
+func TestReadDualSenseV5InputStreamAcceptsInterleavedStateAndMicrophone(t *testing.T) {
+ dev, client, errCh := newV5ReaderTest(t)
state := NewInputState()
- state.LX = 42
- state.R2 = 99
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- microphonePayload := make([]byte, USBMicrophoneClientFrameSize)
- for i := range microphonePayload {
- microphonePayload[i] = byte(i)
- }
-
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, inputPayload)); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- if _, err := client.Write(makeStreamFrameV2(StreamFrameMicrophonePCM, 1, microphonePayload)); err != nil {
- t.Fatalf("write microphone frame: %v", err)
- }
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 2, inputPayload)); err != nil {
- t.Fatalf("write interleaved input frame: %v", err)
- }
- for frameIndex := 1; frameIndex < microphoneTargetClientFrames; frameIndex++ {
- sequence := uint32(frameIndex + 2)
- if _, err := client.Write(makeStreamFrameV2(StreamFrameMicrophonePCM, sequence, microphonePayload)); err != nil {
- t.Fatalf("write microphone frame %d: %v", frameIndex+1, err)
+ state.LX = 64
+ state.Buttons = ButtonCross | ButtonL1
+ state.GyroX = -32768
+ state.GyroY = 0x4350
+ state.AccelZ = -12345
+ input, _ := state.MarshalBinary()
+
+ if _, err := client.Write(makeV5StreamFrame(StreamFrameInputState, 0, input)); err != nil {
+ t.Fatalf("write state: %v", err)
+ }
+ for sequence := uint32(1); sequence <= microphoneTargetClientFrames; sequence++ {
+ pcm := make([]byte, USBMicrophoneClientFrameSize)
+ for index := range pcm {
+ pcm[index] = byte(sequence)
+ }
+ if _, err := client.Write(makeV5StreamFrame(
+ StreamFrameMicrophonePCM, sequence, pcm)); err != nil {
+ t.Fatalf("write microphone frame %d: %v", sequence, err)
}
}
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
-
+ _ = client.Close()
if err := <-errCh; err != nil {
- t.Fatalf("readDualSenseInputStreamVersion returned error: %v", err)
+ t.Fatalf("reader: %v", err)
}
dev.mtx.Lock()
- gotInput := dev.inputState
- gotMicrophoneState := dev.microphoneBuffer.State()
+ got := dev.inputState
+ queued := dev.microphoneBuffer.State().QueuedBytes
dev.mtx.Unlock()
-
- if gotInput.LX != state.LX || gotInput.R2 != state.R2 {
- t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2)
- }
- if gotMicrophoneState.QueuedBytes != len(microphonePayload)*microphoneTargetClientFrames ||
- !gotMicrophoneState.Primed {
- t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState)
+ if got.LX != state.LX || got.Buttons != state.Buttons ||
+ got.GyroX != state.GyroX || got.GyroY != state.GyroY ||
+ got.AccelZ != state.AccelZ {
+ t.Fatalf("V5 input changed: got=%+v want=%+v", got, state)
}
- gotMicrophone := dev.HandleTransfer(context.Background(),
- uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil)
- if !slices.Equal(gotMicrophone, microphonePayload[:USBMicrophonePacketSize]) {
- t.Fatal("microphone payload changed in transport")
+ if queued != USBMicrophoneClientFrameSize*microphoneTargetClientFrames {
+ t.Fatalf("queued microphone bytes=%d", queued)
}
}
-func TestReadDualSenseInputStreamV3RetainsInputAndMicrophoneFraming(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
- dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
-
- server, client := net.Pipe()
- defer server.Close()
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStreamVersion(server, dev, logger, true,
- StreamFrameVersionV3)
- }()
-
- state := NewInputState()
- state.LX = 21
- state.R2 = 87
- state.Buttons = ButtonTriangle
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- microphonePayload := make([]byte, USBMicrophoneClientFrameSize)
- for index := range microphonePayload {
- microphonePayload[index] = byte(index)
- }
-
- if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState, 0,
- inputPayload)); err != nil {
- t.Fatalf("write V3 input frame: %v", err)
- }
- for frame := 0; frame < microphoneTargetClientFrames; frame++ {
- if _, err := client.Write(makeStreamFrameV3(StreamFrameMicrophonePCM,
- uint32(frame+1), microphonePayload)); err != nil {
- t.Fatalf("write V3 microphone frame %d: %v", frame, err)
- }
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
- if err := <-errCh; err != nil {
- t.Fatalf("V3 input reader returned error: %v", err)
- }
-
- dev.mtx.Lock()
- gotInput := dev.inputState
- gotMicrophoneState := dev.microphoneBuffer.State()
- dev.mtx.Unlock()
- if gotInput.LX != state.LX || gotInput.R2 != state.R2 ||
- gotInput.Buttons != state.Buttons {
- t.Fatalf("V3 input state changed: got %+v want %+v", gotInput, state)
+func TestReadDualSenseV5InputStreamRejectsLegacyVersion(t *testing.T) {
+ _, client, errCh := newV5ReaderTest(t)
+ frame := makeV5StreamFrame(StreamFrameInputState, 0,
+ make([]byte, InputStateSize))
+ frame[4] = 0x04
+ if _, err := client.Write(frame[:StreamFrameHeaderSize]); err != nil {
+ t.Fatalf("write legacy frame: %v", err)
}
- if gotMicrophoneState.QueuedBytes != USBMicrophoneClientFrameSize*
- microphoneTargetClientFrames || !gotMicrophoneState.Primed {
- t.Fatalf("unexpected V3 microphone state: %+v", gotMicrophoneState)
+ _ = client.Close()
+ err := <-errCh
+ if err == nil || !strings.Contains(err.Error(), "requires PadSense V5") {
+ t.Fatalf("unexpected legacy version result: %v", err)
}
}
-func TestBaseDispatcherHonorsCreatedV2StreamProtocol(t *testing.T) {
- variant := &dshandler{
- combinedBluetoothFeedback: true,
- microphoneInput: true,
- streamFrameVersion: StreamFrameVersionV2,
+func TestReadDualSenseV5InputStreamRejectsBadCRC(t *testing.T) {
+ _, client, errCh := newV5ReaderTest(t)
+ frame := makeV5StreamFrame(StreamFrameInputState, 0,
+ make([]byte, InputStateSize))
+ frame[12] ^= 0xFF
+ if _, err := client.Write(frame); err != nil {
+ t.Fatalf("write bad CRC frame: %v", err)
}
- dev, err := variant.CreateDevice(nil)
- if err != nil {
- t.Fatalf("CreateDevice returned error: %v", err)
+ _ = client.Close()
+ err := <-errCh
+ if err == nil || !strings.Contains(err.Error(), "CRC mismatch") {
+ t.Fatalf("unexpected CRC result: %v", err)
}
+}
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- // The runtime dispatcher infers the concrete Go type as plain
- // "dualsense", so exercise the base registration here as well.
- errCh <- (&dshandler{}).StreamHandler()(server, &dev, logger)
- }()
-
- state := NewInputState()
- state.LX = 42
- state.R2 = 99
- state.Buttons = ButtonCross
- payload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
+func TestReadDualSenseV5InputStreamRejectsSequenceGap(t *testing.T) {
+ _, client, errCh := newV5ReaderTest(t)
+ payload := make([]byte, InputStateSize)
+ if _, err := client.Write(makeV5StreamFrame(StreamFrameInputState, 7, payload)); err != nil {
+ t.Fatalf("write first frame: %v", err)
}
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, payload)); err != nil {
- t.Fatalf("write input frame: %v", err)
+ if _, err := client.Write(makeV5StreamFrame(StreamFrameInputState, 9, payload)); err != nil {
+ t.Fatalf("write skipped frame: %v", err)
}
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
- if err := <-errCh; err != nil {
- t.Fatalf("base stream handler rejected V2 variant framing: %v", err)
- }
-
- dualSense := dev.(*DualSense)
- dualSense.mtx.Lock()
- got := dualSense.inputState
- dualSense.mtx.Unlock()
- if got.LX != state.LX || got.R2 != state.R2 || got.Buttons != state.Buttons {
- t.Fatalf("V2 frame was not preserved: got LX=%d R2=%d buttons=%#x",
- got.LX, got.R2, got.Buttons)
+ _ = client.Close()
+ err := <-errCh
+ if err == nil || !strings.Contains(err.Error(), "sequence mismatch") {
+ t.Fatalf("unexpected sequence result: %v", err)
}
}
-func TestReadDualSenseInputStreamRejectsUnversionedMicFrames(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStream(server, dev, logger, true)
- }()
-
- oldStylePrefix := []byte{StreamFrameMicrophonePCM, 0, 0, 0, 0, 0, 0, 0}
- if _, err := client.Write(oldStylePrefix); err != nil {
- t.Fatalf("write old style prefix: %v", err)
+func TestReadDualSenseV5InputStreamRejectsInvalidControlBits(t *testing.T) {
+ _, client, errCh := newV5ReaderTest(t)
+ payload := make([]byte, InputStateSize)
+ binary.LittleEndian.PutUint32(payload[4:8], 0x80000000)
+ if _, err := client.Write(makeV5StreamFrame(StreamFrameInputState, 0, payload)); err != nil {
+ t.Fatalf("write corrupt frame: %v", err)
}
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
+ _ = client.Close()
+ err := <-errCh
+ if err == nil || !strings.Contains(err.Error(), "invalid controls") {
+ t.Fatalf("unexpected corrupt input result: %v", err)
}
+}
- err = <-errCh
- if err == nil || !strings.Contains(err.Error(), "invalid DualSense framed stream magic") {
- t.Fatalf("expected invalid magic error, got %v", err)
+func TestReadDualSenseV5InputStreamRejectsUnknownFrameType(t *testing.T) {
+ _, client, errCh := newV5ReaderTest(t)
+ if _, err := client.Write(makeV5StreamFrame(0x7F, 0, nil)); err != nil {
+ t.Fatalf("write unknown frame: %v", err)
}
-
- gotMicrophoneLen := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int)
- if gotMicrophoneLen != 0 {
- t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen)
+ _ = client.Close()
+ err := <-errCh
+ if err == nil || !strings.Contains(err.Error(), "unknown DualSense framed stream") {
+ t.Fatalf("unexpected unknown-frame result: %v", err)
}
}
func TestQueueMicrophonePCMFrameRequiresActiveInterface(t *testing.T) {
dev, err := New(nil)
if err != nil {
- t.Fatalf("New returned error: %v", err)
+ t.Fatalf("New: %v", err)
}
-
frame := make([]byte, USBMicrophoneClientFrameSize)
dev.QueueMicrophonePCMFrame(frame)
- if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != 0 {
- t.Fatalf("inactive mic interface should drop PCM, got %d bytes", queued)
+ if got := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"]; got != 0 {
+ t.Fatalf("inactive interface queued microphone PCM: %v", got)
}
-
dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
dev.QueueMicrophonePCMFrame(frame)
- if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != USBMicrophoneClientFrameSize {
- t.Fatalf("active mic interface should queue PCM, got %d bytes", queued)
+ if got := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"]; got != USBMicrophoneClientFrameSize {
+ t.Fatalf("active interface queued bytes=%v", got)
}
-
dev.SetInterfaceAltSetting(InterfaceMicrophone, 0)
- if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != 0 {
- t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", queued)
- }
-}
-
-func TestQueueMicrophonePCMFrameKeepsNewestMaximumFrames(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
- dev.SetInterfaceAltSetting(InterfaceMicrophone, 1)
-
- for value := 0; value <= microphoneMaximumClientFrames; value++ {
- frame := make([]byte, USBMicrophoneClientFrameSize)
- for i := range frame {
- frame[i] = byte(value)
- }
- dev.QueueMicrophonePCMFrame(frame)
- }
-
- state := dev.GetDeviceSpecificArgs()
- if queued := state["queuedMicrophoneBytes"].(int); queued != USBMicrophoneClientFrameSize*microphoneMaximumClientFrames {
- t.Fatalf("unexpected bounded queue length: %d", queued)
- }
- if dropped := state["microphoneDroppedBytes"].(uint64); dropped != USBMicrophoneClientFrameSize {
- t.Fatalf("unexpected dropped byte count: %d", dropped)
- }
- packet := dev.HandleTransfer(context.Background(),
- uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil)
- if packet[0] != 1 || packet[len(packet)-1] != 1 {
- t.Fatalf("queue did not retain the newest maximum frames: % x", packet[:8])
- }
-}
-
-func TestReadDualSenseInputStreamV2PreservesArbitraryMotionBytes(t *testing.T) {
- patterns := map[string][]byte{
- "full stream magic": {StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3},
- "marker fragment": {StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3},
- "strong CM pattern": {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT},
- "strong CP pattern": {StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2},
- "weak CM pattern": {0x01, 0x01, hidClassOUT},
- "weak CP pattern": {0x80, 0x87, StreamFrameMagic2},
- }
-
- for name, pattern := range patterns {
- t.Run(name, func(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2)
- }()
-
- state := NewInputState()
- state.LX = 12
- state.R2 = 34
- state.Buttons = ButtonCross
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- copy(inputPayload[21:], pattern)
-
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, inputPayload)); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
- if err := <-errCh; err != nil {
- t.Fatalf("readDualSenseInputStreamVersion returned error: %v", err)
- }
-
- dev.mtx.Lock()
- gotInput := dev.inputState
- dev.mtx.Unlock()
- gotPayload, err := gotInput.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- if !slices.Equal(gotPayload[21:21+len(pattern)], pattern) {
- t.Fatalf("valid motion bytes changed: got % x want % x", gotPayload[21:21+len(pattern)], pattern)
- }
- if gotInput.LX != state.LX || gotInput.R2 != state.R2 || gotInput.Buttons != state.Buttons {
- t.Fatalf("valid controls changed: got %+v want %+v", gotInput, *state)
- }
- })
- }
-}
-
-func TestReadDualSenseInputStreamV2RejectsBadCRC(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2)
- }()
-
- state := NewInputState()
- state.LX = 12
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
-
- frame := makeStreamFrameV2(StreamFrameInputState, 0, inputPayload)
- frame[len(frame)-1] ^= 0x80
- if _, err := client.Write(frame); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
-
- if err := <-errCh; err == nil || !strings.Contains(err.Error(), "CRC mismatch") {
- t.Fatalf("expected CRC mismatch, got %v", err)
- }
-}
-
-func TestReadDualSenseInputStreamV2RejectsSequenceGap(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2)
- }()
-
- state := NewInputState()
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 10, inputPayload)); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 12, inputPayload)); err != nil {
- t.Fatalf("write second input frame: %v", err)
- }
- if err := <-errCh; err == nil || !strings.Contains(err.Error(), "sequence mismatch") {
- t.Fatalf("expected sequence mismatch, got %v", err)
- }
-}
-
-func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) {
- dev, err := New(nil)
- if err != nil {
- t.Fatalf("New returned error: %v", err)
- }
-
- server, client := net.Pipe()
- defer server.Close()
-
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- readDualSenseInputStream(server, dev, logger, true)
- }()
-
- state := NewInputState()
- state.LX = -32
- state.Buttons = validDualSenseInputButtons | 0x80000000
- state.DPad = validDualSenseInputDPad | 0x80
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
-
- if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil {
- t.Fatalf("write input frame: %v", err)
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
-
- if err := <-errCh; err == nil || !strings.Contains(err.Error(), "invalid controls") {
- t.Fatalf("expected invalid controls error, got %v", err)
+ if got := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"]; got != 0 {
+ t.Fatalf("interface close retained microphone PCM: %v", got)
}
}
func TestDualSenseUpdateInputStateCopiesState(t *testing.T) {
dev, err := New(nil)
if err != nil {
- t.Fatalf("New returned error: %v", err)
+ t.Fatalf("New: %v", err)
}
-
state := NewInputState()
- state.LX = 44
- state.Buttons = ButtonCross
+ state.Buttons = ButtonTriangle
dev.UpdateInputState(state)
-
- state.LX = -91
- state.Buttons = 0xFFFFFFFF
-
+ state.Buttons = ButtonCircle
dev.mtx.Lock()
- gotInput := dev.inputState
+ got := dev.inputState.Buttons
dev.mtx.Unlock()
-
- if gotInput.LX != 44 || gotInput.Buttons != ButtonCross {
- t.Fatalf("input state should be copied before publish: got LX=%d buttons=%#x",
- gotInput.LX, gotInput.Buttons)
+ if got != ButtonTriangle {
+ t.Fatalf("device retained caller-owned state: got=%#x", got)
}
}
diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go
index 553f274a..e6376131 100644
--- a/device/dualsense/dsedge_handler.go
+++ b/device/dualsense/dsedge_handler.go
@@ -3,8 +3,6 @@ package dualsense
import (
"encoding/json"
"fmt"
- "log/slog"
- "net"
"strings"
"github.com/Alia5/VIIPER/device"
@@ -13,23 +11,10 @@ import (
)
func init() {
- api.RegisterDevice("dualsenseedge", &dsedgehandler{})
- api.RegisterDevice("dualsenseedgeext", &dsedgehandler{extendedFeedback: true})
- api.RegisterDevice("dualsenseedgecombinedext", &dsedgehandler{combinedBluetoothFeedback: true})
- api.RegisterDevice("dualsenseedgecombinedmicext", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion})
- api.RegisterDevice("dualsenseedgecombinedmicv2", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2})
- api.RegisterDevice("dualsenseedgecombinedaudioduplexv3", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV3})
- api.RegisterDevice("dualsenseedgecombinedaudioduplexv4", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV4})
- api.RegisterDevice("dualsenseedgecombinedaudioduplexv5", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV5})
+ api.RegisterDevice(DeviceTypeEdgeCombinedAudioDuplexV5, &dsedgehandler{})
}
-type dsedgehandler struct {
- extendedFeedback bool
- combinedBluetoothFeedback bool
- microphoneInput bool
- speakerOutput bool
- streamFrameVersion byte
-}
+type dsedgehandler struct{}
func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) {
if o == nil {
@@ -94,125 +79,11 @@ func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error
if err != nil {
return nil, err
}
- dse.extendedFeedback = h.extendedFeedback
- dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback
- dse.microphoneInput = h.microphoneInput
- dse.speakerOutput = h.speakerOutput
- dse.streamFrameVersion = h.streamFrameVersion
return dse, nil
}
func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc {
- return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error {
- defer func() {
- if devPtr == nil || *devPtr == nil {
- return
- }
- dse, ok := (*devPtr).(*DualSense)
- if !ok {
- slog.Warn("device is not DualSenseEdge on disconnect")
- return
- }
- dse.mtx.Lock()
- serial := dse.metaState.SerialNumber
- mac := dse.metaState.MACAddress
- dse.mtx.Unlock()
- delete(serials, serial)
- delete(macs, mac)
- slog.Debug("DualSenseEdge disconnected, serial/mac released", "serial", serial, "mac", mac)
- }()
-
- if devPtr == nil || *devPtr == nil {
- return fmt.Errorf("nil device")
- }
- dse, ok := (*devPtr).(*DualSense)
- if !ok {
- return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType)
- }
-
- microphoneInput := h.microphoneInput || dse.microphoneInput
- speakerOutput := h.speakerOutput || dse.speakerOutput
- streamFrameVersion := h.streamFrameVersion
- if dse.streamFrameVersion != 0 {
- streamFrameVersion = dse.streamFrameVersion
- }
- logger.Info("DualSense Edge input stream configured",
- "microphoneInput", microphoneInput,
- "speakerOutput", speakerOutput,
- "frameVersion", streamFrameVersion)
-
- marshalFeedback := func(feedback OutputState) ([]byte, error) {
- var data []byte
- var err error
- if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback {
- data, err = feedback.MarshalCombinedExtendedBinary()
- } else if h.extendedFeedback || dse.extendedFeedback {
- data, err = feedback.MarshalExtendedBinary()
- } else {
- data, err = feedback.MarshalBinary()
- }
- if err != nil {
- return nil, err
- }
- return data, nil
- }
-
- if speakerOutput {
- if streamFrameVersion != StreamFrameVersionV3 &&
- streamFrameVersion != StreamFrameVersionV4 &&
- streamFrameVersion != StreamFrameVersionV5 {
- return fmt.Errorf("DualSense Edge speaker output requires framed stream version 0x%02X, 0x%02X, or 0x%02X",
- StreamFrameVersionV3, StreamFrameVersionV4, StreamFrameVersionV5)
- }
-
- writer := newDualSenseOutputWriter(conn, streamFrameVersion,
- dse.beginSpeakerStream(), logger)
- go writer.Run()
- dse.SetOutputCallback(func(feedback OutputState) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal feedback", "error", err)
- return
- }
- writer.EnqueueControl(StreamFrameOutputState, data)
- })
- if streamFrameVersion == StreamFrameVersionV4 ||
- streamFrameVersion == StreamFrameVersionV5 {
- dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal atomic audio/haptics feedback", "error", err)
- return
- }
- writer.EnqueueAtomicAudioHaptics(data, speakerPCM)
- })
- } else {
- dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB)
- }
- dse.SetSpeakerResetCallback(writer.ResetSpeaker)
- defer func() {
- dse.SetOutputCallback(nil)
- dse.SetSpeakerCallback(nil)
- dse.SetAtomicAudioHapticsCallback(nil)
- dse.SetSpeakerResetCallback(nil)
- writer.Stop()
- }()
- } else {
- dse.SetOutputCallback(func(feedback OutputState) {
- data, err := marshalFeedback(feedback)
- if err != nil {
- logger.Error("failed to marshal feedback", "error", err)
- return
- }
- if _, err := conn.Write(data); err != nil {
- logger.Error("failed to send feedback", "error", err)
- }
- })
- defer dse.SetOutputCallback(nil)
- }
-
- return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion)
- }
+ return dualSenseV5StreamHandler("DualSense Edge")
}
func (h *dsedgehandler) UpdateMetaState(meta string, dev *usb.Device) error {
diff --git a/device/dualsense/native_audio_v5.go b/device/dualsense/native_audio_v5.go
index 2a4a4c70..5c692433 100644
--- a/device/dualsense/native_audio_v5.go
+++ b/device/dualsense/native_audio_v5.go
@@ -19,7 +19,21 @@ func appendDualSenseV5Speaker(dst, src []byte) []byte {
start := len(dst)
dst = append(dst, make([]byte, frames*dualSenseV5SpeakerFrameSize)...)
- copyDualSenseSpeakerChannels(dst[start:],
+ copyDualSenseV5SpeakerChannels(dst[start:],
src[:frames*USBHapticsAudioFrameSize])
return dst
}
+
+// copyDualSenseV5SpeakerChannels copies the front stereo pair from the native
+// four-channel USB endpoint. Rear channels remain on the haptics clock.
+func copyDualSenseV5SpeakerChannels(dst, src []byte) int {
+ frames := min(len(src)/USBHapticsAudioFrameSize,
+ len(dst)/dualSenseV5SpeakerFrameSize)
+ for frame := 0; frame < frames; frame++ {
+ source := frame * USBHapticsAudioFrameSize
+ destination := frame * dualSenseV5SpeakerFrameSize
+ copy(dst[destination:destination+dualSenseV5SpeakerFrameSize],
+ src[source:source+dualSenseV5SpeakerFrameSize])
+ }
+ return frames * dualSenseV5SpeakerFrameSize
+}
diff --git a/device/dualsense/native_audio_v5_test.go b/device/dualsense/native_audio_v5_test.go
index 610ec37e..0efc3089 100644
--- a/device/dualsense/native_audio_v5_test.go
+++ b/device/dualsense/native_audio_v5_test.go
@@ -265,10 +265,10 @@ func TestDualSenseV5SpeakerCombinesFreshStateWithCompletedRearSample(t *testing.
func TestDualSenseV5WriterPublishesExactAtomicContract(t *testing.T) {
server, client := net.Pipe()
- writer := newDualSenseOutputWriter(server, StreamFrameVersionV5, nil, nil)
+ writer := newDualSenseOutputWriter(server, nil, nil)
go writer.Run()
- feedback := make([]byte, OutputStateCombinedExtSize)
+ feedback := make([]byte, OutputStateV5Size)
feedback[OutputStateCombinedBluetoothOffset] = BluetoothCombinedHapticsReportID
speaker := make([]byte, dualSenseV5SpeakerPayloadSize)
for index := range speaker {
@@ -299,7 +299,7 @@ func TestDualSenseV5WriterPublishesExactAtomicContract(t *testing.T) {
}
func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) {
- writer := newDualSenseOutputWriter(nil, StreamFrameVersionV5, nil, nil)
+ writer := newDualSenseOutputWriter(nil, nil, nil)
for generation := 0; generation <= dualSenseOutputAudioQueueCapacity; generation++ {
feedback := []byte{byte(generation)}
speaker := make([]byte, dualSenseV5SpeakerPayloadSize)
@@ -342,8 +342,6 @@ func newV5CaptureDevice(t *testing.T) (*DualSense, *[]capturedV5Generation) {
if err != nil {
t.Fatalf("New returned error: %v", err)
}
- device.combinedBluetoothFeedback = true
- device.streamFrameVersion = StreamFrameVersionV5
captured := make([]capturedV5Generation, 0, 2)
device.SetAtomicAudioHapticsCallback(func(feedback OutputState, speaker []byte) {
generation := capturedV5Generation{
@@ -374,7 +372,7 @@ func makeV5USBPCM(firstFrame, frames int, rearBias int16) []byte {
func frontStereoFromUSB(pcm []byte) []byte {
front := make([]byte, len(pcm)/USBHapticsAudioFrameSize*dualSenseV5SpeakerFrameSize)
- copyDualSenseSpeakerChannels(front, pcm)
+ copyDualSenseV5SpeakerChannels(front, pcm)
return front
}
diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go
index e6cb6b41..8211f2e1 100644
--- a/device/dualsense/output_writer.go
+++ b/device/dualsense/output_writer.go
@@ -12,18 +12,10 @@ import (
const (
dualSenseOutputControlQueueCapacity = 32
dualSenseOutputAudioQueueCapacity = 64
- // Windows submits the virtual DualSense audio endpoint in ten-packet
- // USB/IP URBs. Reserve the captured maximum packet size for every packet;
- // the normal 48 kHz four-channel payload is 3,840 bytes and becomes a
- // 1,920-byte native stereo speaker block.
- // A V4 frame carries one complete 512-source-frame generation: the
- // marshalled native feedback plus its matching front-channel stereo PCM.
- // V5 carries the smaller 480-frame resampled generation. Keep the fixed pool
- // large enough for V4; V3 and V5 consume smaller prefixes from the same pool.
+ // V5 carries one 480-frame PadSense generation: the combined feedback and
+ // its matching front-channel stereo PCM.
dualSenseSpeakerPayloadCapacity = dualSenseAtomicFeedbackPrefix +
- OutputStateCombinedExtSize +
- (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample*
- 2*USBHapticsAudioBytesPerSample
+ OutputStateV5Size + dualSenseV5SpeakerPayloadSize
dualSenseSpeakerTraceInterval = 10 * time.Second
dualSenseSpeakerResetTimeout = 250 * time.Millisecond
dualSenseAtomicFeedbackPrefix = 2
@@ -117,7 +109,6 @@ type dualSenseOutputFrame struct {
// backpressure, so speaker extraction uses a fixed pool and a bounded queue.
type dualSenseOutputWriter struct {
conn net.Conn
- version byte
logger *slog.Logger
telemetry *dualSenseSpeakerStreamTelemetry
control chan dualSenseOutputFrame
@@ -137,7 +128,7 @@ type dualSenseOutputWriter struct {
lastTrace time.Time
}
-func newDualSenseOutputWriter(conn net.Conn, version byte,
+func newDualSenseOutputWriter(conn net.Conn,
telemetry *dualSenseSpeakerStreamTelemetry, logger *slog.Logger) *dualSenseOutputWriter {
if telemetry == nil {
telemetry = &dualSenseSpeakerStreamTelemetry{}
@@ -148,7 +139,6 @@ func newDualSenseOutputWriter(conn net.Conn, version byte,
telemetry.active.Store(true)
w := &dualSenseOutputWriter{
conn: conn,
- version: version,
logger: logger,
telemetry: telemetry,
control: make(chan dualSenseOutputFrame, dualSenseOutputControlQueueCapacity),
@@ -156,7 +146,7 @@ func newDualSenseOutputWriter(conn net.Conn, version byte,
audioFree: make(chan []byte, dualSenseOutputAudioQueueCapacity),
stop: make(chan struct{}),
done: make(chan struct{}),
- packet: make([]byte, 0, StreamFrameV2HeaderSize+dualSenseSpeakerPayloadCapacity),
+ packet: make([]byte, 0, StreamFrameHeaderSize+dualSenseSpeakerPayloadCapacity),
lastTrace: time.Now(),
}
w.streamViable.Store(conn != nil)
@@ -181,67 +171,15 @@ func (w *dualSenseOutputWriter) EnqueueControl(frameType byte, payload []byte) {
})
}
-// EnqueueSpeakerFromUSB extracts front-left/front-right from the native
-// DualSense 48 kHz, four-channel S16LE endpoint. Rear-left/rear-right remain
-// exclusively on the advanced-haptics lane. The extraction itself performs no
-// allocation after writer construction.
-func (w *dualSenseOutputWriter) EnqueueSpeakerFromUSB(usbPCM []byte) {
- const usbFrameSize = USBHapticsAudioFrameSize
- const speakerFrameSize = 2 * USBHapticsAudioBytesPerSample
-
- w.enqueueLock.RLock()
- defer w.enqueueLock.RUnlock()
- if w.stopped {
- return
- }
-
- framesRemaining := len(usbPCM) / usbFrameSize
- if framesRemaining == 0 {
- return
- }
- w.telemetry.receivedPayloads.Add(1)
- w.telemetry.receivedBytes.Add(uint64(framesRemaining * speakerFrameSize))
- sourceOffset := 0
- for framesRemaining > 0 {
- var buffer []byte
- select {
- case buffer = <-w.audioFree:
- default:
- w.recordSpeakerDrop(framesRemaining * speakerFrameSize)
- return
- }
-
- frames := min(framesRemaining, cap(buffer)/speakerFrameSize)
- length := copyDualSenseSpeakerChannels(buffer,
- usbPCM[sourceOffset:sourceOffset+frames*usbFrameSize])
- frame := dualSenseOutputFrame{
- frameType: StreamFrameSpeakerPCM,
- payload: buffer[:length],
- audio: true,
- generation: w.audioGeneration.Load(),
- }
- if !w.enqueueFrameLocked(w.audio, frame) {
- w.audioFree <- buffer[:cap(buffer)]
- w.recordSpeakerDrop(framesRemaining * speakerFrameSize)
- return
- }
- w.recordSpeakerEnqueue(length)
-
- framesRemaining -= frames
- sourceOffset += frames * usbFrameSize
- }
-}
-
-// EnqueueAtomicAudioHaptics publishes one V4/V5 generation. A little-endian
-// feedback length prefixes the native extended feedback; the remaining bytes
-// are the matching stereo PCM block. V5 requires exactly 480 frames.
+// EnqueueAtomicAudioHaptics publishes one V5 generation. A little-endian
+// feedback length prefixes the native combined feedback; the remaining bytes
+// are exactly 480 matching stereo PCM frames.
func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM []byte) {
if len(feedback) == 0 || len(feedback) > int(^uint16(0)) ||
len(speakerPCM) == 0 {
return
}
- if w.version == StreamFrameVersionV5 &&
- len(speakerPCM) != dualSenseV5SpeakerPayloadSize {
+ if len(speakerPCM) != dualSenseV5SpeakerPayloadSize {
return
}
@@ -286,8 +224,8 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [
w.recordSpeakerEnqueue(len(speakerPCM))
}
-// acquireAtomicAudioBuffer preserves the legacy V4 drop-new contract. V5 is
-// explicitly realtime: when TCP momentarily falls behind and every fixed pool
+// acquireAtomicAudioBuffer keeps V5 realtime: when TCP momentarily falls
+// behind and every fixed pool
// buffer is owned, evict the oldest queued (not in-flight) media generation so
// the newest native USB generation can still be published without growing an
// unbounded stale-audio reserve.
@@ -298,9 +236,6 @@ func (w *dualSenseOutputWriter) acquireAtomicAudioBuffer() []byte {
default:
}
- if w.version != StreamFrameVersionV5 {
- return nil
- }
select {
case oldest := <-w.audio:
w.recordSpeakerDrop(atomicSpeakerPCMBytes(oldest.payload))
@@ -356,22 +291,6 @@ func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) {
}
}
-// copyDualSenseSpeakerChannels copies the first stereo pair from interleaved
-// four-channel S16LE PCM into dst and returns the number of bytes written.
-func copyDualSenseSpeakerChannels(dst, src []byte) int {
- const usbFrameSize = USBHapticsAudioFrameSize
- const speakerFrameSize = 2 * USBHapticsAudioBytesPerSample
-
- frames := min(len(src)/usbFrameSize, len(dst)/speakerFrameSize)
- for frame := 0; frame < frames; frame++ {
- source := frame * usbFrameSize
- destination := frame * speakerFrameSize
- copy(dst[destination:destination+speakerFrameSize],
- src[source:source+speakerFrameSize])
- }
- return frames * speakerFrameSize
-}
-
// enqueueFrameLocked requires enqueueLock to be held for reading. Shutdown
// takes the write side before draining, so no producer can publish a frame
// after the final drain has observed an empty queue.
@@ -540,25 +459,25 @@ func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool {
if len(frame.payload) > int(^uint16(0)) {
return true
}
- packetLength := StreamFrameV2HeaderSize + len(frame.payload)
+ packetLength := StreamFrameHeaderSize + len(frame.payload)
if cap(w.packet) < packetLength {
w.packet = make([]byte, packetLength)
} else {
w.packet = w.packet[:packetLength]
}
- header := w.packet[:StreamFrameV2HeaderSize]
+ header := w.packet[:StreamFrameHeaderSize]
header[0] = StreamFrameMagic0
header[1] = StreamFrameMagic1
header[2] = StreamFrameMagic2
header[3] = StreamFrameMagic3
- header[4] = w.version
+ header[4] = StreamFrameVersionV5
header[5] = frame.frameType
binary.LittleEndian.PutUint16(header[6:8], uint16(len(frame.payload)))
binary.LittleEndian.PutUint32(header[8:12], w.sequence)
w.sequence++
binary.LittleEndian.PutUint32(header[12:16],
framedStreamCRC(header[4:12], frame.payload))
- copy(w.packet[StreamFrameV2HeaderSize:], frame.payload)
+ copy(w.packet[StreamFrameHeaderSize:], frame.payload)
remaining := w.packet
for len(remaining) > 0 {
diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go
index 1e3d836f..850de5b1 100644
--- a/device/dualsense/output_writer_test.go
+++ b/device/dualsense/output_writer_test.go
@@ -14,188 +14,98 @@ import (
"github.com/Alia5/VIIPER/usbip"
)
-func TestCopyDualSenseSpeakerChannelsSelectsFrontPairWithoutAllocation(t *testing.T) {
- const frames = 480
- source := make([]byte, frames*USBHapticsAudioFrameSize)
- for frame := 0; frame < frames; frame++ {
- offset := frame * USBHapticsAudioFrameSize
- binary.LittleEndian.PutUint16(source[offset:offset+2], uint16(int16(frame)))
- binary.LittleEndian.PutUint16(source[offset+2:offset+4], uint16(int16(-frame)))
- binary.LittleEndian.PutUint16(source[offset+4:offset+6], uint16(int16(1000+frame)))
- binary.LittleEndian.PutUint16(source[offset+6:offset+8], uint16(int16(-1000-frame)))
- }
- destination := make([]byte, frames*2*USBHapticsAudioBytesPerSample)
-
- if allocations := testing.AllocsPerRun(1000, func() {
- copyDualSenseSpeakerChannels(destination, source)
- }); allocations != 0 {
- t.Fatalf("speaker channel extraction allocated %.2f objects per run", allocations)
- }
- writer := newDualSenseOutputWriter(nil, StreamFrameVersionV3, nil, nil)
- if allocations := testing.AllocsPerRun(1000, func() {
- writer.EnqueueSpeakerFromUSB(source)
- frame := <-writer.audio
- writer.release(frame)
- }); allocations != 0 {
- t.Fatalf("speaker enqueue path allocated %.2f objects per run", allocations)
- }
-
- written := copyDualSenseSpeakerChannels(destination, source)
- if written != len(destination) {
- t.Fatalf("unexpected speaker byte count: got %d want %d", written, len(destination))
- }
- for frame := 0; frame < frames; frame++ {
- offset := frame * 4
- left := int16(binary.LittleEndian.Uint16(destination[offset : offset+2]))
- right := int16(binary.LittleEndian.Uint16(destination[offset+2 : offset+4]))
- if left != int16(frame) || right != int16(-frame) {
- t.Fatalf("speaker frame %d changed: left=%d right=%d", frame, left, right)
- }
+func testV5Media(marker byte) ([]byte, []byte) {
+ feedback := make([]byte, OutputStateV5Size)
+ feedback[OutputStateCombinedBluetoothOffset] = BluetoothCombinedHapticsReportID
+ feedback[0] = marker
+ speaker := make([]byte, dualSenseV5SpeakerPayloadSize)
+ for index := range speaker {
+ speaker[index] = marker
}
+ return feedback, speaker
}
-func TestDualSenseV3WriterFramesNativeSpeakerPairAndFeedback(t *testing.T) {
+func TestDualSenseV5WriterPublishesOnlyV5AtomicFrames(t *testing.T) {
server, client := net.Pipe()
- writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil)
+ writer := newDualSenseOutputWriter(server, nil, nil)
go writer.Run()
- usbPCM := make([]byte, 2*USBHapticsAudioFrameSize)
- copy(usbPCM[0:8], []byte{1, 2, 3, 4, 0xA1, 0xA2, 0xA3, 0xA4})
- copy(usbPCM[8:16], []byte{5, 6, 7, 8, 0xB1, 0xB2, 0xB3, 0xB4})
- writer.EnqueueSpeakerFromUSB(usbPCM)
-
+ feedback, speaker := testV5Media(0x41)
+ writer.EnqueueAtomicAudioHaptics(feedback, speaker)
header, payload := readDualSenseOutputFrame(t, client)
- if header[4] != StreamFrameVersionV3 || header[5] != StreamFrameSpeakerPCM {
- t.Fatalf("unexpected speaker frame header: % x", header)
- }
- if sequence := binary.LittleEndian.Uint32(header[8:12]); sequence != 0 {
- t.Fatalf("unexpected speaker frame sequence: %d", sequence)
+ if header[4] != StreamFrameVersionV5 ||
+ header[5] != StreamFrameAtomicAudioHaptics {
+ t.Fatalf("unexpected V5 frame header: % x", header)
}
- wantSpeaker := []byte{1, 2, 3, 4, 5, 6, 7, 8}
- if string(payload) != string(wantSpeaker) {
- t.Fatalf("speaker payload retained haptics channels: got % x want % x", payload, wantSpeaker)
+ feedbackLength := int(binary.LittleEndian.Uint16(payload[:2]))
+ if feedbackLength != len(feedback) {
+ t.Fatalf("feedback length=%d want=%d", feedbackLength, len(feedback))
}
- if got, want := binary.LittleEndian.Uint32(header[12:16]),
- framedStreamCRC(header[4:12], payload); got != want {
- t.Fatalf("speaker frame CRC mismatch: got %08X want %08X", got, want)
+ if string(payload[2:2+feedbackLength]) != string(feedback) ||
+ string(payload[2+feedbackLength:]) != string(speaker) {
+ t.Fatal("V5 writer changed an atomic generation")
}
- feedback := []byte{0x11, 0x22, 0x33}
- writer.EnqueueControl(StreamFrameOutputState, feedback)
+ control := make([]byte, OutputStateV5Size)
+ control[0] = 0x52
+ writer.EnqueueControl(StreamFrameOutputState, control)
header, payload = readDualSenseOutputFrame(t, client)
- if header[5] != StreamFrameOutputState || string(payload) != string(feedback) {
- t.Fatalf("unexpected feedback frame: header=% x payload=% x", header, payload)
- }
- if sequence := binary.LittleEndian.Uint32(header[8:12]); sequence != 1 {
- t.Fatalf("feedback did not share the speaker sequence: %d", sequence)
+ if header[4] != StreamFrameVersionV5 || header[5] != StreamFrameOutputState ||
+ binary.LittleEndian.Uint32(header[8:12]) != 1 ||
+ string(payload) != string(control) {
+ t.Fatalf("unexpected V5 control frame: header=% x payload0=%02x", header, payload[0])
}
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
+ _ = client.Close()
writer.Stop()
state := writer.telemetry.snapshot()
- if state.ReceivedPayloads != 1 || state.ReceivedBytes != 8 ||
- state.EnqueuedPayloads != 1 || state.EnqueuedBytes != 8 ||
- state.WrittenPayloads != 1 || state.WrittenBytes != 8 ||
- state.DroppedPayloads != 0 || state.WriteFailures != 0 {
- t.Fatalf("unexpected V3 speaker telemetry: %+v", state)
- }
- if state.Active || state.QueueDepth != 0 ||
- len(writer.audioFree) != dualSenseOutputAudioQueueCapacity {
- t.Fatalf("writer did not release its speaker pool: state=%+v free=%d",
- state, len(writer.audioFree))
+ if state.ReceivedPayloads != 1 || state.WrittenPayloads != 1 ||
+ state.DroppedPayloads != 0 || state.WriteFailures != 0 || state.Active {
+ t.Fatalf("unexpected V5 telemetry: %+v", state)
}
}
-func TestDualSenseV4WriterKeepsFeedbackAndSpeakerGenerationAtomic(t *testing.T) {
+func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) {
server, client := net.Pipe()
- writer := newDualSenseOutputWriter(server, StreamFrameVersionV4, nil, nil)
- go writer.Run()
-
- feedback := make([]byte, 474)
- feedback[76] = 0x36
- feedback[154] = 0x41
- speaker := make([]byte, 512*2*USBHapticsAudioBytesPerSample)
- for index := range speaker {
- speaker[index] = byte(index)
+ writer := newDualSenseOutputWriter(server, nil, nil)
+ for index := 0; index < 4; index++ {
+ writer.EnqueueControl(StreamFrameOutputState, []byte{byte(index)})
+ feedback, speaker := testV5Media(byte(index))
+ writer.EnqueueAtomicAudioHaptics(feedback, speaker)
}
- writer.EnqueueAtomicAudioHaptics(feedback, speaker)
+ go writer.Run()
- header, payload := readDualSenseOutputFrame(t, client)
- if header[4] != StreamFrameVersionV4 ||
- header[5] != StreamFrameAtomicAudioHaptics {
- t.Fatalf("unexpected atomic frame header: % x", header)
- }
- feedbackLength := int(binary.LittleEndian.Uint16(payload[:2]))
- if feedbackLength != len(feedback) {
- t.Fatalf("unexpected atomic feedback length: got %d want %d",
- feedbackLength, len(feedback))
- }
- if got := payload[2 : 2+feedbackLength]; string(got) != string(feedback) {
- t.Fatal("atomic frame changed native feedback")
- }
- if got := payload[2+feedbackLength:]; string(got) != string(speaker) {
- t.Fatal("atomic frame changed matching speaker PCM")
+ for index := 0; index < 8; index++ {
+ header, _ := readDualSenseOutputFrame(t, client)
+ want := byte(StreamFrameOutputState)
+ if index%2 != 0 {
+ want = StreamFrameAtomicAudioHaptics
+ }
+ if header[5] != want {
+ t.Fatalf("frame %d type=0x%02X want=0x%02X", index, header[5], want)
+ }
}
-
- _ = client.Close()
writer.Stop()
- state := writer.telemetry.snapshot()
- if state.ReceivedPayloads != 1 || state.ReceivedBytes != uint64(len(speaker)) ||
- state.EnqueuedPayloads != 1 || state.WrittenPayloads != 1 ||
- state.DroppedPayloads != 0 {
- t.Fatalf("unexpected V4 atomic telemetry: %+v", state)
- }
+ _ = client.Close()
}
-func TestDualSenseV3WriterShutdownReturnsEveryQueuedSpeakerBuffer(t *testing.T) {
+func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) {
server, client := net.Pipe()
- writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil)
- go writer.Run()
-
- usbPCM := make([]byte, 480*USBHapticsAudioFrameSize)
- for packet := 0; packet < 10; packet++ {
- writer.EnqueueSpeakerFromUSB(usbPCM)
+ writer := newDualSenseOutputWriter(server, nil, nil)
+ for index := 0; index < 10; index++ {
+ feedback, speaker := testV5Media(byte(index))
+ writer.EnqueueAtomicAudioHaptics(feedback, speaker)
}
+ go writer.Run()
writer.Stop()
_ = client.Close()
state := writer.telemetry.snapshot()
if state.Active || state.QueueDepth != 0 || len(writer.audio) != 0 ||
len(writer.audioFree) != dualSenseOutputAudioQueueCapacity {
- t.Fatalf("shutdown retained speaker buffers: state=%+v queued=%d free=%d",
+ t.Fatalf("shutdown retained buffers: state=%+v queued=%d free=%d",
state, len(writer.audio), len(writer.audioFree))
}
- if state.ReceivedPayloads != 10 || state.EnqueuedPayloads != 10 ||
- state.DroppedPayloads != 0 {
- t.Fatalf("unexpected shutdown telemetry: %+v", state)
- }
-}
-
-func TestDualSenseV3WriterAlternatesFeedbackAndSpeakerUnderPressure(t *testing.T) {
- server, client := net.Pipe()
- writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil)
- usbPCM := make([]byte, USBHapticsAudioFrameSize)
- for frame := 0; frame < 4; frame++ {
- writer.EnqueueControl(StreamFrameOutputState, []byte{byte(frame)})
- writer.EnqueueSpeakerFromUSB(usbPCM)
- }
- go writer.Run()
-
- for frame := 0; frame < 8; frame++ {
- header, _ := readDualSenseOutputFrame(t, client)
- wantType := byte(StreamFrameOutputState)
- if frame%2 != 0 {
- wantType = StreamFrameSpeakerPCM
- }
- if header[5] != wantType {
- t.Fatalf("frame %d starved one output lane: got type 0x%02X want 0x%02X",
- frame, header[5], wantType)
- }
- }
- writer.Stop()
- _ = client.Close()
}
type writeStartedConn struct {
@@ -221,9 +131,7 @@ type deadlineTrackingConn struct {
func newDeadlineTrackingConn(conn net.Conn) *deadlineTrackingConn {
return &deadlineTrackingConn{
- Conn: conn,
- started: make(chan struct{}),
- closed: make(chan struct{}),
+ Conn: conn, started: make(chan struct{}), closed: make(chan struct{}),
}
}
@@ -251,28 +159,22 @@ func (c *deadlineTrackingConn) writeDeadlines() []time.Time {
return append([]time.Time(nil), c.deadlines...)
}
-func TestDualSenseV3WriterWriteFailureCannotRaceFinalDrain(t *testing.T) {
+func TestDualSenseV5WriterWriteFailureCannotRaceFinalDrain(t *testing.T) {
server, client := net.Pipe()
conn := &writeStartedConn{Conn: server, started: make(chan struct{})}
- writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil)
+ writer := newDualSenseOutputWriter(conn, nil, nil)
writer.EnqueueControl(StreamFrameOutputState, []byte{0x01})
go writer.Run()
<-conn.started
- // Model a producer that has already entered the enqueue critical section
- // when the socket fails. Run must wait for it, stop all later producers,
- // and only then perform its final drain.
writer.enqueueLock.RLock()
buffer := <-writer.audioFree
buffer[0] = 0x55
writer.audio <- dualSenseOutputFrame{
- frameType: StreamFrameSpeakerPCM,
- payload: buffer[:4],
- audio: true,
- }
- if err := client.Close(); err != nil {
- t.Fatalf("close failed client: %v", err)
+ frameType: StreamFrameAtomicAudioHaptics,
+ payload: buffer[:4], audio: true,
}
+ _ = client.Close()
writer.enqueueLock.RUnlock()
select {
@@ -282,435 +184,155 @@ func TestDualSenseV3WriterWriteFailureCannotRaceFinalDrain(t *testing.T) {
}
if len(writer.audio) != 0 ||
len(writer.audioFree) != dualSenseOutputAudioQueueCapacity {
- t.Fatalf("write-failure shutdown retained a pooled buffer: queued=%d free=%d",
+ t.Fatalf("shutdown retained a pooled buffer: queued=%d free=%d",
len(writer.audio), len(writer.audioFree))
}
- before := writer.telemetry.snapshot()
- writer.EnqueueSpeakerFromUSB(make([]byte, USBHapticsAudioFrameSize))
- after := writer.telemetry.snapshot()
- if after.ReceivedPayloads != before.ReceivedPayloads ||
- after.DroppedPayloads != before.DroppedPayloads {
- t.Fatalf("stopped writer accepted a stale callback: before=%+v after=%+v",
- before, after)
- }
}
-func TestDualSenseV3WriterResetIsHardWriteGenerationBarrier(t *testing.T) {
+func TestDualSenseV5WriterResetIsHardGenerationBarrier(t *testing.T) {
server, client := net.Pipe()
conn := newDeadlineTrackingConn(server)
- writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil)
-
- oldPCM := make([]byte, USBHapticsAudioFrameSize)
- oldPCM[0] = 0x11
- writer.EnqueueSpeakerFromUSB(oldPCM)
+ writer := newDualSenseOutputWriter(conn, nil, nil)
+ oldFeedback, oldSpeaker := testV5Media(0x11)
+ writer.EnqueueAtomicAudioHaptics(oldFeedback, oldSpeaker)
go writer.Run()
<-conn.started
- queuedOldPCM := make([]byte, USBHapticsAudioFrameSize)
- queuedOldPCM[0] = 0x12
- writer.EnqueueSpeakerFromUSB(queuedOldPCM)
+ queuedFeedback, queuedSpeaker := testV5Media(0x12)
+ writer.EnqueueAtomicAudioHaptics(queuedFeedback, queuedSpeaker)
resetDone := make(chan struct{})
- go func() {
- writer.ResetSpeaker()
- close(resetDone)
- }()
+ go func() { writer.ResetSpeaker(); close(resetDone) }()
select {
case <-resetDone:
- t.Fatal("speaker reset crossed an in-flight old-generation write")
+ t.Fatal("reset crossed an in-flight old-generation write")
case <-time.After(20 * time.Millisecond):
}
-
_, oldPayload := readDualSenseOutputFrame(t, client)
- if len(oldPayload) != 4 || oldPayload[0] != 0x11 {
- t.Fatalf("unexpected in-flight old-generation payload: % x", oldPayload)
+ oldLength := int(binary.LittleEndian.Uint16(oldPayload[:2]))
+ if oldPayload[2] != 0x11 || oldLength != OutputStateV5Size {
+ t.Fatalf("unexpected in-flight generation: % x", oldPayload[:4])
}
select {
case <-resetDone:
case <-time.After(time.Second):
- t.Fatal("speaker reset did not finish after the in-flight write")
- }
- deadlines := conn.writeDeadlines()
- if len(deadlines) != 2 || deadlines[0].IsZero() || !deadlines[1].IsZero() {
- t.Fatalf("viable reset did not arm and clear its write deadline: %v", deadlines)
+ t.Fatal("reset did not finish")
}
if len(writer.audio) != 0 {
- t.Fatalf("speaker reset retained %d queued old-generation frames", len(writer.audio))
+ t.Fatalf("reset retained %d old media frames", len(writer.audio))
}
- newPCM := make([]byte, USBHapticsAudioFrameSize)
- newPCM[0] = 0x22
- writer.EnqueueSpeakerFromUSB(newPCM)
+ newFeedback, newSpeaker := testV5Media(0x22)
+ writer.EnqueueAtomicAudioHaptics(newFeedback, newSpeaker)
_, newPayload := readDualSenseOutputFrame(t, client)
- if len(newPayload) != 4 || newPayload[0] != 0x22 {
- t.Fatalf("unexpected post-reset payload: % x", newPayload)
+ if newPayload[2] != 0x22 {
+ t.Fatalf("post-reset frame is stale: % x", newPayload[:4])
}
-
writer.Stop()
_ = client.Close()
}
-func TestDualSenseV3WriterResetBoundsBlockedWriteAndClosesStream(t *testing.T) {
+func TestDualSenseV5WriterResetBoundsBlockedWrite(t *testing.T) {
server, client := net.Pipe()
conn := newDeadlineTrackingConn(server)
- writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil)
-
- inFlightPCM := make([]byte, USBHapticsAudioFrameSize)
- inFlightPCM[0] = 0x31
- writer.EnqueueSpeakerFromUSB(inFlightPCM)
+ writer := newDualSenseOutputWriter(conn, nil, nil)
+ feedback, speaker := testV5Media(0x31)
+ writer.EnqueueAtomicAudioHaptics(feedback, speaker)
go writer.Run()
<-conn.started
- queuedPCM := make([]byte, USBHapticsAudioFrameSize)
- queuedPCM[0] = 0x32
- writer.EnqueueSpeakerFromUSB(queuedPCM)
-
resetDone := make(chan struct{})
- go func() {
- writer.ResetSpeaker()
- close(resetDone)
- }()
+ go func() { writer.ResetSpeaker(); close(resetDone) }()
select {
case <-resetDone:
case <-time.After(time.Second):
- t.Fatal("speaker reset remained blocked after its write deadline")
+ t.Fatal("reset remained blocked after write deadline")
}
select {
case <-writer.done:
case <-time.After(time.Second):
- t.Fatal("timed-out speaker write did not stop the failed stream")
- }
- select {
- case <-conn.closed:
- default:
- t.Fatal("timed-out speaker write did not close the failed stream")
+ t.Fatal("timed-out write did not stop the stream")
}
-
- deadlines := conn.writeDeadlines()
- if len(deadlines) != 1 || deadlines[0].IsZero() {
- t.Fatalf("failed stream deadline was cleared or not armed: %v", deadlines)
- }
- if writer.streamViable.Load() {
- t.Fatal("timed-out speaker stream remained marked viable")
- }
- if writer.audioGeneration.Load() != 1 || len(writer.audio) != 0 ||
+ if writer.streamViable.Load() || len(writer.audio) != 0 ||
len(writer.audioFree) != dualSenseOutputAudioQueueCapacity {
- t.Fatalf("timed-out reset retained stale audio: generation=%d queued=%d free=%d",
- writer.audioGeneration.Load(), len(writer.audio), len(writer.audioFree))
+ t.Fatal("failed stream retained V5 transport state")
}
-
- buffer := make([]byte, StreamFrameV2HeaderSize+4)
+ buffer := make([]byte, StreamFrameHeaderSize+4)
if count, err := client.Read(buffer); count != 0 || err == nil {
- t.Fatalf("failed stream replayed stale audio: bytes=%d error=%v payload=% x",
- count, err, buffer[:count])
+ t.Fatalf("failed stream replayed stale media: bytes=%d err=%v", count, err)
}
-
- before := writer.telemetry.snapshot()
- writer.EnqueueSpeakerFromUSB(make([]byte, USBHapticsAudioFrameSize))
- after := writer.telemetry.snapshot()
- if after.ReceivedPayloads != before.ReceivedPayloads || len(writer.audio) != 0 {
- t.Fatalf("failed writer accepted audio after reconnect was required: before=%+v after=%+v queued=%d",
- before, after, len(writer.audio))
- }
-
writer.Stop()
_ = client.Close()
}
-func TestDualSenseAndEdgeV3VariantsForwardSpeakerAndAcceptInput(t *testing.T) {
+func TestDualSenseAndEdgeV5HandlersUseAtomicPadSenseContract(t *testing.T) {
for _, edge := range []bool{false, true} {
name := "DualSense"
+ var dev usb.Device
+ var handler func(net.Conn, *usb.Device, *slog.Logger) error
+ var err error
if edge {
name = "DualSense Edge"
+ variant := &dsedgehandler{}
+ dev, err = variant.CreateDevice(nil)
+ handler = variant.StreamHandler()
+ } else {
+ variant := &dshandler{}
+ dev, err = variant.CreateDevice(nil)
+ handler = variant.StreamHandler()
}
- t.Run(name, func(t *testing.T) {
- var dev usb.Device
- var err error
- var streamHandler func(net.Conn, *usb.Device, *slog.Logger) error
- if edge {
- variant := &dsedgehandler{
- combinedBluetoothFeedback: true,
- microphoneInput: true,
- speakerOutput: true,
- streamFrameVersion: StreamFrameVersionV3,
- }
- dev, err = variant.CreateDevice(nil)
- streamHandler = (&dsedgehandler{}).StreamHandler()
- } else {
- variant := &dshandler{
- combinedBluetoothFeedback: true,
- microphoneInput: true,
- speakerOutput: true,
- streamFrameVersion: StreamFrameVersionV3,
- }
- dev, err = variant.CreateDevice(nil)
- streamHandler = (&dshandler{}).StreamHandler()
- }
- if err != nil {
- t.Fatalf("CreateDevice returned error: %v", err)
- }
-
- server, client := net.Pipe()
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- streamHandler(server, &dev, logger)
- }()
-
- state := NewInputState()
- state.LX = 73
- state.Buttons = ButtonCross
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState,
- 0, inputPayload)); err != nil {
- t.Fatalf("write V3 input state: %v", err)
- }
-
- dualSense := dev.(*DualSense)
- dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
- usbPCM := []byte{
- 0x01, 0x02, 0x03, 0x04, 0xA1, 0xA2, 0xA3, 0xA4,
- 0x05, 0x06, 0x07, 0x08, 0xB1, 0xB2, 0xB3, 0xB4,
- }
- dualSense.HandleTransfer(context.Background(),
- EndpointHapticsAudioOut, usbip.DirOut, usbPCM)
-
- header, payload := readDualSenseOutputFrame(t, client)
- if header[4] != StreamFrameVersionV3 ||
- header[5] != StreamFrameSpeakerPCM {
- t.Fatalf("unexpected V3 speaker frame: % x", header)
- }
- want := []byte{0x01, 0x02, 0x03, 0x04,
- 0x05, 0x06, 0x07, 0x08}
- if string(payload) != string(want) {
- t.Fatalf("unexpected native speaker pair: got % x want % x",
- payload, want)
- }
-
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
- if err := <-errCh; err != nil {
- t.Fatalf("V3 stream handler returned error: %v", err)
- }
-
- dualSense.mtx.Lock()
- gotInput := dualSense.inputState
- callbacksCleared := dualSense.outputFunc == nil &&
- dualSense.speakerFunc == nil &&
- dualSense.speakerResetFunc == nil
- dualSense.mtx.Unlock()
- if gotInput.LX != state.LX || gotInput.Buttons != state.Buttons {
- t.Fatalf("V3 input changed: got %+v want %+v", gotInput, state)
- }
- if !callbacksCleared {
- t.Fatal("V3 handler retained a callback after shutdown")
- }
- speakerState := dualSense.GetDeviceSpecificArgs()
- if speakerState["speakerStreamActive"].(bool) ||
- speakerState["speakerPayloadsReceived"].(uint64) != 1 ||
- speakerState["speakerPayloadsDropped"].(uint64) != 0 ||
- speakerState["speakerPayloadsWritten"].(uint64) != 1 {
- t.Fatalf("unexpected exposed speaker state: %+v", speakerState)
- }
- })
- }
-}
-
-func TestDualSenseV4HandlerPairsTheSameUSBGeneration(t *testing.T) {
- variant := &dshandler{
- combinedBluetoothFeedback: true,
- microphoneInput: true,
- speakerOutput: true,
- streamFrameVersion: StreamFrameVersionV4,
- }
- dev, err := variant.CreateDevice(nil)
- if err != nil {
- t.Fatalf("CreateDevice returned error: %v", err)
- }
-
- server, client := net.Pipe()
- errCh := make(chan error, 1)
- go func() {
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- variant.StreamHandler()(server, &dev, logger)
- }()
-
- state := NewInputState()
- inputPayload, err := state.MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
- }
- if _, err := client.Write(makeStreamFrameWithCRC(StreamFrameVersionV4,
- StreamFrameInputState, 0, inputPayload)); err != nil {
- t.Fatalf("write V4 input state: %v", err)
- }
-
- dualSense := dev.(*DualSense)
- dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
- usbPCM := make([]byte, 512*USBHapticsAudioFrameSize)
- negativeRearSample := int16(-12000)
- for frame := 0; frame < 512; frame++ {
- offset := frame * USBHapticsAudioFrameSize
- binary.LittleEndian.PutUint16(usbPCM[offset:offset+2],
- uint16(int16(frame+1)))
- binary.LittleEndian.PutUint16(usbPCM[offset+2:offset+4],
- uint16(int16(-frame-1)))
- binary.LittleEndian.PutUint16(usbPCM[offset+4:offset+6],
- uint16(int16(12000)))
- binary.LittleEndian.PutUint16(usbPCM[offset+6:offset+8],
- uint16(negativeRearSample))
- }
- dualSense.HandleTransfer(context.Background(),
- EndpointHapticsAudioOut, usbip.DirOut, usbPCM)
-
- header, payload := readDualSenseOutputFrame(t, client)
- if header[4] != StreamFrameVersionV4 ||
- header[5] != StreamFrameAtomicAudioHaptics {
- t.Fatalf("unexpected V4 atomic frame: % x", header)
- }
- feedbackLength := int(binary.LittleEndian.Uint16(payload[:2]))
- feedback := payload[2 : 2+feedbackLength]
- speaker := payload[2+feedbackLength:]
- if feedbackLength != 474 || len(speaker) != 512*4 {
- t.Fatalf("unexpected V4 generation sizes: feedback=%d speaker=%d",
- feedbackLength, len(speaker))
- }
- if feedback[76] != 0x36 || feedback[152] != 0x92 ||
- feedback[153] != BluetoothHapticsSampleSize {
- t.Fatalf("atomic feedback omitted combined haptics: % x",
- feedback[76:154])
- }
- for frame := 0; frame < 512; frame++ {
- offset := frame * 4
- left := int16(binary.LittleEndian.Uint16(speaker[offset : offset+2]))
- right := int16(binary.LittleEndian.Uint16(speaker[offset+2 : offset+4]))
- if left != int16(frame+1) || right != int16(-frame-1) {
- t.Fatalf("speaker generation diverged at frame %d: %d/%d",
- frame, left, right)
+ if err != nil {
+ t.Fatalf("%s CreateDevice: %v", name, err)
}
- }
- _ = client.Close()
- if err := <-errCh; err != nil {
- t.Fatalf("V4 stream handler returned error: %v", err)
- }
-}
-
-func TestDualSenseV3ReplacementOwnsTelemetryAndClearsCallbacks(t *testing.T) {
- variant := &dshandler{
- combinedBluetoothFeedback: true,
- microphoneInput: true,
- speakerOutput: true,
- streamFrameVersion: StreamFrameVersionV3,
- }
- dev, err := variant.CreateDevice(nil)
- if err != nil {
- t.Fatalf("CreateDevice returned error: %v", err)
- }
- dualSense := dev.(*DualSense)
- dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
- streamHandler := (&dshandler{}).StreamHandler()
-
- runStream := func(speakerPayloads int) *dualSenseSpeakerStreamTelemetry {
server, client := net.Pipe()
errCh := make(chan error, 1)
go func() {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- errCh <- streamHandler(server, &dev, logger)
+ errCh <- handler(server, &dev, logger)
}()
+ input, _ := NewInputState().MarshalBinary()
+ if _, err := client.Write(makeV5StreamFrame(StreamFrameInputState, 0, input)); err != nil {
+ t.Fatalf("%s write input: %v", name, err)
+ }
- inputPayload, err := NewInputState().MarshalBinary()
- if err != nil {
- t.Fatalf("MarshalBinary returned error: %v", err)
+ controller := dev.(*DualSense)
+ controller.SetInterfaceAltSetting(InterfaceHapticsAudio, 1)
+ pcm := make([]byte, dualSenseV5SpeakerFrames*USBHapticsAudioFrameSize)
+ for frame := 0; frame < dualSenseV5SpeakerFrames; frame++ {
+ offset := frame * USBHapticsAudioFrameSize
+ binary.LittleEndian.PutUint16(pcm[offset:offset+2], uint16(frame+1))
}
- if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState,
- 0, inputPayload)); err != nil {
- t.Fatalf("write V3 input state: %v", err)
+ controller.HandleTransfer(context.Background(), EndpointHapticsAudioOut,
+ usbip.DirOut, pcm)
+ header, payload := readDualSenseOutputFrame(t, client)
+ if header[4] != StreamFrameVersionV5 ||
+ header[5] != StreamFrameAtomicAudioHaptics {
+ t.Fatalf("%s emitted non-V5 transport: % x", name, header)
}
-
- usbPCM := make([]byte, USBHapticsAudioFrameSize)
- for payload := 0; payload < speakerPayloads; payload++ {
- usbPCM[0] = byte(payload + 1)
- dualSense.HandleTransfer(context.Background(),
- EndpointHapticsAudioOut, usbip.DirOut, usbPCM)
- header, got := readDualSenseOutputFrame(t, client)
- if header[5] != StreamFrameSpeakerPCM || len(got) != 4 ||
- got[0] != usbPCM[0] {
- t.Fatalf("unexpected replacement speaker frame: header=% x payload=% x",
- header, got)
- }
+ feedbackLength := int(binary.LittleEndian.Uint16(payload[:2]))
+ if feedbackLength != OutputStateV5Size ||
+ len(payload[2+feedbackLength:]) != dualSenseV5SpeakerPayloadSize {
+ t.Fatalf("%s emitted wrong generation sizes", name)
}
- if err := client.Close(); err != nil {
- t.Fatalf("close client pipe: %v", err)
- }
+ _ = client.Close()
if err := <-errCh; err != nil {
- t.Fatalf("V3 stream handler returned error: %v", err)
+ t.Fatalf("%s stream handler: %v", name, err)
}
-
- dualSense.mtx.Lock()
- telemetry := dualSense.speakerStreamTelemetry
- callbacksCleared := dualSense.outputFunc == nil &&
- dualSense.speakerFunc == nil &&
- dualSense.speakerResetFunc == nil
- dualSense.mtx.Unlock()
+ controller.mtx.Lock()
+ callbacksCleared := controller.outputFunc == nil &&
+ controller.atomicAudioHapticsFunc == nil &&
+ controller.speakerResetFunc == nil
+ controller.mtx.Unlock()
if !callbacksCleared {
- t.Fatal("finished V3 stream retained a callback")
+ t.Fatalf("%s retained callbacks after shutdown", name)
}
- return telemetry
- }
-
- first := runStream(1)
- second := runStream(2)
- if first == second {
- t.Fatal("replacement V3 stream reused the previous telemetry generation")
- }
- first.receivedPayloads.Add(100)
- state := dualSense.GetDeviceSpecificArgs()
- if state["speakerStreamActive"].(bool) ||
- state["speakerPayloadsReceived"].(uint64) != 2 ||
- state["speakerPayloadsEnqueued"].(uint64) != 2 ||
- state["speakerPayloadsWritten"].(uint64) != 2 ||
- state["speakerPayloadsDropped"].(uint64) != 0 {
- t.Fatalf("stale V3 generation changed replacement telemetry: %+v", state)
- }
-}
-
-func TestDualSenseOutputWriterResetFlushesSpeakerGeneration(t *testing.T) {
- telemetry := &dualSenseSpeakerStreamTelemetry{}
- writer := newDualSenseOutputWriter(nil, StreamFrameVersionV3, telemetry, nil)
- usbPCM := make([]byte, USBHapticsAudioFrameSize)
- usbPCM[0] = 0x55
-
- writer.EnqueueSpeakerFromUSB(usbPCM)
- if len(writer.audio) != 1 {
- t.Fatal("speaker reset precondition did not queue audio")
- }
- writer.ResetSpeaker()
- if len(writer.audio) != 0 || len(writer.audioFree) != dualSenseOutputAudioQueueCapacity {
- t.Fatalf("speaker reset retained pooled audio: queued=%d free=%d",
- len(writer.audio), len(writer.audioFree))
- }
- if writer.audioGeneration.Load() != 1 || telemetry.droppedPayloads.Load() != 0 {
- t.Fatalf("unexpected reset generation/telemetry: generation=%d drops=%d",
- writer.audioGeneration.Load(), telemetry.droppedPayloads.Load())
- }
-
- writer.EnqueueSpeakerFromUSB(usbPCM)
- frame := <-writer.audio
- if frame.generation != 1 || len(frame.payload) != 4 || frame.payload[0] != 0x55 {
- t.Fatalf("post-reset frame used stale generation or payload: generation=%d payload=% x",
- frame.generation, frame.payload)
}
- writer.release(frame)
}
func readDualSenseOutputFrame(t *testing.T, reader io.Reader) ([]byte, []byte) {
t.Helper()
- header := make([]byte, StreamFrameV2HeaderSize)
+ header := make([]byte, StreamFrameHeaderSize)
if _, err := io.ReadFull(reader, header); err != nil {
t.Fatalf("read output frame header: %v", err)
}
diff --git a/device/dualsense/state.go b/device/dualsense/state.go
index ce2d9fb2..7f852bb6 100644
--- a/device/dualsense/state.go
+++ b/device/dualsense/state.go
@@ -3,6 +3,7 @@ package dualsense
import (
"encoding/binary"
"encoding/json"
+ "fmt"
"io"
"log/slog"
"time"
@@ -124,57 +125,13 @@ type OutputState struct {
TriggerL2Frequency uint8
RawOutputReport [OutputReportSize]byte
- BluetoothHapticsOutputReport [BluetoothHapticsReportSize]byte
BluetoothCombinedOutputReport [BluetoothCombinedHapticsReportSize]byte
}
-func (f *OutputState) MarshalBinary() ([]byte, error) {
- return []byte{
- f.RumbleSmall,
- f.RumbleLarge,
- f.LedRed,
- f.LedGreen,
- f.LedBlue,
- f.PlayerLeds,
- }, nil
-}
-
-func (f *OutputState) MarshalExtendedBinary() ([]byte, error) {
- b := make([]byte, OutputStateExtSize)
- b[0] = f.RumbleSmall
- b[1] = f.RumbleLarge
- b[2] = f.LedRed
- b[3] = f.LedGreen
- b[4] = f.LedBlue
- b[5] = f.PlayerLeds
-
- b[6] = f.TriggerR2Mode
- b[7] = f.TriggerR2StartResistance
- b[8] = f.TriggerR2EffectForce
- b[9] = f.TriggerR2RangeForce
- b[10] = f.TriggerR2NearReleaseStrength
- b[11] = f.TriggerR2NearMiddleStrength
- b[12] = f.TriggerR2PressedStrength
- b[15] = f.TriggerR2Frequency
-
- b[17] = f.TriggerL2Mode
- b[18] = f.TriggerL2StartResistance
- b[19] = f.TriggerL2EffectForce
- b[20] = f.TriggerL2RangeForce
- b[21] = f.TriggerL2NearReleaseStrength
- b[22] = f.TriggerL2NearMiddleStrength
- b[23] = f.TriggerL2PressedStrength
- b[26] = f.TriggerL2Frequency
- copy(b[OutputStateRawReportOffset:], f.RawOutputReport[:])
- copy(b[OutputStateBluetoothHapticsOffset:], f.BluetoothHapticsOutputReport[:])
- return b, nil
-}
-
-// MarshalCombinedExtendedBinary emits the versioned vDS-style 0x36 feedback
-// extension. It intentionally does not append the legacy 0x32 report: stream
-// consumers must select exactly one framing contract.
-func (f *OutputState) MarshalCombinedExtendedBinary() ([]byte, error) {
- b := make([]byte, OutputStateCombinedExtSize)
+// MarshalV5Binary emits the single PadSense transport feedback contract:
+// compact state, native USB output report, and combined Bluetooth carrier.
+func (f *OutputState) MarshalV5Binary() ([]byte, error) {
+ b := make([]byte, OutputStateV5Size)
b[0] = f.RumbleSmall
b[1] = f.RumbleLarge
b[2] = f.LedRed
@@ -204,9 +161,11 @@ func (f *OutputState) MarshalCombinedExtendedBinary() ([]byte, error) {
return b, nil
}
-func (f *OutputState) UnmarshalBinary(data []byte) error {
- if len(data) < OutputStateSize {
- return io.ErrUnexpectedEOF
+// UnmarshalV5Binary accepts only the production PadSense feedback payload.
+// Legacy compact and partially extended payloads are deliberately rejected.
+func (f *OutputState) UnmarshalV5Binary(data []byte) error {
+ if len(data) != OutputStateV5Size {
+ return fmt.Errorf("invalid DualSense V5 feedback length %d, expected %d", len(data), OutputStateV5Size)
}
f.RumbleSmall = data[0]
f.RumbleLarge = data[1]
@@ -214,10 +173,6 @@ func (f *OutputState) UnmarshalBinary(data []byte) error {
f.LedGreen = data[3]
f.LedBlue = data[4]
f.PlayerLeds = data[5]
- if len(data) < OutputStateCompatExtSize {
- return nil
- }
-
f.TriggerR2Mode = data[6]
f.TriggerR2StartResistance = data[7]
f.TriggerR2EffectForce = data[8]
@@ -234,18 +189,17 @@ func (f *OutputState) UnmarshalBinary(data []byte) error {
f.TriggerL2NearMiddleStrength = data[22]
f.TriggerL2PressedStrength = data[23]
f.TriggerL2Frequency = data[26]
- if len(data) >= OutputStateCombinedExtSize {
- copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateCombinedBluetoothOffset])
- copy(f.BluetoothCombinedOutputReport[:], data[OutputStateCombinedBluetoothOffset:OutputStateCombinedExtSize])
- } else if len(data) >= OutputStateExtSize {
- copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateBluetoothHapticsOffset])
- copy(f.BluetoothHapticsOutputReport[:], data[OutputStateBluetoothHapticsOffset:OutputStateExtSize])
- } else if len(data) >= OutputStateBluetoothHapticsOffset {
- copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateBluetoothHapticsOffset])
- }
+ copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateCombinedBluetoothOffset])
+ copy(f.BluetoothCombinedOutputReport[:], data[OutputStateCombinedBluetoothOffset:OutputStateV5Size])
return nil
}
+// UnmarshalBinary implements encoding.BinaryUnmarshaler using the strict V5
+// contract. It intentionally provides no compact or partial compatibility.
+func (f *OutputState) UnmarshalBinary(data []byte) error {
+ return f.UnmarshalV5Binary(data)
+}
+
func encodeTouchStatus(active bool, tracking uint8) uint8 {
if tracking != 0 {
if active {
diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md
index 69f62906..548db620 100644
--- a/docs/devices/dualsense.md
+++ b/docs/devices/dualsense.md
@@ -1,281 +1,101 @@
-# DualSense Controller
-
-The DualSense virtual gamepad emulates a complete PlayStation 5 DualSense
-controller connected via USB.
-The DualSense Edge variant is also supported and uses the same wire/state model.
-
-It supports sticks, triggers, D-pad, face/shoulder buttons, PS button,
-touchpad click, back paddles/function buttons (Edge variant), mic mute button,
-IMU (gyro + accelerometer), and touchpad finger coordinates.
-
-=== "TCP API"
-
- Use `dualsense` as the default device type when adding a device via the API
- or client libraries.
-
- Use `dualsenseedge` when you want the Edge variant.
-
- ## Client Library Support
-
- The wire protocol is abstracted by client libraries.
- The **Go client** includes built-in types (`/device/dualsense`),
- and **generated client libraries** provide equivalent structures
- with proper packing.
-
- You don't need to manually construct packets, just use the provided types
- and send/receive them via the device control and feedback stream.
-
- See: [API Reference](../api/overview.md)
-
- ## (RAW) Streaming protocol
-
- The device stream is a bidirectional, raw TCP connection with fixed-size
- packets.
-
- ### Input State
-
- - 33-byte packets, little-endian layout:
- - Sticks: StickLX, StickLY, StickRX, StickRY: int8 each (4 bytes)
- -128 to 127 per axis (-128=min, 0=center, 127=max)
- - Buttons: uint32 (4 bytes, bitfield)
- - DPad: uint8 (1 byte, bitfield)
- - Triggers: TriggerL2, TriggerR2: uint8, uint8 (2 bytes)
- 0-255 (0=not pressed, 255=fully pressed)
- - Touch1: Touch1X, Touch1Y: uint16 each, Touch1Active: status byte (5 bytes)
- - Touch2: Touch2X, Touch2Y: uint16 each, Touch2Active: status byte (5 bytes)
- - Gyroscope: GyroX, GyroY, GyroZ: int16 each (6 bytes, raw report
- values)
- - Accelerometer: AccelX, AccelY, AccelZ: int16 each
- (6 bytes, raw report values)
-
- See `/device/dualsense/state.go` for details.
-
- ### Feedback (Rumble & LED)
-
- - Base `dualsense` / `dualsenseedge` streams send 6-byte packets:
- - RumbleSmall: uint8, RumbleLarge: uint8 (2 bytes), 0-255 intensity
- values
- - LED Color: LedRed, LedGreen, LedBlue: uint8 each (3 bytes), 0-255 per
- channel
- - PlayerLeds: uint8 (1 byte), host-controlled player indicator LED mask
- - Extended `dualsenseext` / `dualsenseedgeext` streams send 217-byte
- VIIPER feedback packets. Bytes 0..27 preserve the legacy compact
- feedback layout: base rumble/LED bytes plus native-spaced trigger
- blocks. Bytes 28..75 contain the native USB HID output report `0x02`
- exactly as sent by the host, allowing clients to forward DualSense
- haptics/control flags instead of reducing them to generic rumble. Bytes
- 76..216 contain one optional Bluetooth HID haptics report `0x32` built
- from the experimental 3 kHz stereo haptics/audio OUT endpoint. A zero
- report ID means no haptics frame is present for that feedback packet.
- Bytes 0..5 preserve the original rumble/LED layout above. Bytes 6..16
- contain the R2 adaptive-trigger effect block copied from USB output
- report 0x02 with the same reserved gaps used by the native report.
- Bytes 17..27 contain the L2 adaptive-trigger effect block with the same
- layout. Each trigger block is 11 bytes:
- - Mode
- - StartResistance
- - EffectForce
- - RangeForce
- - NearReleaseStrength
- - NearMiddleStrength
- - PressedStrength
- - Reserved
- - Reserved
- - Frequency
- - Reserved
+# DualSense Controller
- Native USB HID output report `0x02` is advertised as 47 payload bytes by
- the captured DualSense USB descriptor, so hosts see 48 bytes including the
- report ID. The extended VIIPER feedback stream includes that native report
- so DS4Windows can pass through host haptics/control semantics to physical
- DualSense hardware when available.
+VIIPER emulates complete USB-connected DualSense and DualSense Edge devices,
+including controls, touch, motion, adaptive triggers, lightbar, speaker,
+advanced haptics, and microphone endpoints.
- See `/device/dualsense/state.go` for the `OutputState` wire definition.
+## Supported device types
- ### Full-duplex audio stream (V3)
+VIIPER 0.0.6 exposes only the production PadSense-derived V5 contracts:
- `dualsensecombinedaudioduplexv3` and
- `dualsenseedgecombinedaudioduplexv3` are opt-in variants for clients that
- transport microphone input and native speaker output on the controller
- stream. The older device names and their wire formats remain unchanged, so
- a client can fall back to `dualsensecombinedmicv2` or the legacy raw stream.
+| Device type | USB functions |
+| --- | --- |
+| `dualsensecombinedaudioduplexv5` | DualSense HID, speaker/haptics OUT, microphone IN |
+| `dualsenseaudioonlyduplexv5` | DualSense speaker/haptics OUT and microphone IN sidecar |
+| `dualsenseedgecombinedaudioduplexv5` | DualSense Edge HID, speaker/haptics OUT, microphone IN |
- Every V3 packet has a 16-byte header followed by its payload:
+The old raw, extended, V1, V2, V3, and V4 names are intentionally not
+registered. Clients must use V5; VIIPER does not silently negotiate an older
+header or split audio/state transport.
- | Offset | Size | Field |
- | --- | --- | --- |
- | 0 | 4 | ASCII `VPCM` |
- | 4 | 1 | Version `0x03` |
- | 5 | 1 | Frame type |
- | 6 | 2 | Payload length, little endian |
- | 8 | 4 | Sequence, little endian |
- | 12 | 4 | IEEE CRC32, little endian |
+## V5 stream contract
- The CRC covers header bytes 4..11 followed by the payload. Sequence
- numbers increase independently in each direction and are shared by all
- frame types in that direction.
+Every packet uses a 16-byte header followed by its payload:
- | Direction | Type | Payload |
- | --- | --- | --- |
- | Client to VIIPER | `0x01` | 33-byte controller input state |
- | Client to VIIPER | `0x02` | 1,920-byte microphone block: signed 16-bit little-endian, 48 kHz, stereo, 10 ms |
- | VIIPER to client | `0x81` | 474-byte combined extended feedback state |
- | VIIPER to client | `0x82` | Native speaker PCM: signed 16-bit little-endian, 48 kHz, stereo |
+| Offset | Size | Field |
+| --- | --- | --- |
+| 0 | 4 | ASCII `VPCM` |
+| 4 | 1 | Version `0x05` |
+| 5 | 1 | Frame type |
+| 6 | 2 | Payload length, little endian |
+| 8 | 4 | Monotonic sequence, little endian |
+| 12 | 4 | IEEE CRC32, little endian |
- Windows exposes the virtual playback endpoint as four channels. V3 speaker
- frames contain only channels 1 and 2 (front left/right); channels 3 and 4
- remain reserved for advanced haptics. A normal ten-packet USB/IP audio URB
- therefore produces a 1,920-byte, 10 ms speaker frame, although clients must
- honor the payload length rather than assume a fixed block size.
-
- ## Reference
-
- ### Button Constants
-
- | Button | Hex Value |
- | -------- | ----------- |
- | Square button | 0x00000010 |
- | Cross (X) button | 0x00000020 |
- | Circle button | 0x00000040 |
- | Triangle button | 0x00000080 |
- | L1 (Left bumper) | 0x00000100 |
- | R1 (Right bumper) | 0x00000200 |
- | L2 button | 0x00000400 |
- | R2 button | 0x00000800 |
- | Create button | 0x00001000 |
- | Options button | 0x00002000 |
- | L3 (Left stick button) | 0x00004000 |
- | R3 (Right stick button) | 0x00008000 |
- | PS button | 0x00010000 |
- | Touchpad click | 0x00020000 |
- | Mic mute button | 0x00040000 |
- | Edge Variant only | --- |
- | RFn button | 0x00200000 |
- | LFn button | 0x00100000 |
- | R4 back paddle | 0x00800000 |
- | L4 back paddle | 0x00400000 |
-
- ### D-Pad Constants
-
- | D-Pad Direction | Hex Value |
- | --------------- | ----------- |
- | Up | 0x01 |
- | Down | 0x02 |
- | Left | 0x04 |
- | Right | 0x08 |
-
- ### Touchpad Coordinates
-
- Touch coordinates are sent as `Touch{1,2}X: uint16` and `Touch{1,2}Y: uint16`
- plus a touch status byte. Legacy clients may send `0` for inactive and `1`
- for active. New clients should send the raw DualSense tracking byte instead:
- bit 7 set means inactive, and the low 7 bits are the contact tracking ID.
- If a client marks a touch active without a tracking ID, VIIPER emits `1` as
- a safe active fallback rather than `0`.
+The CRC covers header bytes 4 through 11 followed by the payload. Sequence
+numbers are shared by every frame type in one direction. A version mismatch,
+sequence gap, CRC mismatch, invalid payload length, or unknown frame type
+closes the stream instead of changing protocols.
- VIIPER clamps touch coordinates to the DualSense range:
-
- - X: **0..1920**
- - Y: **0..1080**
-
- See `/device/dualsense/const.go`.
-
- ### IMU (Gyro + Accelerometer)
-
- VIIPER exposes DualSense IMU values as raw report-space `int16` values,
- while helper conversions use fixed scale factors.
-
- Constants (see `/device/dualsense/const.go`):
-
- - `GyroCountsPerDps = 16.384`
- - `AccelCountsPerMS2 = 835.07`
-
- Gyro (degrees/second):
-
- raw_gyro = round(gyro_dps * GyroCountsPerDps)
- gyro_dps = raw_gyro / GyroCountsPerDps
-
- Accelerometer (m/s2):
-
- raw_accel = round(accel_ms2 * AccelCountsPerMS2)
- accel_ms2 = raw_accel / AccelCountsPerMS2
-
- On device creation, VIIPER initializes the accelerometer to a controller
- lying flat with gravity downwards (`AccelZ = -8192`, i.e. roughly -1g).
-
- Helpers are in `/device/dualsense/helpers.go`.
-
-=== "libVIIPER"
-
- ## API
-
- | Function | Description |
- | --- | --- |
- | `CreateDualSenseDevice(...)` | Create a virtual DualSense device |
- | `CreateDualSenseEdgeDevice(...)` | Create a virtual DualSense Edge |
- | `SetDualSenseDeviceState(handle, state)` | Push input state |
- | `SetDualSenseOutputCallback(handle, cb)` | Register output callback |
- | `RemoveDualSenseDevice(handle)` | Remove the device |
-
- ## Input state
-
- ```c
- typedef struct {
- int8_t LX;
- int8_t LY;
- int8_t RX;
- int8_t RY;
- uint32_t Buttons;
- uint8_t DPad;
- uint8_t L2;
- uint8_t R2;
- uint16_t Touch1X;
- uint16_t Touch1Y;
- uint8_t Touch1Active;
- uint16_t Touch2X;
- uint16_t Touch2Y;
- uint8_t Touch2Active;
- int16_t GyroX;
- int16_t GyroY;
- int16_t GyroZ;
- int16_t AccelX;
- int16_t AccelY;
- int16_t AccelZ;
- } DSDeviceState;
- ```
-
- ## Meta state
-
- Optional metadata can be provided during `CreateDualSenseDevice`
- and `CreateDualSenseEdgeDevice`.
-
- ```c
- typedef struct {
- const char* SerialNumber; // NULL = use default
- const char* MACAddress; // NULL = use default
- const char* Board; // NULL = use default
- uint8_t BatteryStatus; // 0 = use default
- double TemperatureCelsius; // 0 = use default
- double BatteryVoltage; // 0 = use default
- const char* ShellColor; // NULL = use default (e.g. "00", "Z1")
- } DSMetaState;
- ```
-
- ## Output callback
-
- Called when the host sends rumble or LED commands to the device.
-
- ```c
- typedef void (*DSOutputCallback)(
- DSDeviceHandle handle,
- uint8_t rumbleSmall,
- uint8_t rumbleLarge,
- uint8_t ledRed,
- uint8_t ledGreen,
- uint8_t ledBlue,
- uint8_t playerLeds
- );
- ```
-
- Pass `NULL` to `SetDualSenseOutputCallback` to clear
- a previously registered callback.
+| Direction | Type | Payload |
+| --- | --- | --- |
+| Client to VIIPER | `0x01` | 33-byte controller input state |
+| Client to VIIPER | `0x02` | 1,920-byte microphone PCM block: stereo S16LE, 48 kHz, 10 ms |
+| VIIPER to client | `0x81` | 474-byte current combined controller feedback |
+| VIIPER to client | `0x83` | Atomic feedback plus the matching 1,920-byte speaker PCM generation |
+
+An atomic `0x83` payload begins with a little-endian 16-bit feedback length,
+then the 474-byte feedback object, then exactly 480 stereo S16LE speaker
+frames. The four-channel virtual USB source is preserved at 48 kHz: front
+left/right become the speaker generation, while rear left/right independently
+complete the 512-frame advanced-haptics clock. At each 480-frame presentation
+boundary VIIPER consumes one completed rear sample or emits silence for that
+lane, matching the proven PadSense cadence without replaying stale haptics.
+
+Controller state, adaptive triggers, lightbar, rumble, haptics, and speaker
+data are serialized by one V5 writer. Media backpressure is bounded and
+newest-wins; interface resets form a hard generation boundary so stale audio
+cannot cross a stop/reconnect.
+
+## Input state
+
+The 33-byte input payload is little endian:
+
+- Sticks: LX, LY, RX, RY as signed 8-bit values.
+- Buttons: 32-bit bitfield.
+- D-pad: 8-bit bitfield.
+- L2 and R2: unsigned 8-bit values.
+- Two touch contacts: X/Y, active flag, and tracking ID.
+- Gyroscope and accelerometer: three signed 16-bit axes each.
+
+Button bits:
+
+| Control | Value |
+| --- | --- |
+| Square | `0x00000010` |
+| Cross | `0x00000020` |
+| Circle | `0x00000040` |
+| Triangle | `0x00000080` |
+| L1 / R1 | `0x00000100` / `0x00000200` |
+| L2 / R2 | `0x00000400` / `0x00000800` |
+| Create / Options | `0x00001000` / `0x00002000` |
+| L3 / R3 | `0x00004000` / `0x00008000` |
+| PS | `0x00010000` |
+| Touchpad click | `0x00020000` |
+| Mic mute | `0x00040000` |
+| Edge LFn / RFn | `0x00100000` / `0x00200000` |
+| Edge L4 / R4 | `0x00400000` / `0x00800000` |
+
+D-pad bits are Up `0x01`, Down `0x02`, Left `0x04`, and Right `0x08`.
+
+## Feedback state
+
+The 474-byte V5 feedback object contains:
+
+- Bytes 0 through 5: compatible rumble, lightbar RGB, and player LEDs.
+- Bytes 6 through 27: native-spaced R2 and L2 adaptive-trigger blocks.
+- Bytes 28 through 75: the native 48-byte USB output report `0x02`.
+- Bytes 76 through 473: the current 398-byte combined Bluetooth carrier.
+
+The combined carrier keeps state and media on one presentation clock. The
+physical-controller bridge supplies the encoded speaker lane and forwards it
+using its PadSense-derived transport.
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index ce0cf0f9..459f5b3d 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -18,17 +18,23 @@ You must have a USBIP-Client implementation available on your system to use VIIP
=== "Windows"
- [usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver).
-
- **Install and done 😉**
-
- !!! warning "USBIP-Win2 security issue"
- The releases of usbip-win2 **currently** (at the time of writing) install the publicly available test signing CA as a _trusted root CA_ on your system.
- You can safely remove this CA after installation using `certmgr.msc` (run as admin) and removing the "USBIP" from the "Trusted Root Certification Authorities" -> "Certificates" list.
-
- **Alternatively**, you can download and install the **latest pre-release** driver manually from the
- [OSSign repository](https://github.com/OSSign/vadimgrn--usbip-win2/releases), which has this issue fixed already.
- _Note_ that the installer does not work, only the driver `.cat,.inf,.sys` files.
+ VIIPER for DS4Windows requires the signed
+ [usbip-win2 0.9.7.7 x64 release](https://github.com/vadimgrn/usbip-win2/releases/tag/v.0.9.7.7).
+ Use the DS4Windows **Install / Repair VIIPER** action whenever possible; its
+ package contains this exact verified installer.
+
+ !!! danger "Do not substitute another USBIP build"
+ This release is ABI-pinned to **0.9.7.7**. Version 0.9.7.8 reproduced
+ kernel pool corruption during controller attachment and is explicitly
+ rejected. The installer verifies the version and live ABI before
+ allowing VIIPER to start.
+
+ !!! warning "USBIP-Win2 signing certificate"
+ The upstream 0.9.7.7 installer may add the publicly available USBIP
+ test-signing CA to **Trusted Root Certification Authorities**. After
+ installation, you may remove the certificate named **USBIP** with
+ `certlm.msc` (run as administrator). Do not replace 0.9.7.7 with a
+ newer or OSSign build: VIIPER validates this exact userspace/driver ABI.
=== "Linux"
@@ -107,7 +113,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP
### Pre-built Binaries
- Download the latest release from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page. Pre-built binaries are available for:
+ Download the latest release from the [hbashton/VIIPER Releases](https://github.com/hbashton/VIIPER/releases) page. Pre-built binaries are available for:
- Windows (x64, ARM64)
- Linux (x64, ARM64)
@@ -135,7 +141,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP
=== "Windows"
```powershell
- irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex
+ irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex
```
Installs to: `%LOCALAPPDATA%\VIIPER\viiper.exe`
@@ -151,7 +157,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP
=== "Linux"
```bash
- curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh
+ curl -fsSL https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.sh | sh
```
Installs to: `/usr/local/bin/viiper`
@@ -174,7 +180,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP
## Pre-built Binaries
- Download the latest `libVIIPER` release artifact from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page.
+ Download the latest `libVIIPER` release artifact from the [hbashton/VIIPER Releases](https://github.com/hbashton/VIIPER/releases) page.
The archive contains:
- `libVIIPER.dll` / `libVIIPER.so`: the shared library
@@ -184,7 +190,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP
## Building from Source
```bash
- git clone https://github.com/Alia5/VIIPER.git
+ git clone https://github.com/hbashton/VIIPER.git
cd VIIPER
just build-libVIIPER
```
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 883cea95..bfcc7e75 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -5,7 +5,7 @@
Ensure you have:
1. **USBIP installed** on your system (see [Installation](installation.md#requirements))
-2. **VIIPER binary** downloaded from [GitHub Releases](https://github.com/Alia5/VIIPER/releases) or [built from source](installation.md#building-from-source)
+2. **VIIPER binary** downloaded from [hbashton/VIIPER Releases](https://github.com/hbashton/VIIPER/releases) or [built from source](installation.md#building-from-source)
## Starting the Server
@@ -241,7 +241,10 @@ See [Linux Kernel Module Setup](installation.md#linux-kernel-module-setup-for-au
**Windows - USBIP tool not found:**
-Download and install [usbip-win2](https://github.com/vadimgrn/usbip-win2) and ensure `usbip.exe` is in your PATH.
+Install the exact signed
+[usbip-win2 0.9.7.7 x64 release](https://github.com/vadimgrn/usbip-win2/releases/tag/v.0.9.7.7).
+Do not mix it with another usbip-win2 userspace or driver version; VIIPER
+checks the live ABI before starting.
### Device Not Attaching
diff --git a/docs/getting-started/usbip.md b/docs/getting-started/usbip.md
index c49a7d3f..6f90393e 100644
--- a/docs/getting-started/usbip.md
+++ b/docs/getting-started/usbip.md
@@ -1,72 +1,67 @@
-
-# 🔌 USBIP
-
-=== "🪟 Windows"
-
- [usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver).
-
- **Install and done 😉**
-
- !!! warning "USBIP-Win2 security issue"
- The releases of usbip-win2 **currently** (at the time of writing) install the publicly available test signing CA as a _trusted root CA_ on your system.
- You can safely remove this CA after installation using `certmgr.msc` (run as admin) and removing the "USBIP" from the "Trusted Root Certification Authorities" -> "Certificates" list.
-
- **Alternativly**, you can download and istall the **latest pre-release** driver manually from the
- [OSSign repository](https://github.com/OSSign/vadimgrn--usbip-win2/releases), which has this issue fixed already.
- _Note_ that the installer does not work, only the driver `.cat,.inf,.sys` files.
-
-=== "🐧 Linux"
-
- ### 🏹 Arch Linux
-
- ```bash
- sudo pacman -S usbip
- ```
-
- [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP)
-
- ??? tip "Steam OS users"
- If you are installing SISR on Steam OS, you have to switch to the desktop mode and enable write access to the root filesystem first:
-
- ```bash
- sudo steamos-readonly disable
- ```
-
- ### 🟠 Ubuntu/Debian
-
- ```bash
- sudo apt install linux-tools-generic
- ```
-
- [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html)
-
- ### 🧩 Linux Kernel Module Setup
-
- !!! info "USBIP Client Requirement"
- USBIP requires the `vhci-hcd` (Virtual Host Controller Interface) kernel module on Linux.
- Most Linux distributions include this module but don't load it automatically.
-
- #### 🧷 One-Time Setup
-
- To load the module automatically on boot:
-
- ```bash
- echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf
- sudo modprobe vhci-hcd
- ```
-
- #### 🔄 Manual Loading
-
- To load the module for the current session only:
-
- ```bash
- sudo modprobe vhci-hcd
- ```
-
- #### 🔎 Verification
-
- Check if the module is loaded:
-
- ```bash
- lsmod | grep vhci_hcd
- ```
+# USBIP
+
+=== "Windows"
+
+ VIIPER for DS4Windows requires the signed
+ [usbip-win2 0.9.7.7 x64 release](https://github.com/vadimgrn/usbip-win2/releases/tag/v.0.9.7.7).
+ Prefer the DS4Windows **Install / Repair VIIPER** action because it bundles
+ and verifies this exact package. Do not install 0.9.7.8 or substitute a
+ different userspace/driver build; VIIPER rejects an incompatible ABI.
+
+ !!! warning "USBIP-Win2 signing certificate"
+ The 0.9.7.7 installer may add the publicly available USBIP test-signing
+ CA as a trusted root. After installation, you may remove the certificate
+ named **USBIP** with `certlm.msc` (run as administrator). Keep the
+ 0.9.7.7 driver and userspace files installed together.
+
+=== "Linux"
+
+ ### Arch Linux
+
+ ```bash
+ sudo pacman -S usbip
+ ```
+
+ [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP)
+
+ ??? tip "Steam OS users"
+ If you are installing VIIPER on Steam OS, switch to desktop mode and
+ enable write access to the root filesystem first:
+
+ ```bash
+ sudo steamos-readonly disable
+ ```
+
+ ### Ubuntu/Debian
+
+ ```bash
+ sudo apt install linux-tools-generic
+ ```
+
+ [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html)
+
+ ### Linux kernel module setup
+
+ !!! info "USBIP client requirement"
+ USBIP requires the `vhci-hcd` (Virtual Host Controller Interface)
+ kernel module on Linux. Most distributions include it but do not load
+ it automatically.
+
+ #### One-time setup
+
+ ```bash
+ echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf
+ sudo modprobe vhci-hcd
+ ```
+
+ #### Manual loading
+
+ ```bash
+ sudo modprobe vhci-hcd
+ ```
+
+ #### Verification
+
+ ```bash
+ lsmod | grep vhci_hcd
+ ```
diff --git a/docs/index.md b/docs/index.md
index 9d1b462d..6b38f70b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -14,7 +14,7 @@ that are indistinguishable from real hardware to the operating system and applic
- [CLI Reference](cli/overview.md)
- [API Reference](api/overview.md)
- [libVIIPER](libviiper/overview.md)
-- [GitHub Repository](https://github.com/Alia5/VIIPER)
+- [GitHub Repository](https://github.com/hbashton/VIIPER)
## What is VIIPER?
diff --git a/docs/libviiper/overview.md b/docs/libviiper/overview.md
index 78956c3c..c296fa65 100644
--- a/docs/libviiper/overview.md
+++ b/docs/libviiper/overview.md
@@ -39,7 +39,7 @@ Handles (`USBServerHandle`, `Xbox360DeviceHandle`, …) are opaque `uintptr_t` v
## Examples
-Full working examples are in [`examples/libVIIPER/`](https://github.com/Alia5/VIIPER/tree/main/examples/libVIIPER).
+Full working examples are in [`examples/libVIIPER/`](https://github.com/hbashton/VIIPER/tree/main/examples/libVIIPER).
=== "C"
@@ -96,7 +96,7 @@ Full working examples are in [`examples/libVIIPER/`](https://github.com/Alia5/VI
LibVIIPER.CloseUSBServer(serverHandle);
```
- See [`examples/libVIIPER/C#/`](https://github.com/Alia5/VIIPER/tree/main/examples/libVIIPER/C%23) for the full project including P/Invoke declarations.
+ See [`examples/libVIIPER/C#/`](https://github.com/hbashton/VIIPER/tree/main/examples/libVIIPER/C%23) for the full project including P/Invoke declarations.
## Devices
diff --git a/docs/misc/support.md b/docs/misc/support.md
index 0ee90c23..eaca0430 100644
--- a/docs/misc/support.md
+++ b/docs/misc/support.md
@@ -12,7 +12,7 @@ Feel free to joind the Discord server to ask for help, or a general chat and han
## GitHub Discussions
-The repository has [Discussions](https://github.com/Alia5/VIIPER/discussions) enabled, browse existing topics and open your own
+The repository has [Discussions](https://github.com/hbashton/VIIPER/discussions) enabled; browse existing topics or open your own.
if your search didn't bring up satisfying results
## GitHub Issues
diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md
deleted file mode 100644
index fe3bbc0a..00000000
--- a/docs/research/dualsense-bluetooth-haptics.md
+++ /dev/null
@@ -1,260 +0,0 @@
-# DualSense Bluetooth Haptics Notes
-
-These notes track the implementation path for forwarding a game's virtual
-DualSense haptics to a physical Bluetooth DualSense.
-
-## Ground truth
-
-- SAxense (`egormanga/SAxense`) streams haptics to a Bluetooth DualSense by
- writing HID output report `0x32` to the controller's `hidraw` node.
-- SAxense accepts signed 8-bit stereo PCM at 3000 Hz.
-- Each Bluetooth report is 141 bytes:
- - byte 0: report ID `0x32`
- - byte 1: tag/sequence nibble
- - packet `0x11`: stream/control packet, length 7, body
- `FE 00 00 00 00 FF `
- - packet `0x12`: haptics PCM packet, length 64, body is 64 bytes of stereo
- PCM
- - final 4 bytes: DualSense Bluetooth CRC32 with seed `0xEADA2D49`
-- VIIPER now has `BuildBluetoothHapticsReport` mirroring that packet layout so
- the runtime can package haptics PCM without re-discovering the format.
-
-## PadForge / HIDMaestro read
-
-PadForge and HIDMaestro are useful references, but mostly for the HID side of
-the problem.
-
-What the public code proves:
-
-- PadForge uses HIDMaestro to create virtual DualSense / DualSense Edge HID
- devices.
-- HIDMaestro profiles declare USB DualSense output report `0x02` as 48 bytes.
- The important HID fields are:
- - byte 1: `validFlag0`
- - byte 2: `validFlag1`
- - bytes 3-4: compatible rumble motors
- - byte 9: mute LED
- - bytes 11-21: right trigger effect
- - bytes 22-32: left trigger effect
- - bytes 45-47: lightbar RGB
- - bytes 1-47: `effectPayload`
-- HIDMaestro profiles declare Bluetooth DualSense output report `0x31` as 78
- bytes, with a rolling tag, BT flag byte, shifted HID field offsets, and a
- CRC32 footer using prefix `[0xA2, 0x31]`.
-- PadForge listens for HIDMaestro `OutputDecoded`, takes the decoded
- `effectPayload`, and forwards it to an assigned physical DualSense via
- `SDL_SendGamepadEffect`.
-- PadForge also has a raw HID writer that encodes profile fields through
- HIDMaestro and writes to the physical HID path directly, bypassing SDL's
- effect state machine when needed.
-
-What the public code does not prove:
-
-- It does not obviously expose a virtual DualSense USB Audio Class function to
- games.
-- It does not obviously capture host-to-device isochronous audio endpoint
- transfers from a game.
-- Its README "audio" features appear, from source review, to include
- audio-reactive user effects and HID effect passthrough, not necessarily the
- native DualSense advanced-haptics audio interface that Ghost of Tsushima uses
- on a wired controller.
-
-Practical takeaway:
-
-- Use HIDMaestro's profiles as a spec oracle for USB `0x02` and Bluetooth
- `0x31` HID report mapping.
-- Keep PadForge's queueing model in mind: HID output callbacks should enqueue
- and return quickly, while a worker forwards effects to physical hardware.
-- Do not assume PadForge solves advanced haptics audio for VIIPER. The missing
- piece is still a virtual USB audio/haptics function or another endpoint path
- that lets the game send PCM-like haptics data to the virtual controller.
-
-## What Ghost of Tsushima is likely sending
-
-For native DualSense behavior, Ghosts can send separate channels of data:
-
-- HID output report `0x02`: adaptive trigger state, lightbar/player LEDs, mute
- LED, and ordinary compatible rumble flags.
-- USB audio stream: advanced haptics, exposed by a real wired DualSense as an
- audio function. SAxense's input format strongly suggests that the useful
- Bluetooth haptics payload is 3000 Hz stereo 8-bit PCM.
-
-VIIPER's current DualSense device is HID-only. It captures report `0x02`, but it
-does not expose a virtual DualSense USB audio function yet. That means a game
-that sends advanced haptics through the DualSense audio interface has nowhere to
-send those frames in the current virtual device, and the HID traffic dump alone
-cannot recover them.
-
-## Required next implementation path
-
-1. Capture a real wired DualSense USB descriptor, including all audio
- interfaces, alternate settings, isochronous endpoints, and class-specific
- audio descriptors.
-2. Extend VIIPER's DualSense descriptor to expose the same USB audio/haptics
- interface in addition to the HID interface.
-3. Route host-to-device audio endpoint transfers into a DualSense haptics
- diagnostics ring buffer.
-4. Add an experimental bridge that converts captured haptics PCM into
- `BuildBluetoothHapticsReport` frames and writes them to the physical
- Bluetooth DualSense transport.
-5. Keep HID report `0x02` handling separate for adaptive triggers and LEDs.
-
-## Near-term VIIPER work from the references
-
-1. Add a USB `0x02` to BT `0x31` mapping helper using HIDMaestro's declared
- offsets and CRC32 scope. This is for ordinary HID effects: rumble, lightbar,
- mute LED, player LEDs, and adaptive trigger command blobs.
-2. Keep the raw USB `0x02` report in diagnostics, because Ghost's HID output
- tells us when it is selecting the haptics path versus ordinary rumble.
-3. Add audio endpoint capture only after the descriptor exposes the real
- DualSense audio interfaces. Until then, captures will show HID output only.
-
-## Debug workflow
-
-1. Start DualSense traffic capture in DS4Windows' VIIPER debugger.
-2. Keep capture running while the game has focus.
-3. Exercise the feature in game.
-4. Return to DS4Windows and use Export DS Traffic.
-5. Inspect the exported JSON for HID reports. If no audio endpoint capture is
- present, that confirms the current virtual device is still HID-only and the
- audio descriptor work is the next blocker.
-
-## Ghost of Tsushima bow capture, 2026-06-14
-
-Capture file:
-
-- `%APPDATA%\DS4Windows\Logs\dualsense_traffic_20260614_103717.json`
-
-Summary:
-
-- 2,497 total traffic events.
-- 1,248 parsed DualSense output reports.
-- Three clear bow-draw clusters were captured.
-- Each bow draw lasts roughly 1.5-1.7 seconds.
-- Each bow draw contains:
- - 47-48 adaptive trigger output reports.
- - 94-100 compatible rumble output reports.
- - large motor rumble ramping up to `0xFF`.
-
-Representative trigger sequence:
-
-```text
-21 ff 03 49 92 24 09 00 00 00 00
-26 ff 03 49 92 24 09 00 00 7f 00
-26 ff 03 49 92 24 09 00 00 7d 00
-26 ff 03 49 92 24 09 00 00 7b 00
-26 ff 03 49 92 24 09 00 00 79 00
-26 ff 03 49 92 24 09 00 00 77 00
-26 ff 03 49 92 24 09 00 00 75 00
-26 ff 03 92 24 49 12 00 00 74 00
-26 ff 03 92 24 49 12 00 00 72 00
-26 ff 03 92 24 49 12 00 00 70 00
-26 ff 03 92 24 49 12 00 00 6e 00
-26 ff 03 92 24 49 12 00 00 6c 00
-```
-
-Representative compatible rumble report:
-
-```text
-02 02 40 00 ff 00 00 00 ...
-```
-
-Interpretation:
-
-- Ghost is definitely driving the adaptive trigger over normal DualSense HID
- output report `0x02`.
-- The harsh vibration felt during bow draw is visible as a compatible rumble
- ramp, not just inferred haptics.
-- This capture does not include report `0x32` haptics PCM. That is expected:
- report `0x32` is the Bluetooth HID report SAxense writes to the physical
- controller after it already has PCM.
-- The next bridge should first forward HID report `0x02` as Bluetooth `0x31`
- for adaptive triggers, while keeping compatible rumble separate from future
- SAxense-style haptics PCM.
-
-## Ghost of Tsushima bow capture with rumble disabled, 2026-06-14
-
-Capture file:
-
-- `%APPDATA%\DS4Windows\Logs\dualsense_traffic_20260614_104112.json`
-
-Summary:
-
-- 2,936 total traffic events.
-- 1,468 parsed DualSense output reports.
-- Three clear bow-draw clusters were captured.
-- Each bow draw lasts roughly 1.3 seconds.
-- Each bow draw contains 47 adaptive-trigger output reports.
-- Compatible rumble is absent: `small=0`, `large=0` for the draw clusters.
-
-Interpretation:
-
-- Disabling game rumble removes the normal `0x02/0x40` compatible rumble ramp.
-- The remaining vibration felt during bow draw comes from the adaptive trigger
- effect mode itself, not from advanced haptics being converted into ordinary
- controller rumble.
-- This validates keeping the trigger HID path separate from the SAxense
- haptics/audio path.
-
-## Experimental SAxense bridge in VIIPER
-
-VIIPER now has an experimental DualSense haptics/audio OUT endpoint:
-
-- endpoint: `0x05`
-- type: isochronous OUT
-- format advertised in the descriptor: 4-channel, 16-bit, 48000 Hz PCM
- (channels 3/4 are downsampled to the Bluetooth haptics stream; channels
- 1/2 remain available as the virtual controller speaker pair)
-
-When host software writes 64-byte haptics/audio chunks to this endpoint,
-VIIPER records:
-
-- `audio-haptics-out`: the raw host-to-device audio/haptics bytes.
-- `saxense-hid-0x32`: the generated 141-byte SAxense-style Bluetooth HID
- haptics report.
-
-The extended DualSense feedback stream now appends one optional 141-byte
-Bluetooth haptics report after the existing 76-byte feedback payload. DS4Windows
-can forward that report to a real Bluetooth DualSense through its existing
-physical controller handle. This creates the first end-to-end testable contract:
-if Ghost opens the virtual audio endpoint, the traffic export should include
-`audio-haptics-out` events, matching `saxense-hid-0x32` packets, and the
-physical controller should receive report `0x32`.
-
-Windows reported the experimental audio interface as a failed `MEDIA` device on
-`USB\VID_054C&PID_0CE6&MI_01` while the AudioControl descriptor was malformed.
-The current UAC1 AudioControl topology is 0x002A bytes: header, Input Terminal,
-Feature Unit, and Output Terminal. The server also implements standard
-`GET_INTERFACE` / `SET_INTERFACE` handling so the audio streaming interface
-can switch from alternate setting 0 to alternate setting 1.
-
-Captured wired DualSense descriptors use a nine-byte audio OUT endpoint with
-two trailing zero bytes before the class-specific endpoint descriptor. VIIPER
-preserves that hardware layout. The virtual endpoint also answers UAC1
-`GET_CUR`, `GET_MIN`, `GET_MAX`, `GET_RES`, and `SET_CUR`
-sampling-frequency requests with the fixed 48 kHz format it advertises.
-
-Descriptor fidelity alone is not enough for usable audio. USB audio uses
-isochronous URBs, including packet-descriptor arrays and realtime completion.
-The current USBIP transport must implement those frames and pacing before the
-experimental audio function can be considered supported. vDS validates this
-requirement with a UdeCx driver that completes ISO URBs at audio cadence.
-
-## vDS reference findings
-
-`hurryman2212/vds` includes captured DualSense USB descriptor data and a
-Windows UdeCx implementation. Its captured standard DualSense audio OUT path
-uses high-speed USB, endpoint `0x01`, a 392-byte maximum packet, interval 4
-(one millisecond at high speed), and 48 kHz four-channel S16_LE PCM. The
-driver reads each ISO packet descriptor, completes every packet, and delays the
-completion by the stream duration. Those are protocol requirements, not
-optimizations: immediate completions cause the host audio engine to burst PCM,
-which overflows a Bluetooth haptics queue and produces stutter.
-
-VIIPER now models the captured endpoint values and preserves USB/IP ISO packet
-descriptor arrays. A complete virtual DualSense audio implementation still
-needs the remaining captured composite topology (audio input and feature-unit
-controls) and hardware testing with the usbip-win2 client.
-
-Credit: SAxense research by egormanga/Sdore should be credited anywhere this
-Bluetooth haptics packet path is surfaced to users or shipped.
diff --git a/docs/research/dualsense-bluetooth-microphone.md b/docs/research/dualsense-bluetooth-microphone.md
deleted file mode 100644
index 006dd6ce..00000000
--- a/docs/research/dualsense-bluetooth-microphone.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# DualSense Bluetooth Microphone Notes
-
-This document records the evidence and integration constraints for presenting a
-physical Bluetooth DualSense microphone through a virtual DualSense audio
-device. It is deliberately separate from the haptics notes: microphone capture
-is controller-to-host traffic, while advanced haptics are host-to-controller
-audio traffic.
-
-## Conclusion
-
-Bluetooth microphone capture from a physical DualSense is possible. This is not
-a speculative protocol: two independently maintained Pico W bridge projects
-implement it and expose it as a standard USB Audio Class capture endpoint.
-
-The missing piece for DS4Windows is Windows transport validation. The working
-bridges own the Bluetooth HID interrupt L2CAP channel directly. DS4Windows uses
-Windows HidBth through a HID device handle, so it must prove both of these paths
-before a user-facing microphone feature is claimed:
-
-1. A Bluetooth control/audio report `0x36` of 398 bytes reaches the controller
- through `WriteFile` on the HidBth HID handle.
-2. HidBth delivers microphone-tagged `0x31` reports to the existing HID input
- handle instead of filtering them.
-
-The current DS4Windows receive loop is unsafe for the second case: it CRC-checks
-every Bluetooth packet as a normal 78-byte input report before classifying the
-packet. A microphone frame must be recognized and queued before normal input
-CRC and gamepad parsing. Otherwise it can be counted as corruption and lead to
-a disconnect.
-
-## Observed Bluetooth microphone protocol
-
-Both `awalol/DS5Dongle` and `hurryman2212/DS5_Bridge` implement the same
-wire behavior:
-
-- The controller microphone is enabled by bit 0 in byte 4 of a Bluetooth output
- report `0x36`. `0xFE` means disabled and `0xFF` means enabled.
-- The control report is 398 bytes, contains a `0x11` stream/control subreport,
- a `0x10` SetStateData subreport, and a silent `0x12` haptics subreport.
-- It is sent over the HID interrupt channel with HIDP output prefix `0xA2` and
- a DualSense Bluetooth CRC32 footer (seed `0xEADA2D49`).
-- The controller returns a Bluetooth input report `0x31`. Bit 1 of its flag
- byte marks a microphone packet.
-- In raw L2CAP framing, the Opus payload begins at byte 4: `A1 31 flags ...`.
- It is a 71-byte Opus frame, mono, 48 kHz, 480 samples per frame (10 ms).
-- The bridge projects decode it with `opus_decode(..., 48000, 1, 480)` and
- duplicate mono samples into a stereo UAC capture endpoint because the real
- DualSense audio device is commonly enumerated by Windows as stereo capture.
-- The mic stream is sticky after enabling. The OLED fork sends a silent control
- `0x36` at roughly 4 Hz only until frames arrive, then stops. It resumes that
- keepalive when the stream stalls. This avoids making microphone capture depend
- on a speaker stream and limits controller battery impact.
-
-## Sources checked
-
-All source repositories below are present in this workspace. DS5Dongle and its
-OLED fork are MIT licensed. DS5_Bridge is AGPL-3.0, so it is a behavioral/spec
-reference only and must not be copied into a non-AGPL component.
-
-| Source | Relevant evidence |
-| --- | --- |
-| `external/DS5Dongle/src/main.cpp` | Classifies interrupt `0x31` reports with flag bit 1, then queues `data + 4`. |
-| `external/DS5Dongle/src/audio.cpp` | Defines `MIC_OPUS_SIZE = 71`, `MIC_FRAMES = 480`, creates a 48 kHz mono Opus decoder, and sends report `0x36` with the mic enable bit. |
-| `external/DS5Dongle/src/bt.cpp` | Prefixes outgoing interrupt reports with `0xA2` and calculates the DualSense Bluetooth CRC. |
-| `external/DS5Dongle-OLED-Edition/BLUETOOTH_AUDIO_NOTES.md` | Documents the mic-enable discovery, sticky stream behavior, four-Hz arming keepalive, Opus decoding, jitter buffer, and tests with Discord/OBS. |
-| `external/DS5_Bridge/src/main.cpp` and `src/audio.cpp` | Independently classifies `0x31` flag bit 1, accepts 71-byte packets, decodes mono Opus at 48 kHz/480 frames, and has loss concealment. Behavioral reference only because of AGPL-3.0. |
-| `external/vds/module/vds_hcd_core.c` | Implements the virtual DualSense audio-IN endpoint and real-time ISO-IN completion pacing, but currently fills every input packet with silence. Its README explicitly states microphone input is unsupported. This is a useful virtual-UAC reference, not a physical microphone implementation. |
-| `DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs` | Current physical-controller path. Its Bluetooth input loop assumes only ordinary 78-byte `0x31` reports; it already uses `WriteOutputReportViaInterrupt` for experimental `0x32`/`0x35` reports. |
-
-## Exact DS4Windows experiment before implementation
-
-Do not wire the audio capture endpoint or add UI first. Build a narrowly scoped
-diagnostic on a physical Bluetooth DualSense with verbose logging:
-
-1. Log HID capabilities: input, output, and feature report byte lengths.
-2. Submit one CRC-correct 398-byte, silent `0x36` microphone-enable report via
- `WriteOutputReportViaInterrupt`; log the returned Win32 error and exact byte
- count. Do not retry rapidly.
-3. Add a pre-CRC classifier for `0x31` input reports. It must log only header,
- length, and the mic flag, not raw voice data.
-4. If flagged frames arrive, copy exactly 71 bytes into a bounded queue and
- bypass normal controller-state parsing.
-5. Decode the frames in a worker with Opus PLC/jitter buffering, then expose
- them only after a real virtual UAC capture endpoint exists.
-6. On controller disconnect, profile change, and DS4Windows shutdown, send one
- best-effort mic-disable `0x36` report and dispose decoder/queues.
-
-The report offset is transport-dependent: direct L2CAP sources see `A1 31 ...`;
-Windows HidBth may strip the HIDP transaction prefix. The diagnostic must derive
-the offset from the actual received buffer and never assume that raw L2CAP byte
-positions map one-for-one onto `HidDevice.ReadFile` buffers.
-
-## User-facing constraints
-
-- Enabling controller mic should be explicit and opt-in. The documented stream
- keeps the controller audio subsystem active and costs battery.
-- It must not be tied to Bluetooth speaker mirroring or advanced haptics.
-- It should be disabled automatically when no app has opened the virtual audio
- capture endpoint, and immediately on DS4Windows shutdown.
-- Audio should not be logged. Diagnostics should contain packet lengths, timing,
- counters, decoder results, and errors only.
-
-## Relationship to virtual DualSense audio
-
-VIIPER still needs a full composite UAC capture interface and reliable USB/IP
-isochronous IN transfer handling before Windows can enumerate the virtual
-DualSense microphone. vDS demonstrates the required device-side shape: its
-virtual controller has audio-IN endpoint `0x82`, 48 kHz stereo S16_LE timing,
-and completion pacing at audio cadence. Its present implementation zero-fills
-the ISO-IN buffers, so it cannot be lifted wholesale. The physical Bluetooth
-capture protocol above is independent of that virtual-device work. It is
-valuable to validate the physical capture path first, because it isolates
-HidBth compatibility from UAC/USBIP descriptor work.
-
-## Attribution
-
-Any implementation that ports the MIT-licensed DS5Dongle/OLED ideas should
-retain their copyright and license notice. Cite `awalol/DS5Dongle` and the
-OLED fork's Bluetooth audio notes in source attribution and release notes.
diff --git a/examples/go/virtual_ds_and_edge_cli/main.go b/examples/go/virtual_ds_and_edge_cli/main.go
index 4d52e985..6a8aaa65 100644
--- a/examples/go/virtual_ds_and_edge_cli/main.go
+++ b/examples/go/virtual_ds_and_edge_cli/main.go
@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding"
+ "encoding/binary"
"fmt"
+ "hash/crc32"
"io"
"os"
"os/signal"
@@ -82,9 +84,9 @@ func main() {
fmt.Printf("Using existing bus %d\n", busID)
}
- deviceType := "dualsense"
+ deviceType := dualsense.DeviceTypeCombinedAudioDuplexV5
if edge {
- deviceType = "dualsenseedge"
+ deviceType = dualsense.DeviceTypeEdgeCombinedAudioDuplexV5
}
stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, deviceType, nil)
@@ -108,17 +110,12 @@ func main() {
}
}()
- feedbackCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) {
- var b [dualsense.OutputStateSize]byte
- if _, err := io.ReadFull(r, b[:]); err != nil {
- return nil, err
- }
- msg := new(dualsense.OutputState)
- if err := msg.UnmarshalBinary(b[:]); err != nil {
- return nil, err
- }
- return msg, nil
- })
+ var feedbackSequence uint32
+ feedbackSequenceInitialized := false
+ feedbackCh, errCh := stream.StartReading(ctx, 10,
+ func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) {
+ return readV5Feedback(r, &feedbackSequence, &feedbackSequenceInitialized)
+ })
go func() {
for {
@@ -165,17 +162,23 @@ func main() {
defer sendTicker.Stop()
go func() {
+ var sequence uint32
for {
select {
case <-sendTicker.C:
box.mu.Lock()
st := box.state
box.mu.Unlock()
- if err := stream.WriteBinary(&st); err != nil {
+ payload, err := st.MarshalBinary()
+ if err == nil {
+ err = writeAll(stream, makeV5Frame(dualsense.StreamFrameInputState, sequence, payload))
+ }
+ if err != nil {
fmt.Printf("Send error: %v\n", err)
cancel()
return
}
+ sequence++
case <-ctx.Done():
return
}
@@ -263,6 +266,97 @@ func main() {
}
}
+func makeV5Frame(frameType byte, sequence uint32, payload []byte) []byte {
+ header := make([]byte, dualsense.StreamFrameHeaderSize)
+ header[0] = dualsense.StreamFrameMagic0
+ header[1] = dualsense.StreamFrameMagic1
+ header[2] = dualsense.StreamFrameMagic2
+ header[3] = dualsense.StreamFrameMagic3
+ header[4] = dualsense.StreamFrameVersionV5
+ header[5] = frameType
+ binary.LittleEndian.PutUint16(header[6:8], uint16(len(payload)))
+ binary.LittleEndian.PutUint32(header[8:12], sequence)
+ binary.LittleEndian.PutUint32(header[12:16], v5CRC(header[4:12], payload))
+ return append(header, payload...)
+}
+
+func readV5Feedback(r *bufio.Reader, expectedSequence *uint32,
+ sequenceInitialized *bool) (*dualsense.OutputState, error) {
+ header := make([]byte, dualsense.StreamFrameHeaderSize)
+ if _, err := io.ReadFull(r, header); err != nil {
+ return nil, err
+ }
+ if header[0] != dualsense.StreamFrameMagic0 ||
+ header[1] != dualsense.StreamFrameMagic1 ||
+ header[2] != dualsense.StreamFrameMagic2 ||
+ header[3] != dualsense.StreamFrameMagic3 ||
+ header[4] != dualsense.StreamFrameVersionV5 {
+ return nil, fmt.Errorf("invalid DualSense V5 feedback header")
+ }
+
+ payloadLength := int(binary.LittleEndian.Uint16(header[6:8]))
+ const atomicPayloadLength = 2 + dualsense.OutputStateV5Size + 480*2*2
+ if payloadLength != dualsense.OutputStateV5Size && payloadLength != atomicPayloadLength {
+ return nil, fmt.Errorf("invalid DualSense V5 feedback payload length %d", payloadLength)
+ }
+ payload := make([]byte, payloadLength)
+ if _, err := io.ReadFull(r, payload); err != nil {
+ return nil, err
+ }
+ if got, want := binary.LittleEndian.Uint32(header[12:16]), v5CRC(header[4:12], payload); got != want {
+ return nil, fmt.Errorf("DualSense V5 feedback CRC mismatch: got %08X want %08X", got, want)
+ }
+ sequence := binary.LittleEndian.Uint32(header[8:12])
+ if *sequenceInitialized && sequence != *expectedSequence {
+ return nil, fmt.Errorf("DualSense V5 feedback sequence mismatch: got %d want %d", sequence, *expectedSequence)
+ }
+ *expectedSequence = sequence + 1
+ *sequenceInitialized = true
+
+ feedback := payload
+ switch header[5] {
+ case dualsense.StreamFrameOutputState:
+ if payloadLength != dualsense.OutputStateV5Size {
+ return nil, fmt.Errorf("invalid V5 control payload length %d", payloadLength)
+ }
+ case dualsense.StreamFrameAtomicAudioHaptics:
+ if payloadLength != atomicPayloadLength ||
+ int(binary.LittleEndian.Uint16(payload[:2])) != dualsense.OutputStateV5Size {
+ return nil, fmt.Errorf("invalid V5 atomic feedback envelope")
+ }
+ feedback = payload[2 : 2+dualsense.OutputStateV5Size]
+ default:
+ return nil, fmt.Errorf("unknown DualSense V5 feedback type 0x%02X", header[5])
+ }
+
+ state := new(dualsense.OutputState)
+ if err := state.UnmarshalV5Binary(feedback); err != nil {
+ return nil, err
+ }
+ return state, nil
+}
+
+func v5CRC(headerFields, payload []byte) uint32 {
+ hash := crc32.NewIEEE()
+ _, _ = hash.Write(headerFields)
+ _, _ = hash.Write(payload)
+ return hash.Sum32()
+}
+
+func writeAll(writer io.Writer, data []byte) error {
+ for len(data) > 0 {
+ written, err := writer.Write(data)
+ if err != nil {
+ return err
+ }
+ if written == 0 {
+ return io.ErrShortWrite
+ }
+ data = data[written:]
+ }
+ return nil
+}
+
func printHelp() {
fmt.Println("Assignments: Key=Value [duration]")
fmt.Println(" Example: LX=-100")
diff --git a/internal/cmd/install.go b/internal/cmd/install.go
index 31d8c112..f115efb5 100644
--- a/internal/cmd/install.go
+++ b/internal/cmd/install.go
@@ -1,7 +1,9 @@
package cmd
import (
+ "bufio"
"errors"
+ "fmt"
"log/slog"
"os"
"path/filepath"
@@ -12,7 +14,9 @@ import (
type Install struct{}
// Uninstall removes VIIPER startup configuration.
-type Uninstall struct{}
+type Uninstall struct {
+ Yes bool `help:"Confirm removal without prompting." short:"y"`
+}
func (c *Install) Run(logger *slog.Logger) error {
exe, err := os.Executable()
@@ -37,6 +41,19 @@ func (c *Uninstall) Run(logger *slog.Logger) error {
return errors.New("cannot uninstall from 'go run'")
}
+ if !c.Yes {
+ fmt.Print("Remove VIIPER startup registration and stop its server? [y/N]: ")
+ answer, readErr := bufio.NewReader(os.Stdin).ReadString('\n')
+ if readErr != nil && len(answer) == 0 {
+ return fmt.Errorf("could not read uninstall confirmation: %w", readErr)
+ }
+ answer = strings.TrimSpace(strings.ToLower(answer))
+ if answer != "y" && answer != "yes" {
+ fmt.Println("Uninstall canceled. No changes were made.")
+ return nil
+ }
+ }
+
return uninstall(logger)
}
diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go
index 69de36c7..655c3425 100644
--- a/internal/cmd/install_windows.go
+++ b/internal/cmd/install_windows.go
@@ -19,11 +19,28 @@ import (
)
const (
- runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
- runValueKey = "VIIPER"
+ runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
+ runValueKey = "VIIPER"
+ runScheduledTask = "RunVIIPER"
)
func install(logger *slog.Logger) error {
+ if err := requireUSBIPRuntime(); err != nil {
+ return err
+ }
+ scheduledExe, err := currentScheduledTaskExe()
+ if err != nil {
+ return fmt.Errorf("failed to inspect legacy %s scheduled task: %w", runScheduledTask, err)
+ }
+ if err := removeScheduledTask(); err != nil {
+ return fmt.Errorf("failed to remove legacy %s scheduled task: %w", runScheduledTask, err)
+ }
+ if scheduledExe != "" {
+ if err := killProcessesByExe(scheduledExe, logger); err != nil {
+ return fmt.Errorf("failed to stop legacy scheduled VIIPER instance: %w", err)
+ }
+ }
+
exePath, err := currentExecutable()
if err != nil {
return err
@@ -73,6 +90,18 @@ func uninstall(logger *slog.Logger) error {
if err != nil {
return err
}
+ scheduledExe, err := currentScheduledTaskExe()
+ if err != nil {
+ return fmt.Errorf("failed to inspect %s scheduled task: %w", runScheduledTask, err)
+ }
+ if err := removeScheduledTask(); err != nil {
+ return fmt.Errorf("failed to remove %s scheduled task; run uninstall as administrator: %w", runScheduledTask, err)
+ }
+ if scheduledExe != "" {
+ if err := killProcessesByExe(scheduledExe, logger); err != nil {
+ return fmt.Errorf("failed to stop scheduled VIIPER instance: %w", err)
+ }
+ }
key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
if err != nil {
@@ -95,7 +124,51 @@ func uninstall(logger *slog.Logger) error {
}
}
- logger.Info("VIIPER autorun entry removed")
+ currentExe, currentErr := currentExecutable()
+ if currentErr == nil && !strings.EqualFold(currentExe, autorunExe) {
+ if err := killProcessesByExe(currentExe, logger); err != nil {
+ return fmt.Errorf("failed to stop installed VIIPER instance: %w", err)
+ }
+ }
+
+ logger.Info("VIIPER startup entries removed and server stopped")
+ return nil
+}
+
+func currentScheduledTaskExe() (string, error) {
+ script := fmt.Sprintf(
+ "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};$a=@($t.Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};$a[0].Execute",
+ runScheduledTask,
+ )
+ output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("scheduled task query failed: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ path := strings.Trim(strings.TrimSpace(string(output)), `"`)
+ if path == "" {
+ return "", nil
+ }
+ path = filepath.Clean(path)
+ if !strings.EqualFold(filepath.Base(path), "viiper.exe") {
+ return "", fmt.Errorf("%s action is not a VIIPER executable: %s", runScheduledTask, path)
+ }
+ return path, nil
+}
+
+func removeScheduledTask() error {
+ // Get-ScheduledTask makes absence distinguishable from an access-denied
+ // deletion. Never report uninstall success while a highest-privilege task
+ // can silently start VIIPER again at the next logon.
+ script := fmt.Sprintf(
+ "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};Unregister-ScheduledTask -TaskName '%s' -Confirm:$false -ErrorAction Stop;if(Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue){throw 'scheduled task still exists'}",
+ runScheduledTask,
+ runScheduledTask,
+ runScheduledTask,
+ )
+ output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("scheduled task removal failed: %w: %s", err, strings.TrimSpace(string(output)))
+ }
return nil
}
diff --git a/internal/cmd/server.go b/internal/cmd/server.go
index 0d445f19..ef19a934 100644
--- a/internal/cmd/server.go
+++ b/internal/cmd/server.go
@@ -37,6 +37,11 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error {
}
func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error {
+ if err := requireUSBIPRuntime(); err != nil {
+ logger.Error("Refusing to start VIIPER with an incompatible USB/IP runtime", "error", err)
+ return err
+ }
+
ctx, cancel := context.WithCancel(ctx)
defer cancel()
tray.Run(ctx, cancel)
@@ -102,9 +107,6 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger
r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv))
r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv))
r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv))
- r.Register("debug/dualsense-traffic/set", handler.DualSenseTrafficSet())
- r.Register("debug/dualsense-traffic/get", handler.DualSenseTrafficGet())
- r.Register("debug/dualsense-traffic/clear", handler.DualSenseTrafficClear())
r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv))
if s.APIServerConfig.AutoAttachLocalClient {
diff --git a/internal/cmd/usbip_prerequisite_other.go b/internal/cmd/usbip_prerequisite_other.go
new file mode 100644
index 00000000..91a5356f
--- /dev/null
+++ b/internal/cmd/usbip_prerequisite_other.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package cmd
+
+func requireUSBIPRuntime() error {
+ return nil
+}
diff --git a/internal/cmd/usbip_prerequisite_windows.go b/internal/cmd/usbip_prerequisite_windows.go
new file mode 100644
index 00000000..a09f0d52
--- /dev/null
+++ b/internal/cmd/usbip_prerequisite_windows.go
@@ -0,0 +1,142 @@
+//go:build windows
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const (
+ requiredUSBIPVersion = "0.9.7.7"
+ usbipProbeTimeout = 10 * time.Second
+)
+
+type usbipCommandRunner func(context.Context, string, ...string) ([]byte, error)
+
+func requireUSBIPRuntime() error {
+ usbipPath, err := canonicalUSBIPExecutable()
+ if err != nil {
+ return err
+ }
+
+ return probeUSBIPRuntime(usbipPath, runUSBIPCommand)
+}
+
+func canonicalUSBIPExecutable() (string, error) {
+ programFiles := strings.TrimSpace(os.Getenv("ProgramW6432"))
+ if programFiles == "" {
+ programFiles = strings.TrimSpace(os.Getenv("ProgramFiles"))
+ }
+ if programFiles == "" {
+ return "", fmt.Errorf("USB/IP prerequisite failed: Windows Program Files directory is unavailable")
+ }
+
+ usbipPath := filepath.Join(programFiles, "USBip", "usbip.exe")
+ info, err := os.Stat(usbipPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", fmt.Errorf(
+ "USB/IP prerequisite failed: usbip-win2 %s is not installed at %s; run the DS4Windows VIIPER setup",
+ requiredUSBIPVersion,
+ usbipPath,
+ )
+ }
+ return "", fmt.Errorf("USB/IP prerequisite failed: cannot inspect %s: %w", usbipPath, err)
+ }
+ if info.IsDir() {
+ return "", fmt.Errorf("USB/IP prerequisite failed: %s is not an executable file", usbipPath)
+ }
+
+ return usbipPath, nil
+}
+
+func runUSBIPCommand(ctx context.Context, executable string, args ...string) ([]byte, error) {
+ return exec.CommandContext(ctx, executable, args...).CombinedOutput()
+}
+
+func probeUSBIPRuntime(usbipPath string, run usbipCommandRunner) error {
+ versionCtx, cancelVersion := context.WithTimeout(context.Background(), usbipProbeTimeout)
+ versionOutput, versionErr := run(versionCtx, usbipPath, "--version")
+ versionTimedOut := versionCtx.Err() == context.DeadlineExceeded
+ cancelVersion()
+
+ if versionTimedOut {
+ return fmt.Errorf("USB/IP prerequisite failed: %s --version timed out", usbipPath)
+ }
+ if versionErr != nil {
+ return fmt.Errorf(
+ "USB/IP prerequisite failed: cannot query %s version: %w%s",
+ usbipPath,
+ versionErr,
+ formatUSBIPOutput(versionOutput),
+ )
+ }
+
+ installedVersion := strings.TrimSpace(string(versionOutput))
+ if installedVersion != requiredUSBIPVersion {
+ if installedVersion == "" {
+ installedVersion = "unknown"
+ }
+ return fmt.Errorf(
+ "USB/IP prerequisite failed: VIIPER requires usbip-win2 %s at %s (found %s); run the DS4Windows VIIPER setup",
+ requiredUSBIPVersion,
+ usbipPath,
+ installedVersion,
+ )
+ }
+
+ portCtx, cancelPort := context.WithTimeout(context.Background(), usbipProbeTimeout)
+ portOutput, portErr := run(portCtx, usbipPath, "port")
+ portTimedOut := portCtx.Err() == context.DeadlineExceeded
+ cancelPort()
+
+ if portTimedOut {
+ return fmt.Errorf("USB/IP prerequisite failed: %s port timed out", usbipPath)
+ }
+ if portErr != nil {
+ return fmt.Errorf(
+ "USB/IP prerequisite failed: usbip-win2 %s driver/CLI probe failed: %w%s",
+ requiredUSBIPVersion,
+ portErr,
+ formatUSBIPOutput(portOutput),
+ )
+ }
+ if reason := usbipProbeFailure(portOutput); reason != "" {
+ return fmt.Errorf(
+ "USB/IP prerequisite failed: usbip-win2 %s driver/CLI probe reported %s; repair USBIP and reboot before starting VIIPER",
+ requiredUSBIPVersion,
+ reason,
+ )
+ }
+
+ return nil
+}
+
+func usbipProbeFailure(output []byte) string {
+ text := strings.ToLower(strings.TrimSpace(string(output)))
+ for _, marker := range []string{
+ "abi mismatch",
+ "unexpected size",
+ "specified conversion is not valid",
+ "invalid structure size",
+ } {
+ if strings.Contains(text, marker) {
+ return marker
+ }
+ }
+ return ""
+}
+
+func formatUSBIPOutput(output []byte) string {
+ text := strings.TrimSpace(string(output))
+ if text == "" {
+ return ""
+ }
+ return ": " + text
+}
diff --git a/internal/cmd/usbip_prerequisite_windows_test.go b/internal/cmd/usbip_prerequisite_windows_test.go
new file mode 100644
index 00000000..bfdd83a9
--- /dev/null
+++ b/internal/cmd/usbip_prerequisite_windows_test.go
@@ -0,0 +1,90 @@
+//go:build windows
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProbeUSBIPRuntimeAcceptsPinnedCompatibleRuntime(t *testing.T) {
+ var calls [][]string
+ run := func(_ context.Context, executable string, args ...string) ([]byte, error) {
+ call := append([]string{executable}, args...)
+ calls = append(calls, call)
+ switch args[0] {
+ case "--version":
+ return []byte("0.9.7.7\r\n"), nil
+ case "port":
+ return []byte("Imported USB devices\r\n====================\r\n"), nil
+ default:
+ t.Fatalf("unexpected arguments: %v", args)
+ return nil, nil
+ }
+ }
+
+ err := probeUSBIPRuntime(`C:\Program Files\USBip\usbip.exe`, run)
+
+ require.NoError(t, err)
+ assert.Equal(t, [][]string{
+ {`C:\Program Files\USBip\usbip.exe`, "--version"},
+ {`C:\Program Files\USBip\usbip.exe`, "port"},
+ }, calls)
+}
+
+func TestProbeUSBIPRuntimeRejectsEveryOtherVersionBeforeDriverProbe(t *testing.T) {
+ portCalled := false
+ run := func(_ context.Context, _ string, args ...string) ([]byte, error) {
+ if args[0] == "port" {
+ portCalled = true
+ }
+ return []byte("0.9.7.8\r\n"), nil
+ }
+
+ err := probeUSBIPRuntime(`C:\Program Files\USBip\usbip.exe`, run)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "requires usbip-win2 0.9.7.7")
+ assert.Contains(t, err.Error(), "found 0.9.7.8")
+ assert.False(t, portCalled)
+}
+
+func TestProbeUSBIPRuntimeRejectsSuccessfulABIErrorOutput(t *testing.T) {
+ run := func(_ context.Context, _ string, args ...string) ([]byte, error) {
+ if args[0] == "--version" {
+ return []byte("0.9.7.7"), nil
+ }
+ return []byte("error: ABI mismatch, unexpected size of the input structure"), nil
+ }
+
+ err := probeUSBIPRuntime(`C:\Program Files\USBip\usbip.exe`, run)
+
+ require.Error(t, err)
+ assert.Contains(t, strings.ToLower(err.Error()), "abi mismatch")
+}
+
+func TestProbeUSBIPRuntimeIncludesPortFailureOutput(t *testing.T) {
+ run := func(_ context.Context, _ string, args ...string) ([]byte, error) {
+ if args[0] == "--version" {
+ return []byte("0.9.7.7"), nil
+ }
+ return []byte("driver query failed"), errors.New("exit status 1")
+ }
+
+ err := probeUSBIPRuntime(`C:\Program Files\USBip\usbip.exe`, run)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "driver query failed")
+}
+
+func TestUSBIPProbeFailureRecognizesKnownABIConversionError(t *testing.T) {
+ assert.Equal(t,
+ "specified conversion is not valid",
+ usbipProbeFailure([]byte("ERROR: The specified conversion is not valid.")),
+ )
+}
diff --git a/internal/codegen/common/readme.go b/internal/codegen/common/readme.go
index 777c0754..f98dee03 100644
--- a/internal/codegen/common/readme.go
+++ b/internal/codegen/common/readme.go
@@ -14,7 +14,7 @@ This is an automatically generated client library for [VIIPER](https://github.co
## Documentation
- **Project Repository**: https://github.com/Alia5/VIIPER
-- **Documentation**: https://alia5.github.io/VIIPER/
+- **Documentation**: https://github.com/hbashton/VIIPER
## About VIIPER
diff --git a/internal/codegen/generator/rust/project.go b/internal/codegen/generator/rust/project.go
index c7221cce..83a11c5b 100644
--- a/internal/codegen/generator/rust/project.go
+++ b/internal/codegen/generator/rust/project.go
@@ -18,7 +18,7 @@ description = "VIIPER Client Library for Rust"
license = "MIT"
repository = "https://github.com/Alia5/VIIPER"
homepage = "https://github.com/Alia5/VIIPER"
-documentation = "https://alia5.github.io/VIIPER/stable/clients/rust/"
+documentation = "https://github.com/hbashton/VIIPER"
readme = "README.md"
keywords = ["viiper", "usbip", "virtual-device", "input-emulation", "hid"]
categories = ["api-bindings", "hardware-support"]
diff --git a/internal/server/api/autoattach.go b/internal/server/api/autoattach.go
index 810a18e0..0a6b6033 100644
--- a/internal/server/api/autoattach.go
+++ b/internal/server/api/autoattach.go
@@ -7,6 +7,21 @@ import (
"github.com/Alia5/VIIPER/usbip"
)
+// AutoAttachResult identifies the USB/IP import created by auto-attach.
+// Command-based attach implementations cannot reliably discover this metadata
+// without a global port scan, so they return the zero value.
+type AutoAttachResult struct {
+ USBIPPort int32
+ USBIPOwnerSerial string
+}
+
func AttachLocalhostClient(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error {
+ _, err := AttachLocalhostClientWithResult(ctx, deviceExportMeta, usbipServerPort, useNativeIOCTL, logger)
+ return err
+}
+
+// AttachLocalhostClientWithResult attaches a device and returns the exact
+// USB/IP import metadata when the platform attach mechanism provides it.
+func AttachLocalhostClientWithResult(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) (AutoAttachResult, error) {
return attachLocalhostClientImpl(ctx, deviceExportMeta, usbipServerPort, useNativeIOCTL, logger)
}
diff --git a/internal/server/api/autoattach_linux.go b/internal/server/api/autoattach_linux.go
index 311433e0..3f8f838b 100644
--- a/internal/server/api/autoattach_linux.go
+++ b/internal/server/api/autoattach_linux.go
@@ -14,7 +14,7 @@ import (
"github.com/Alia5/VIIPER/usbip"
)
-func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, _ bool, logger *slog.Logger) error {
+func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, _ bool, logger *slog.Logger) (AutoAttachResult, error) {
logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID)
cmd := exec.CommandContext(
@@ -32,11 +32,11 @@ func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.Expo
"error", err,
"port", usbipServerPort,
"output", string(output))
- return err
+ return AutoAttachResult{}, err
}
logger.Debug("usbip attach output", "output", string(output))
- return nil
+ return AutoAttachResult{}, nil
}
// CheckAutoAttachPrerequisites checks if auto-attach prerequisites are met on Linux.
diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go
index a3b37774..fd9ab5a9 100644
--- a/internal/server/api/autoattach_windows.go
+++ b/internal/server/api/autoattach_windows.go
@@ -73,54 +73,45 @@ const (
ioctlPluginHardware = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | (0x800 << 2) | methodBuffered
)
-func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error {
+func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) (AutoAttachResult, error) {
if useNativeIOCTL {
- err := attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger)
- if err != nil {
- slog.Error("Native IOCTL auto-attach failed, falling back to command execution", "error", err)
- slog.Info("Trying fallback via usbip executable")
- } else {
- return nil
- }
+ // Never hide a native ABI mismatch behind usbip.exe. A mismatch means
+ // the pinned 0.9.7.7 userspace and loaded driver are not a valid pair and must
+ // be repaired/rebooted before VIIPER creates devices.
+ return attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger)
}
return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger)
}
-func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error {
+func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) (AutoAttachResult, error) {
logger.Info("Auto-attaching localhost client via native IOCTL",
"busID", deviceExportMeta.BusID,
"deviceID", deviceExportMeta.DevID)
if usbipServerPort == 0 {
- return fmt.Errorf("argumentValidation: invalid TCP port number (0)")
+ return AutoAttachResult{}, fmt.Errorf("argumentValidation: invalid TCP port number (0)")
}
devicePath, err := getDeviceInterfacePath(&deviceGUID)
if err != nil {
- return fmt.Errorf("discovery: %w", err)
+ return AutoAttachResult{}, fmt.Errorf("discovery: %w", err)
}
logger.Debug("Found usbip-win2 device", "path", devicePath)
- var ioctlData attachIOCTL
- ioctlData.Size = uint32(unsafe.Sizeof(ioctlData))
-
busID := fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID)
- if len(busID) >= len(ioctlData.BusID) {
- return fmt.Errorf("argumentValidation: bus ID too long: %s", busID)
+ if len(busID) >= len(attachIOCTL{}.BusID) {
+ return AutoAttachResult{}, fmt.Errorf("argumentValidation: bus ID too long: %s", busID)
}
- copy(ioctlData.BusID[:], busID)
service := fmt.Sprintf("%d", usbipServerPort)
- if len(service) >= len(ioctlData.Service) {
- return fmt.Errorf("argumentValidation: service string too long: %s", service)
+ if len(service) >= len(attachIOCTL{}.Service) {
+ return AutoAttachResult{}, fmt.Errorf("argumentValidation: service string too long: %s", service)
}
- copy(ioctlData.Service[:], service)
- copy(ioctlData.Host[:], "localhost")
devicePathUTF16, err := windows.UTF16PtrFromString(devicePath)
if err != nil {
- return fmt.Errorf("open: failed to convert device path: %w", err)
+ return AutoAttachResult{}, fmt.Errorf("open: failed to convert device path: %w", err)
}
handle, err := windows.CreateFile(
@@ -133,42 +124,59 @@ func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbip
0,
)
if err != nil {
- return fmt.Errorf("open: failed to open usbip-win2 device: %w", err)
+ return AutoAttachResult{}, fmt.Errorf("open: failed to open usbip-win2 device: %w", err)
}
defer windows.CloseHandle(handle) // nolint
logger.Debug("Opened device handle")
- var bytesReturned uint32
- err = windows.DeviceIoControl(
- handle,
- ioctlPluginHardware,
- (*byte)(unsafe.Pointer(&ioctlData)),
- uint32(unsafe.Sizeof(ioctlData)),
- (*byte)(unsafe.Pointer(&ioctlData)),
- uint32(unsafe.Sizeof(ioctlData)),
- &bytesReturned,
- nil,
- )
+ ioctlData := attachIOCTL{Size: uint32(unsafe.Sizeof(attachIOCTL{}))}
+ copy(ioctlData.BusID[:], busID)
+ copy(ioctlData.Service[:], service)
+ copy(ioctlData.Host[:], "localhost")
+ port, bytesReturned, err := submitAttachIOCTL(handle,
+ unsafe.Pointer(&ioctlData), ioctlData.Size, &ioctlData.PortOutput)
if err != nil {
- return fmt.Errorf("IOControl: DeviceIoControl failed: %w", err)
+ return AutoAttachResult{}, fmt.Errorf("IOControl: usbip-win2 0.9.7.7 native attach failed (repair or reboot USBIP; no command fallback was attempted): %w", err)
}
- logger.Debug("IOCTL completed", "bytesReturned", bytesReturned, "portOutput", ioctlData.PortOutput)
+ logger.Debug("IOCTL completed", "bytesReturned", bytesReturned, "portOutput", port)
- if ioctlData.PortOutput <= 0 {
- return fmt.Errorf("ResponseValidation: invalid USB port returned: %d", ioctlData.PortOutput)
+ if port <= 0 {
+ return AutoAttachResult{}, fmt.Errorf("ResponseValidation: invalid USB port returned: %d", port)
}
logger.Info("Successfully attached device via IOCTL",
"busID", deviceExportMeta.BusID,
"deviceID", deviceExportMeta.DevID,
- "usbPort", ioctlData.PortOutput)
+ "usbPort", port)
+
+ return AutoAttachResult{
+ USBIPPort: port,
+ }, nil
+}
- return nil
+func submitAttachIOCTL(handle windows.Handle, data unsafe.Pointer, size uint32,
+ portOutput *int32) (int32, uint32, error) {
+ var bytesReturned uint32
+ err := windows.DeviceIoControl(
+ handle,
+ ioctlPluginHardware,
+ (*byte)(data),
+ size,
+ (*byte)(data),
+ 8, // usbip-win2 returns only base.size + location.port.
+ &bytesReturned,
+ nil,
+ )
+ if err == nil && bytesReturned != 8 {
+ return 0, bytesReturned, fmt.Errorf(
+ "usbip-win2 returned %d attach bytes; expected 8", bytesReturned)
+ }
+ return *portOutput, bytesReturned, err
}
-func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error {
+func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) (AutoAttachResult, error) {
logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID)
cmd := exec.CommandContext(
@@ -186,21 +194,18 @@ func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, u
"error", err,
"port", usbipServerPort,
"output", string(output))
- return err
+ return AutoAttachResult{}, err
}
logger.Debug("usbip attach output", "output", string(output))
- return nil
+ return AutoAttachResult{}, nil
}
func resolveUsbipExecutable() string {
- if path, err := exec.LookPath("usbip"); err == nil {
- return path
- }
-
// The usbip-win2 installer does not consistently add its directory to
- // PATH for already-running services. Prefer the standard install location
- // before returning the bare command and its useful original error.
+ // PATH for already-running services. Prefer the canonical installation so
+ // a stale copy elsewhere cannot pair a different userspace ABI with the
+ // pinned 0.9.7.7 driver.
seen := make(map[string]struct{})
for _, root := range []string{
os.Getenv("ProgramW6432"),
@@ -223,6 +228,10 @@ func resolveUsbipExecutable() string {
}
}
+ if path, err := exec.LookPath("usbip"); err == nil {
+ return path
+ }
+
return "usbip"
}
@@ -308,9 +317,8 @@ func CheckAutoAttachPrerequisites(useNativeIOCTL bool, logger *slog.Logger) bool
if err != nil {
logger.Warn("Native IOCTL auto-attach prerequisites not met", "error", err)
logger.Warn("Native IOCTL auto-attach is unavailable until discovery succeeds")
- logger.Info("If usbip-win2 is not installed, download and install:")
- logger.Info(" https://github.com/vadimgrn/usbip-win2")
- logger.Info(" https://github.com/OSSign/vadimgrn--usbip-win2")
+ logger.Info("Install the exact signed usbip-win2 0.9.7.7 x64 package:")
+ logger.Info(" https://github.com/vadimgrn/usbip-win2/releases/tag/v.0.9.7.7")
return false
}
logger.Debug("usbip-win2 driver found")
diff --git a/internal/server/api/autoattach_windows_test.go b/internal/server/api/autoattach_windows_test.go
new file mode 100644
index 00000000..4e4dae82
--- /dev/null
+++ b/internal/server/api/autoattach_windows_test.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package api
+
+import (
+ "testing"
+ "unsafe"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsbipAttachABISizes(t *testing.T) {
+ require.Equal(t, uintptr(1100), unsafe.Sizeof(attachIOCTL{}),
+ "usbip-win2 0.9.7.7 plugin_hardware ABI changed")
+}
+
+func TestNativeAutoAttachResultCarriesExactPort(t *testing.T) {
+ got := AutoAttachResult{
+ USBIPPort: 7,
+ }
+
+ require.Equal(t, int32(7), got.USBIPPort)
+ require.Empty(t, got.USBIPOwnerSerial)
+}
diff --git a/internal/server/api/device_stream_handler.go b/internal/server/api/device_stream_handler.go
index fbb4ef88..c7e31ef1 100644
--- a/internal/server/api/device_stream_handler.go
+++ b/internal/server/api/device_stream_handler.go
@@ -39,6 +39,11 @@ func inferDeviceType(dev any) string {
if dev == nil {
return ""
}
+ if typed, ok := dev.(interface{ VIIPERDeviceType() string }); ok {
+ if deviceType := strings.ToLower(typed.VIIPERDeviceType()); deviceType != "" {
+ return deviceType
+ }
+ }
t := reflect.TypeOf(dev)
if t.Kind() == reflect.Pointer {
t = t.Elem()
diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go
index c22487fe..7e8e6ff2 100644
--- a/internal/server/api/handler/bus_device_add.go
+++ b/internal/server/api/handler/bus_device_add.go
@@ -14,6 +14,8 @@ import (
"github.com/Alia5/VIIPER/viipertypes"
)
+var attachLocalhostClientWithResult = api.AttachLocalhostClientWithResult
+
// BusDeviceAdd returns a handler to add devices to a bus.
func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc {
return func(req *api.Request, res *api.Response, logger *slog.Logger) error {
@@ -77,8 +79,9 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc {
apiSrv.ScheduleDeviceCleanup(uint32(busID),
fmt.Sprintf("%d", exportMeta.DevID), devCtx)
+ autoAttachResult := api.AutoAttachResult{}
if apiSrv.Config().AutoAttachLocalClient {
- err := api.AttachLocalhostClient(
+ autoAttachResult, err = attachLocalhostClientWithResult(
req.Ctx,
exportMeta,
s.GetListenPort(),
@@ -94,12 +97,14 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc {
}
payload, err := json.Marshal(viipertypes.Device{
- BusID: uint32(busID),
- DevID: fmt.Sprintf("%d", exportMeta.DevID),
- Vid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDVendor),
- Pid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDProduct),
- Type: name,
- DeviceSpecific: dev.GetDeviceSpecificArgs(),
+ BusID: uint32(busID),
+ DevID: fmt.Sprintf("%d", exportMeta.DevID),
+ Vid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDVendor),
+ Pid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDProduct),
+ Type: name,
+ DeviceSpecific: dev.GetDeviceSpecificArgs(),
+ USBIPPort: autoAttachResult.USBIPPort,
+ USBIPOwnerSerial: autoAttachResult.USBIPOwnerSerial,
})
if err != nil {
return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err))
diff --git a/internal/server/api/handler/bus_device_add_internal_test.go b/internal/server/api/handler/bus_device_add_internal_test.go
new file mode 100644
index 00000000..63eadc94
--- /dev/null
+++ b/internal/server/api/handler/bus_device_add_internal_test.go
@@ -0,0 +1,68 @@
+package handler
+
+import (
+ "context"
+ "log/slog"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ _ "github.com/Alia5/VIIPER/device/xbox360"
+ th "github.com/Alia5/VIIPER/internal/_testing"
+ "github.com/Alia5/VIIPER/internal/server/api"
+ usbs "github.com/Alia5/VIIPER/internal/server/usb"
+ "github.com/Alia5/VIIPER/usbip"
+ "github.com/Alia5/VIIPER/viiperclient"
+ "github.com/Alia5/VIIPER/virtualbus"
+)
+
+func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) {
+ const ownerSerial = "DS4W123456789AB"
+ type attachCall struct {
+ busID uint32
+ devID uint32
+ native bool
+ }
+ attachCalls := make(chan attachCall, 1)
+ originalAutoAttach := attachLocalhostClientWithResult
+ t.Cleanup(func() { attachLocalhostClientWithResult = originalAutoAttach })
+ attachLocalhostClientWithResult = func(_ context.Context, meta *usbip.ExportMeta, _ uint16, native bool, _ *slog.Logger) (api.AutoAttachResult, error) {
+ attachCalls <- attachCall{busID: meta.BusID, devID: meta.DevID, native: native}
+ return api.AutoAttachResult{
+ USBIPPort: 7,
+ USBIPOwnerSerial: ownerSerial,
+ }, nil
+ }
+
+ addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) {
+ apiSrv.Config().AutoAttachLocalClient = true
+ apiSrv.Config().AutoAttachWindowsNative = true
+
+ bus, err := virtualbus.NewWithBusID(80251)
+ require.NoError(t, err)
+ require.NoError(t, s.AddBus(bus))
+
+ r.Register("bus/{id}/add", BusDeviceAdd(s, apiSrv))
+ })
+ defer done()
+
+ client := viiperclient.NewTransport(addr)
+ response, err := client.Do("bus/{id}/add", `{"type":"xbox360"}`, map[string]string{"id": "80251"})
+ require.NoError(t, err)
+ assert.JSONEq(t, `{
+ "busId": 80251,
+ "devId": "1",
+ "deviceSpecific": {"subType": 1},
+ "vid": "0x045e",
+ "pid": "0x028e",
+ "type": "xbox360",
+ "usbipPort": 7,
+ "usbipOwnerSerial": "DS4W123456789AB"
+ }`, response)
+
+ call := <-attachCalls
+ require.Equal(t, uint32(80251), call.busID)
+ require.Equal(t, uint32(1), call.devID)
+ require.True(t, call.native)
+}
diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go
index 8a495411..9207cbc6 100644
--- a/internal/server/api/handler/bus_devices_list.go
+++ b/internal/server/api/handler/bus_devices_list.go
@@ -59,6 +59,11 @@ func inferDeviceType(dev any) string {
if dev == nil {
return ""
}
+ if typed, ok := dev.(interface{ VIIPERDeviceType() string }); ok {
+ if deviceType := strings.ToLower(typed.VIIPERDeviceType()); deviceType != "" {
+ return deviceType
+ }
+ }
t := reflect.TypeOf(dev)
if t.Kind() == reflect.Pointer {
t = t.Elem()
diff --git a/internal/server/api/handler/dualsense_traffic.go b/internal/server/api/handler/dualsense_traffic.go
deleted file mode 100644
index 6d126527..00000000
--- a/internal/server/api/handler/dualsense_traffic.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "fmt"
- "log/slog"
-
- "github.com/Alia5/VIIPER/device/dualsense"
- "github.com/Alia5/VIIPER/internal/server/api"
- apierror "github.com/Alia5/VIIPER/internal/server/api/error"
- "github.com/Alia5/VIIPER/viipertypes"
-)
-
-func DualSenseTrafficSet() api.HandlerFunc {
- return func(req *api.Request, res *api.Response, logger *slog.Logger) error {
- var payload viipertypes.DualSenseTrafficSetRequest
- if req.Payload != "" {
- if err := json.Unmarshal([]byte(req.Payload), &payload); err != nil {
- return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err))
- }
- }
-
- dualsense.SetTrafficDiagnosticsEnabled(payload.Enabled, payload.Clear)
- logger.Info("DualSense traffic diagnostics set", "enabled", payload.Enabled, "clear", payload.Clear)
- events := dualsense.TrafficDiagnosticsSnapshot()
- out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{
- Enabled: dualsense.TrafficDiagnosticsEnabled(),
- Count: len(events),
- })
- if err != nil {
- return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err))
- }
- res.JSON = string(out)
- return nil
- }
-}
-
-func DualSenseTrafficGet() api.HandlerFunc {
- return func(_ *api.Request, res *api.Response, _ *slog.Logger) error {
- events := dualsense.TrafficDiagnosticsSnapshot()
- out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{
- Enabled: dualsense.TrafficDiagnosticsEnabled(),
- Count: len(events),
- Events: makeDualSenseTrafficEvents(events),
- })
- if err != nil {
- return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err))
- }
- res.JSON = string(out)
- return nil
- }
-}
-
-func DualSenseTrafficClear() api.HandlerFunc {
- return func(_ *api.Request, res *api.Response, logger *slog.Logger) error {
- dualsense.ClearTrafficDiagnostics()
- logger.Info("DualSense traffic diagnostics cleared")
- events := dualsense.TrafficDiagnosticsSnapshot()
- out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{
- Enabled: dualsense.TrafficDiagnosticsEnabled(),
- Count: len(events),
- })
- if err != nil {
- return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err))
- }
- res.JSON = string(out)
- return nil
- }
-}
-
-func makeDualSenseTrafficEvents(events []dualsense.TrafficEvent) []viipertypes.DualSenseTrafficEvent {
- out := make([]viipertypes.DualSenseTrafficEvent, len(events))
- for idx, event := range events {
- out[idx] = viipertypes.DualSenseTrafficEvent{
- TimeUTC: event.TimeUTC,
- Direction: event.Direction,
- Source: event.Source,
- ReportType: event.ReportType,
- ReportID: event.ReportID,
- Request: event.Request,
- Value: event.Value,
- Index: event.Index,
- Length: event.Length,
- Hex: event.Hex,
- Summary: event.Summary,
- DecodedOutput: event.DecodedOutput,
- }
- }
-
- return out
-}
diff --git a/internal/server/api/stream_reconnect_test.go b/internal/server/api/stream_reconnect_test.go
index 31c91313..bf57b072 100644
--- a/internal/server/api/stream_reconnect_test.go
+++ b/internal/server/api/stream_reconnect_test.go
@@ -157,7 +157,7 @@ func TestAPIServer_DeviceStreamCloseFirstReconnectPreservesDualSenseMicrophoneQu
require.NoError(t, usbServer.AddBus(bus))
client := viiperclient.New(apiServer.Addr())
- created, err := client.DeviceAdd(busID, "dualsensecombinedmicv2", nil)
+ created, err := client.DeviceAdd(busID, "dualsensecombinedaudioduplexv5", nil)
require.NoError(t, err)
require.NotNil(t, created)
@@ -219,7 +219,7 @@ func makeDualSenseStreamFrame(sequence uint32, payload []byte) []byte {
const headerSize = 16
header := make([]byte, headerSize)
copy(header[0:4], []byte{'V', 'P', 'C', 'M'})
- header[4] = dualsense.StreamFrameVersionV2
+ header[4] = dualsense.StreamFrameVersionV5
header[5] = dualsense.StreamFrameMicrophonePCM
binary.LittleEndian.PutUint16(header[6:8], uint16(len(payload)))
binary.LittleEndian.PutUint32(header[8:12], sequence)
diff --git a/internal/tray/tray_windows.go b/internal/tray/tray_windows.go
index 82bb530f..d288a795 100644
--- a/internal/tray/tray_windows.go
+++ b/internal/tray/tray_windows.go
@@ -13,6 +13,7 @@ import (
"runtime/debug"
"fyne.io/systray"
+ "github.com/Alia5/VIIPER/internal/codegen/common"
"golang.org/x/sys/windows/registry"
)
@@ -67,6 +68,14 @@ func Run(ctx context.Context, shutdown func()) {
}
func readVersion() string {
+ // Release builds inject this value explicitly. debug.ReadBuildInfo reports
+ // "(devel)" for a binary built directly from a checkout, even when the
+ // release linker metadata is present, which previously made the tray show
+ // "VIIPER - dev" for packaged 0.0.6 builds.
+ if version, err := common.GetVersion(); err == nil &&
+ version != "0.0.1-dev" {
+ return version
+ }
if info, ok := debug.ReadBuildInfo(); ok {
v := info.Main.Version
if v != "" && v != "(devel)" {
diff --git a/internal/tray/tray_windows_test.go b/internal/tray/tray_windows_test.go
new file mode 100644
index 00000000..a6907d31
--- /dev/null
+++ b/internal/tray/tray_windows_test.go
@@ -0,0 +1,18 @@
+//go:build windows
+
+package tray
+
+import (
+ "testing"
+
+ "github.com/Alia5/VIIPER/internal/codegen/common"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadVersionPrefersInjectedReleaseVersion(t *testing.T) {
+ original := common.Version
+ common.Version = "v0.0.6"
+ t.Cleanup(func() { common.Version = original })
+
+ require.Equal(t, "0.0.6", readVersion())
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 3b8ac665..cce42ad7 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -23,6 +24,10 @@ const (
ActionViewGitHub = 1
ActionUpdateNow = 2
ActionDismiss = 3
+
+ repositoryURL = "https://github.com/hbashton/VIIPER"
+ releasesAPIURL = "https://api.github.com/repos/hbashton/VIIPER/releases"
+ installScriptBaseURL = "https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install"
)
var (
@@ -85,7 +90,6 @@ type release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Prerelease bool `json:"prerelease"`
- HTMLURL string `json:"html_url"`
}
func CheckUpdate(currentVersion string, notify config.UpdateNotify) {
@@ -97,7 +101,7 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) {
var r release
if notify == config.UpdateNotifyPrerelease {
- resp, err := client.Get("https://api.github.com/repos/Alia5/VIIPER/releases?per_page=1")
+ resp, err := client.Get(releasesAPIURL + "?per_page=1")
if err != nil {
slog.Error("failed to fetch releases", "error", err)
return
@@ -117,7 +121,7 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) {
}
r = releases[0]
} else {
- resp, err := client.Get("https://api.github.com/repos/Alia5/VIIPER/releases/latest")
+ resp, err := client.Get(releasesAPIURL + "/latest")
if err != nil {
slog.Error("failed to fetch latest release", "error", err)
return
@@ -160,11 +164,6 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) {
}
slog.Info("update available", "current", currentVersion, "available", matched)
- installChannel := "stable"
- if notify == config.UpdateNotifyPrerelease {
- installChannel = "main"
- }
-
action := showMessageBox(
"VIIPER Update Available",
fmt.Sprintf("A new version of VIIPER is available: %s", matched),
@@ -174,15 +173,19 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) {
case ActionRemindLater:
remindLaterVersion = matched
case ActionViewGitHub:
- openBrowser(r.HTMLURL)
+ openBrowser(releaseURL(r.TagName))
case ActionUpdateNow:
writeDismissed(matched)
- runInstallScript(installChannel)
+ runInstallScript()
case ActionDismiss:
writeDismissed(matched)
}
}
+func releaseURL(tag string) string {
+ return repositoryURL + "/releases/tag/" + url.PathEscape(tag)
+}
+
func openBrowser(url string) {
var err error
switch runtime.GOOS {
@@ -196,18 +199,17 @@ func openBrowser(url string) {
}
}
-func runInstallScript(channel string) {
- baseURL := "https://alia5.github.io/VIIPER/" + channel + "/install"
+func runInstallScript() {
switch runtime.GOOS {
case "windows":
- url := baseURL + ".ps1"
+ url := installScriptBaseURL + ".ps1"
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Start-Process powershell -ArgumentList '-NoExit -NoProfile -ExecutionPolicy Bypass -Command \"iwr -useb "+url+" | iex\"' -Verb RunAs")
if err := cmd.Run(); err != nil {
slog.Error("failed to run install script", "error", err)
}
case "linux":
- cmd := exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL '%s.sh' | sh", baseURL))
+ cmd := exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL '%s.sh' | sh", installScriptBaseURL))
if err := cmd.Start(); err != nil {
slog.Error("failed to run install script", "error", err)
}
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
new file mode 100644
index 00000000..768acd0b
--- /dev/null
+++ b/internal/updater/updater_test.go
@@ -0,0 +1,34 @@
+package updater
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRuntimeURLsUseHbashtonRepository(t *testing.T) {
+ t.Parallel()
+
+ urls := map[string]string{
+ "repository": repositoryURL,
+ "releases": releasesAPIURL,
+ "installer": installScriptBaseURL,
+ "release": releaseURL("v1.2.3"),
+ }
+ for name, runtimeURL := range urls {
+ if !strings.Contains(runtimeURL, "hbashton/VIIPER") {
+ t.Errorf("%s URL %q does not target hbashton/VIIPER", name, runtimeURL)
+ }
+ if strings.Contains(strings.ToLower(runtimeURL), "alia5") {
+ t.Errorf("%s URL %q retains the Alia5 repository", name, runtimeURL)
+ }
+ }
+}
+
+func TestReleaseURLEscapesTag(t *testing.T) {
+ t.Parallel()
+
+ const want = "https://github.com/hbashton/VIIPER/releases/tag/release%2Fcandidate"
+ if got := releaseURL("release/candidate"); got != want {
+ t.Fatalf("releaseURL() = %q, want %q", got, want)
+ }
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index a5a108bb..04efcea0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,8 +1,8 @@
site_name: VIIPER Documentation
site_description: Virtual Input over IP EmulatoR - Documentation
site_author: Peter Repukat
-repo_name: Alia5/VIIPER
-repo_url: https://github.com/Alia5/VIIPER
+repo_name: hbashton/VIIPER
+repo_url: https://github.com/hbashton/VIIPER
theme:
name: material
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 7fdb0f19..3c065555 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -1,34 +1,57 @@
-$ErrorActionPreference = "Stop"
+param(
+ [switch]$Yes
+)
-$viiperVersion = "dev-snapshot"
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
-$repo = "hbashton/VIIPER"
-$apiUrl = if ($viiperVersion -eq "dev-snapshot" -or $viiperVersion -eq "latest") {
- "https://api.github.com/repos/$repo/releases/latest"
+$viiperVersion = "v0.0.6"
+$usbipTargetVersion = [Version]"0.9.7.7"
+$installDir = Join-Path $env:LOCALAPPDATA "VIIPER"
+$usbipReplacementStatePath = Join-Path $installDir `
+ "usbip-replacement-pending.json"
+$usbipUninstallKeyName = "{199505b0-b93d-4521-a8c7-897818e0205a}_is1"
+$programFilesRoot = if ($env:ProgramW6432) {
+ $env:ProgramW6432
}
else {
- "https://api.github.com/repos/$repo/releases/tags/$viiperVersion"
+ $env:ProgramFiles
}
+$canonicalUsbipPath = Join-Path $programFilesRoot "USBip\usbip.exe"
+
+Write-Host "VIIPER setup plan:" -ForegroundColor Cyan
+Write-Host " 1. Download and install VIIPER first." -ForegroundColor Cyan
+Write-Host " 2. Verify or install signed usbip-win2 0.9.7.7." -ForegroundColor Cyan
+Write-Host " 3. Verify the USBIP ABI before starting VIIPER." -ForegroundColor Cyan
+Write-Host "USB hubs may restart, and Windows may require a reboot." -ForegroundColor Yellow
+if (-not $Yes) {
+ Write-Host "Save work first. Replacing an incompatible USBIP driver is a two-step process and may require you to restart Windows yourself." -ForegroundColor Yellow
+ $answer = Read-Host "Install VIIPER first and continue? [Y/N]"
+ if ($answer -notmatch '^(?i:y|yes)$') {
+ Write-Host "Setup canceled. No changes were made." -ForegroundColor Yellow
+ return
+ }
+}
+
+$repo = "hbashton/VIIPER"
+$apiUrl = "https://api.github.com/repos/$repo/releases/tags/$viiperVersion"
-Write-Host "Fetching VIIPER release: $viiperVersion..."
+Write-Host "[1/4] Fetching VIIPER release: $viiperVersion..." -ForegroundColor Cyan
$releaseData = Invoke-RestMethod -Uri $apiUrl -ErrorAction Stop
$version = $releaseData.tag_name
-if (-not $version) {
- Write-Host "Error: Could not fetch VIIPER release" -ForegroundColor Red
- exit 1
+if (-not $version) {
+ throw "Could not fetch the requested VIIPER release."
}
Write-Host "Version: $version"
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else {
- Write-Host "Error: Only 64-bit Windows is supported" -ForegroundColor Red
- exit 1
+ throw "Only 64-bit Windows is supported."
}
if ((Get-CimInstance Win32_ComputerSystem).SystemType -match "ARM") {
- Write-Host "Error: The current hbashton VIIPER package supports Windows x64 only" -ForegroundColor Red
- exit 1
+ throw "The current hbashton VIIPER package supports Windows x64 only."
}
$preferredAssetNames = @("viiper-windows-$arch.zip", "viiper.exe")
@@ -46,7 +69,28 @@ if (-not $asset) {
$downloadUrl = $asset.browser_download_url
Write-Host "Downloading from: $downloadUrl"
-$tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ }
+$tempDir = New-TemporaryFile | ForEach-Object {
+ Remove-Item $_
+ New-Item -ItemType Directory -Path $_
+}
+$setupMutex = [Threading.Mutex]::new($false, "Local\DS4Windows-VIIPER-Setup")
+$setupMutexAcquired = $false
+try {
+ try {
+ $setupMutexAcquired = $setupMutex.WaitOne(0)
+ }
+ catch [Threading.AbandonedMutexException] {
+ $setupMutexAcquired = $true
+ }
+ if (-not $setupMutexAcquired) {
+ throw "Another DS4Windows/VIIPER setup is already running. Wait for it to finish, then try again."
+ }
+}
+catch {
+ $setupMutex.Dispose()
+ Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
+ throw
+}
try {
function Get-ViiperVersion($path) {
@@ -61,17 +105,190 @@ try {
return $null
}
- function Parse-VersionOrNull($ver) {
- if (-not $ver) { return $null }
- $clean = $ver.Trim().TrimStart('v', 'V')
- $clean = $clean.Split('-')[0]
- try { return [Version]$clean }
- catch { return $null }
- }
+ function Parse-VersionOrNull($ver) {
+ if (-not $ver) { return $null }
+ $clean = $ver.Trim().TrimStart('v', 'V')
+ $clean = $clean.Split('-')[0]
+ try { return [Version]$clean }
+ catch { return $null }
+ }
+
+ function Get-WindowsBootSessionId {
+ $bootTime = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).
+ LastBootUpTime
+ if ($bootTime -isnot [DateTime]) {
+ $bootTime = [Management.ManagementDateTimeConverter]::ToDateTime(
+ [string]$bootTime)
+ }
+ return $bootTime.ToUniversalTime().Ticks.ToString(
+ [Globalization.CultureInfo]::InvariantCulture)
+ }
+
+ function Set-UsbipReplacementBoundary([Version]$installedVersion,
+ [Version]$requiredVersion) {
+ New-Item -ItemType Directory -Path $installDir -Force | Out-Null
+ [ordered]@{
+ BootSessionId = Get-WindowsBootSessionId
+ RemovedVersion = $installedVersion.ToString()
+ RequiredVersion = $requiredVersion.ToString()
+ StartedUtc = [DateTime]::UtcNow.ToString("o")
+ } | ConvertTo-Json | Set-Content -LiteralPath `
+ $usbipReplacementStatePath -Encoding UTF8
+ }
+
+ function Assert-UsbipPostRebootState {
+ $activeDevices = @(Get-CimInstance Win32_PnPEntity -ErrorAction Stop |
+ Where-Object {
+ $_.Service -eq 'usbip2_ude' -or
+ $_.PNPDeviceID -eq 'ROOT\USB\0000'
+ })
+ if ($activeDevices.Count -gt 0) {
+ throw "The old usbip2_ude root device is still present " +
+ "after reboot. No replacement driver was installed."
+ }
+
+ $runningDrivers = @(Get-CimInstance Win32_SystemDriver `
+ -ErrorAction Stop | Where-Object {
+ $_.State -eq "Running" -and
+ $_.Name -match '(?i)^usbip2_(?:ude|filter)$'
+ })
+ if ($runningDrivers.Count -gt 0) {
+ $names = ($runningDrivers | ForEach-Object { $_.Name }) -join ", "
+ throw "Old USBIP driver service(s) remain active after reboot: " +
+ "$names. No replacement driver was installed."
+ }
+
+ $previousErrorActionPreference = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ $driverStoreOutput = @(& pnputil.exe /enum-drivers 2>&1)
+ $driverStoreExitCode = $LASTEXITCODE
+ }
+ finally {
+ $ErrorActionPreference = $previousErrorActionPreference
+ }
+ $driverStoreText = ($driverStoreOutput | ForEach-Object {
+ [string]$_
+ }) -join [Environment]::NewLine
+ if ($driverStoreExitCode -ne 0) {
+ throw "Could not verify the DriverStore after reboot " +
+ "(pnputil exit=$driverStoreExitCode): $driverStoreText"
+ }
+ if ($driverStoreText -match
+ '(?im)\busbip2_(?:ude|filter)\.inf\b') {
+ throw "An old usbip2_ude.inf or usbip2_filter.inf package " +
+ "remains in the DriverStore. Remove it safely, reboot, then " +
+ "run setup again."
+ }
+ }
+
+ function Resolve-UsbipReplacementBoundary {
+ if (-not (Test-Path -LiteralPath $usbipReplacementStatePath)) {
+ return
+ }
+
+ try {
+ $state = Get-Content -LiteralPath $usbipReplacementStatePath `
+ -Raw | ConvertFrom-Json
+ }
+ catch {
+ throw "The USBIP replacement state is unreadable. Refusing to " +
+ "cross the driver reboot boundary automatically."
+ }
+
+ if (-not $state.BootSessionId -or
+ $state.BootSessionId -eq (Get-WindowsBootSessionId)) {
+ throw "USBIP replacement phase 1 is complete. Restart Windows " +
+ "yourself, then run setup again to install 0.9.7.7."
+ }
+
+ Write-Host "Validating the required reboot after USBIP removal..." `
+ -ForegroundColor Cyan
+ Assert-UsbipPostRebootState
+ Remove-Item -LiteralPath $usbipReplacementStatePath -Force
+ Write-Host "Reboot boundary validated; phase 2 may proceed." `
+ -ForegroundColor Green
+ }
+
+ function Get-UsbipUninstallEntry([Version]$installedVersion) {
+ if (-not $installedVersion) { return $null }
+
+ $matches = @()
+ foreach ($basePath in @(
+ "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall",
+ "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
+ )) {
+ $path = Join-Path $basePath $usbipUninstallKeyName
+ if (-not (Test-Path -LiteralPath $path)) { continue }
+ $entry = Get-ItemProperty -LiteralPath $path -ErrorAction Stop
+ $entryVersion = Parse-VersionOrNull `
+ ($entry.DisplayVersion -as [string])
+ $displayName = $entry.DisplayName -as [string]
+ $publisher = $entry.Publisher -as [string]
+ $installLocation = $entry.InstallLocation -as [string]
+ if (-not [string]::Equals($publisher, "usbip-win2",
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not [string]::Equals($displayName,
+ "USBip version $installedVersion",
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not $entryVersion -or
+ $entryVersion -ne $installedVersion) {
+ throw "USBIP uninstall metadata does not exactly match " +
+ "installed version $installedVersion. No driver " +
+ "transition was started."
+ }
+ if ([string]::IsNullOrWhiteSpace($installLocation) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($installLocation).TrimEnd('\', '/'),
+ [IO.Path]::GetFullPath(
+ (Split-Path -Parent $canonicalUsbipPath)).TrimEnd('\', '/'),
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "USBIP uninstall metadata points outside its canonical " +
+ "Program Files directory. No driver transition was started."
+ }
+ $matches += $entry
+ }
+
+ if ($matches.Count -gt 1) {
+ throw "Multiple uninstall records match usbip-win2 " +
+ "$installedVersion. Remove stale metadata manually before " +
+ "running setup again."
+ }
+ return $matches | Select-Object -First 1
+ }
+
+ function Get-UsbipUninstallRecords {
+ $records = @()
+ foreach ($basePath in @(
+ "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall",
+ "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
+ )) {
+ $path = Join-Path $basePath $usbipUninstallKeyName
+ if (Test-Path -LiteralPath $path) {
+ $records += Get-ItemProperty -LiteralPath $path `
+ -ErrorAction Stop
+ }
+ }
+ return $records
+ }
$tempDownload = Join-Path $tempDir $asset.name
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempDownload -ErrorAction Stop
+ $assetDigest = $asset.digest -as [string]
+ $digestMatch = [regex]::Match($assetDigest,
+ '^(?i:sha256):(?[0-9a-f]{64})$')
+ if (-not $digestMatch.Success) {
+ throw "Release asset '$($asset.name)' has no verifiable SHA-256 digest."
+ }
+ $downloadHash = (Get-FileHash -LiteralPath $tempDownload `
+ -Algorithm SHA256).Hash
+ if (-not [string]::Equals($downloadHash,
+ $digestMatch.Groups['hash'].Value,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "VIIPER release asset integrity check failed."
+ }
+
if ([IO.Path]::GetExtension($asset.name) -eq ".zip") {
$extractDir = Join-Path $tempDir "release"
Expand-Archive -LiteralPath $tempDownload -DestinationPath $extractDir -Force
@@ -84,19 +301,697 @@ try {
else {
$tempViiper = $tempDownload
}
+
+ function Get-RunningPadSenseProcesses {
+ try {
+ return @(Get-CimInstance Win32_Process -ErrorAction Stop |
+ Where-Object { $_.Name -match "^PadSense.*\.exe$" })
+ }
+ catch {
+ return @(Get-Process -ErrorAction SilentlyContinue |
+ Where-Object { $_.ProcessName -match "^PadSense" } |
+ ForEach-Object {
+ [pscustomobject]@{
+ Name = "$($_.ProcessName).exe"
+ ProcessId = $_.Id
+ }
+ })
+ }
+ }
+
+ function Assert-PadSenseNotRunning {
+ $processes = @(Get-RunningPadSenseProcesses)
+ if ($processes.Count -eq 0) { return }
+
+ $owners = ($processes | ForEach-Object {
+ "$($_.Name) PID=$($_.ProcessId)"
+ }) -join ", "
+ throw "PadSense is running ($owners) and may own active USBIP " +
+ "imports. Close PadSense completely, including its " +
+ "tray/background process, then run setup again. The usbip-win2 " +
+ "driver transition was not started."
+ }
+
+ function Stop-ControllerBackends {
+ $targets = @(Get-Process -Name "DS4Windows", "viiper" `
+ -ErrorAction SilentlyContinue)
+ if ($targets.Count -eq 0) { return }
+
+ Write-Host "Stopping DS4Windows and VIIPER before the USBIP driver transition..." -ForegroundColor Yellow
+ foreach ($process in $targets) {
+ try { [void]$process.CloseMainWindow() } catch { }
+ }
+ Start-Sleep -Milliseconds 750
+ foreach ($process in $targets) {
+ try {
+ if (-not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force -ErrorAction Stop
+ }
+ }
+ catch { }
+ }
+ Start-Sleep -Milliseconds 500
+
+ $remaining = @(Get-Process -Name "DS4Windows", "viiper" `
+ -ErrorAction SilentlyContinue)
+ if ($remaining.Count -gt 0) {
+ $owners = ($remaining | ForEach-Object {
+ "$($_.ProcessName).exe PID=$($_.Id)"
+ }) -join ", "
+ throw "Could not stop controller backend process(es): $owners. " +
+ "Close them manually, then run setup again. The usbip-win2 " +
+ "driver transition was not started."
+ }
+ }
+
+ function Test-ManagedViiperPath([string]$path, [string]$managedPath) {
+ if ([string]::IsNullOrWhiteSpace($path)) { return $false }
+ try {
+ return [string]::Equals(
+ [IO.Path]::GetFullPath($path).TrimEnd('\', '/'),
+ [IO.Path]::GetFullPath($managedPath).TrimEnd('\', '/'),
+ [StringComparison]::OrdinalIgnoreCase)
+ }
+ catch { return $false }
+ }
+
+ function Get-ForeignViiperProcesses([string]$managedPath) {
+ return @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue |
+ Where-Object {
+ -not [string]::IsNullOrWhiteSpace(
+ ($_.ExecutablePath -as [string])) -and
+ -not (Test-ManagedViiperPath `
+ ($_.ExecutablePath -as [string]) $managedPath)
+ })
+ }
+
+ function Remove-ForeignViiperInstallations([string]$managedPath,
+ $knownForeign = $null) {
+ $foreign = if ($null -ne $knownForeign) {
+ @($knownForeign)
+ }
+ else {
+ @(Get-ForeignViiperProcesses $managedPath)
+ }
+ if ($foreign.Count -eq 0) { return }
+
+ Write-Host "Running foreign VIIPER installation(s) were detected:" `
+ -ForegroundColor Yellow
+ foreach ($process in $foreign) {
+ $displayPath = if ($process.ExecutablePath) {
+ $process.ExecutablePath
+ }
+ else { "" }
+ Write-Host " PID=$($process.ProcessId) $displayPath" `
+ -ForegroundColor Yellow
+ }
+
+ # Never let -Yes silently authorize deletion outside the managed
+ # installation directory. This choice is always made by the user.
+ $answer = Read-Host (
+ "Use administrator rights to stop these processes, remove their " +
+ "viiper.exe files, and continue with the managed install? [Y/N]")
+ if ($answer -notmatch '^(?i:y|yes)$') {
+ throw "Setup stopped. VIIPER will not fall back to a process " +
+ "outside '$managedPath'. Close or remove it, then run setup again."
+ }
+
+ Disable-ViiperStartup
+ $targets = @($foreign | ForEach-Object {
+ [ordered]@{
+ Pid = [int]$_.ProcessId
+ Path = $_.ExecutablePath -as [string]
+ }
+ })
+ $payload = [ordered]@{
+ ManagedPath = [IO.Path]::GetFullPath($managedPath)
+ Targets = $targets
+ } | ConvertTo-Json -Depth 4 -Compress
+ $payloadBase64 = [Convert]::ToBase64String(
+ [Text.Encoding]::UTF8.GetBytes($payload))
+
+ $helper = @'
+$ErrorActionPreference = "Stop"
+$payloadJson = [Text.Encoding]::UTF8.GetString(
+ [Convert]::FromBase64String("__PAYLOAD__"))
+$payload = $payloadJson | ConvertFrom-Json
+$managed = [IO.Path]::GetFullPath([string]$payload.ManagedPath).TrimEnd('\', '/')
+$paths = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::OrdinalIgnoreCase)
+foreach ($target in @($payload.Targets)) {
+ $process = Get-CimInstance Win32_Process -Filter (
+ "ProcessId=" + [int]$target.Pid) -ErrorAction SilentlyContinue
+ $path = if ($process -and $process.ExecutablePath) {
+ [string]$process.ExecutablePath
+ }
+ else { [string]$target.Path }
+ if ([string]::IsNullOrWhiteSpace($path)) { exit 21 }
+ $path = [IO.Path]::GetFullPath($path)
+ if ([IO.Path]::GetFileName($path) -ine "viiper.exe" -or
+ [string]::Equals($path.TrimEnd('\', '/'), $managed,
+ [StringComparison]::OrdinalIgnoreCase)) { exit 22 }
+ [void]$paths.Add($path)
+ if ($process) {
+ & taskkill.exe /PID ([int]$target.Pid) /T /F 2>&1 | Out-Null
+ }
+}
+for ($attempt = 1; $attempt -le 12; $attempt++) {
+ $remaining = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue |
+ Where-Object {
+ -not $_.ExecutablePath -or
+ -not [string]::Equals(
+ ([IO.Path]::GetFullPath(
+ [string]$_.ExecutablePath)).TrimEnd('\', '/'),
+ $managed, [StringComparison]::OrdinalIgnoreCase)
+ })
+ if ($remaining.Count -eq 0) { break }
+ foreach ($process in $remaining) {
+ if ($process.ExecutablePath) {
+ $path = [IO.Path]::GetFullPath([string]$process.ExecutablePath)
+ if ([IO.Path]::GetFileName($path) -ine "viiper.exe") { exit 22 }
+ [void]$paths.Add($path)
+ }
+ & taskkill.exe /PID ([int]$process.ProcessId) /T /F 2>&1 | Out-Null
+ }
+ Start-Sleep -Milliseconds 250
+}
+$remaining = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue |
+ Where-Object {
+ -not $_.ExecutablePath -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath([string]$_.ExecutablePath).TrimEnd('\', '/'),
+ $managed, [StringComparison]::OrdinalIgnoreCase)
+ })
+if ($remaining.Count -gt 0) { exit 23 }
+foreach ($path in $paths) {
+ Remove-Item -LiteralPath $path -Force -ErrorAction Stop
+ if (Test-Path -LiteralPath $path) { exit 24 }
+}
+'@.Replace("__PAYLOAD__", $payloadBase64)
+ $encodedHelper = [Convert]::ToBase64String(
+ [Text.Encoding]::Unicode.GetBytes($helper))
+ Write-Host "Requesting administrator removal of foreign VIIPER..." `
+ -ForegroundColor Yellow
+ $result = Start-Process powershell.exe -Verb RunAs -PassThru -Wait `
+ -ArgumentList @("-NoProfile", "-NonInteractive",
+ "-ExecutionPolicy", "Bypass", "-EncodedCommand",
+ $encodedHelper)
+ if ($result.ExitCode -ne 0) {
+ throw "Administrator removal of foreign VIIPER failed with " +
+ "code $($result.ExitCode). No managed VIIPER was started."
+ }
+
+ $remaining = @(Get-ForeignViiperProcesses $managedPath)
+ if ($remaining.Count -gt 0) {
+ throw "A foreign VIIPER process returned after administrator " +
+ "removal. No managed VIIPER was started."
+ }
+ Write-Host "Foreign VIIPER installation(s) removed." `
+ -ForegroundColor Green
+ }
+
+ function Stop-ManagedViiperInstances([string]$managedPath) {
+ $running = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue)
+ if ($running.Count -eq 0) { return }
+
+ $managedFullPath = [IO.Path]::GetFullPath($managedPath)
+ $inventoryPath = Join-Path $tempDir (
+ "viiper-inventory-" + [Guid]::NewGuid().ToString("N") + ".json")
+ $payload = [ordered]@{
+ ManagedPath = $managedFullPath
+ InventoryPath = $inventoryPath
+ } | ConvertTo-Json -Compress
+ $payloadBase64 = [Convert]::ToBase64String(
+ [Text.Encoding]::UTF8.GetBytes($payload))
+ $helper = @'
+$ErrorActionPreference = "Stop"
+$payloadJson = [Text.Encoding]::UTF8.GetString(
+ [Convert]::FromBase64String("__PAYLOAD__"))
+$payload = $payloadJson | ConvertFrom-Json
+$managed = [IO.Path]::GetFullPath(
+ [string]$payload.ManagedPath).TrimEnd('\', '/')
+$inventoryPath = [IO.Path]::GetFullPath([string]$payload.InventoryPath)
+$running = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction Stop)
+
+# Validate the complete process set before terminating anything. A binary from
+# another location must go through the installer's explicit removal prompt.
+$foreign = @()
+foreach ($process in $running) {
+ $path = [string]$process.ExecutablePath
+ if ([string]::IsNullOrWhiteSpace($path) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($path).TrimEnd('\', '/'), $managed,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ $foreign += [ordered]@{
+ ProcessId = [int]$process.ProcessId
+ ExecutablePath = $path
+ }
+ }
+}
+if ($foreign.Count -gt 0) {
+ [ordered]@{ Processes = @($foreign) } | ConvertTo-Json -Depth 4 |
+ Set-Content -LiteralPath $inventoryPath -Encoding UTF8
+ exit 31
+}
+foreach ($process in $running) {
+ & taskkill.exe /PID ([int]$process.ProcessId) /T /F 2>&1 | Out-Null
+}
+for ($attempt = 1; $attempt -le 20; $attempt++) {
+ $remaining = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue)
+ if ($remaining.Count -eq 0) { exit 0 }
+ $foreign = @()
+ foreach ($process in $remaining) {
+ $path = [string]$process.ExecutablePath
+ if ([string]::IsNullOrWhiteSpace($path) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($path).TrimEnd('\', '/'), $managed,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ $foreign += [ordered]@{
+ ProcessId = [int]$process.ProcessId
+ ExecutablePath = $path
+ }
+ }
+ }
+ if ($foreign.Count -gt 0) {
+ [ordered]@{ Processes = @($foreign) } | ConvertTo-Json -Depth 4 |
+ Set-Content -LiteralPath $inventoryPath -Encoding UTF8
+ exit 31
+ }
+ Start-Sleep -Milliseconds 250
+}
+exit 32
+'@.Replace("__PAYLOAD__", $payloadBase64)
+ $encodedHelper = [Convert]::ToBase64String(
+ [Text.Encoding]::Unicode.GetBytes($helper))
+
+ Write-Host "Stopping the managed VIIPER instance for replacement..." `
+ -ForegroundColor Cyan
+ $result = Start-Process powershell.exe -Verb RunAs -PassThru -Wait `
+ -ArgumentList @("-NoProfile", "-NonInteractive",
+ "-ExecutionPolicy", "Bypass", "-EncodedCommand",
+ $encodedHelper)
+ if ($result.ExitCode -eq 31) {
+ if (-not (Test-Path -LiteralPath $inventoryPath -PathType Leaf)) {
+ throw "A VIIPER process outside '$managedPath' appeared during setup, but its path could not be verified. No managed binary was replaced."
+ }
+ $inventory = Get-Content -LiteralPath $inventoryPath -Raw |
+ ConvertFrom-Json
+ Remove-ForeignViiperInstallations $managedPath `
+ @($inventory.Processes)
+ Remove-Item -LiteralPath $inventoryPath -Force `
+ -ErrorAction SilentlyContinue
+ Stop-ManagedViiperInstances $managedPath
+ return
+ }
+ if ($result.ExitCode -ne 0) {
+ throw "Administrator shutdown of managed VIIPER failed with code $($result.ExitCode). Close it manually and run setup again."
+ }
+ $remaining = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue)
+ if ($remaining.Count -gt 0) {
+ throw "VIIPER is still running after administrator shutdown. No managed binary was replaced."
+ }
+ Remove-Item -LiteralPath $inventoryPath -Force `
+ -ErrorAction SilentlyContinue
+ }
+
+ function Get-CanonicalUsbipPath {
+ if (Test-Path -LiteralPath $canonicalUsbipPath -PathType Leaf) {
+ return $canonicalUsbipPath
+ }
+ return $null
+ }
+
+ function Test-UsbipRuntime([Version]$expectedVersion) {
+ $usbipPath = Get-CanonicalUsbipPath
+ if (-not $usbipPath) {
+ return [pscustomobject]@{
+ Healthy = $false
+ Reason = "missing"
+ Detail = "canonical usbip.exe is missing"
+ }
+ }
+
+ $actualVersion = $null
+ try { $actualVersion = [Version](Get-Item -LiteralPath $usbipPath).VersionInfo.FileVersion }
+ catch { }
+ if (-not $actualVersion -or $actualVersion -ne $expectedVersion) {
+ return [pscustomobject]@{
+ Healthy = $false
+ Reason = "version"
+ Detail = "usbip.exe version is '$actualVersion'; expected $expectedVersion"
+ }
+ }
+
+ $previousErrorActionPreference = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ $output = & $usbipPath port 2>&1
+ }
+ finally {
+ $ErrorActionPreference = $previousErrorActionPreference
+ }
+ $exitCode = $LASTEXITCODE
+ $text = ($output | Out-String).Trim()
+ if ($text -match "(?i)ABI\s+mismatch|unexpected\s+size|specified\s+conversion\s+is\s+not\s+valid|invalid\s+structure\s+size") {
+ return [pscustomobject]@{
+ Healthy = $false
+ Reason = "abi-mismatch"
+ Detail = "usbip.exe port failed (exit $exitCode): $text"
+ }
+ }
+
+ if ($exitCode -ne 0) {
+ return [pscustomobject]@{
+ Healthy = $false
+ Reason = "probe-failed"
+ Detail = "usbip.exe port failed (exit $exitCode): $text"
+ }
+ }
+
+ return [pscustomobject]@{
+ Healthy = $true
+ Reason = "ready"
+ Detail = "0.9.7.7 ABI probe passed"
+ }
+ }
+
+ function Get-UsbipPortBlocks([string]$portText) {
+ $blocks = @()
+ $currentPort = $null
+ $currentLines = [Collections.Generic.List[string]]::new()
+ foreach ($line in ($portText -split "`r?`n")) {
+ $header = [regex]::Match($line, '(?i)^\s*Port\s+(\d+):')
+ if ($header.Success) {
+ if ($null -ne $currentPort) {
+ $blocks += [pscustomobject]@{
+ Port = $currentPort
+ Text = $currentLines -join [Environment]::NewLine
+ }
+ }
+ $currentPort = [int]$header.Groups[1].Value
+ $currentLines = [Collections.Generic.List[string]]::new()
+ }
+ if ($null -ne $currentPort) { $currentLines.Add($line) }
+ }
+ if ($null -ne $currentPort) {
+ $blocks += [pscustomobject]@{
+ Port = $currentPort
+ Text = $currentLines -join [Environment]::NewLine
+ }
+ }
+ return $blocks
+ }
+
+ function Test-ViiperOwnedUsbipPort([string]$block) {
+ $location = [regex]::Match(
+ $block, '(?im)^\s*->\s+(?usbip://\S+)\s*$')
+ if (-not $location.Success) { return $false }
+
+ $uri = $null
+ if (-not [Uri]::TryCreate($location.Groups['uri'].Value,
+ [UriKind]::Absolute, [ref]$uri)) {
+ return $false
+ }
+ $hostIsLoopback = $uri.Host -ieq "localhost"
+ $address = $null
+ if ([Net.IPAddress]::TryParse($uri.Host.Trim('[', ']'),
+ [ref]$address)) {
+ $hostIsLoopback = [Net.IPAddress]::IsLoopback($address)
+ }
+ $remoteBusId = $uri.AbsolutePath.Trim('/')
+ if ($uri.Scheme -ine "usbip" -or $uri.Port -ne 3241 -or
+ -not $hostIsLoopback -or
+ $remoteBusId -notmatch '^\d+-\d+$') {
+ return $false
+ }
+
+ $serialLine = [regex]::Match(
+ $block, '(?im)^\s*->\s+serial\b(?.*)$')
+ if (-not $serialLine.Success -or
+ [string]::IsNullOrWhiteSpace(
+ $serialLine.Groups['value'].Value.Trim(" '"))) {
+ # usbip-win2 0.9.7.7 has no attach-time owner serial. The exact
+ # loopback VIIPER endpoint and bus/device tuple are its identity.
+ return $true
+ }
+
+ $serial = [regex]::Match(
+ $serialLine.Groups['value'].Value,
+ "^\s*'(?[^']+)'\s*$")
+ return $serial.Success -and
+ $serial.Groups['serial'].Value -cmatch '^DS4W[0-9A-Fa-f]{11}$'
+ }
+
+ function Disconnect-UsbipImports([string]$usbipPath) {
+ if (-not $usbipPath -or
+ -not (Test-Path -LiteralPath $usbipPath -PathType Leaf)) {
+ return
+ }
+
+ $previousErrorActionPreference = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ $portOutput = @(& $usbipPath port 2>&1)
+ $portExitCode = $LASTEXITCODE
+ }
+ finally { $ErrorActionPreference = $previousErrorActionPreference }
+ $portText = ($portOutput | ForEach-Object { [string]$_ }) -join `
+ [Environment]::NewLine
+ if ($portExitCode -ne 0) {
+ $detail = if ($portText) { $portText.Trim() }
+ else { "no diagnostic output" }
+ throw "Cannot safely inspect USBIP imports " +
+ "(exit=$portExitCode): $detail. No driver transition was started."
+ }
+
+ $blocks = @(Get-UsbipPortBlocks $portText)
+ $owned = @($blocks | Where-Object {
+ Test-ViiperOwnedUsbipPort $_.Text
+ })
+ $foreign = @($blocks | Where-Object {
+ -not (Test-ViiperOwnedUsbipPort $_.Text)
+ })
+ if ($foreign.Count -gt 0) {
+ $ports = ($foreign | ForEach-Object { $_.Port }) -join ", "
+ throw "USBIP port(s) $ports are not exact local VIIPER " +
+ "imports. Close their owning application or detach them " +
+ "manually; setup changed no imports."
+ }
+
+ foreach ($block in $owned) {
+ $port = $block.Port
+ Write-Host "Detaching exact local VIIPER import on port $port..." `
+ -ForegroundColor Yellow
+ $previousErrorActionPreference = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ $detachOutput = @(& $usbipPath detach -p $port 2>&1)
+ $detachExitCode = $LASTEXITCODE
+ }
+ finally { $ErrorActionPreference = $previousErrorActionPreference }
+ if ($detachExitCode -ne 0) {
+ $detail = ($detachOutput | ForEach-Object {
+ [string]$_
+ }) -join [Environment]::NewLine
+ throw "Could not detach VIIPER USBIP port $port " +
+ "(exit=$detachExitCode): $detail"
+ }
+ }
+ }
+
+ function Remove-MismatchedUsbipPackage($entry,
+ [Version]$installedVersion, [Version]$targetVersion) {
+ if (-not $installedVersion -or $installedVersion -eq $targetVersion) {
+ return $false
+ }
+ if (-not $entry) {
+ throw "USBIP $installedVersion is unsupported, but no exact " +
+ "uninstall record exists. Refusing to overlay " +
+ "$targetVersion. Remove the package manually, reboot, then " +
+ "run setup again."
+ }
+
+ $entryVersion = Parse-VersionOrNull ($entry.DisplayVersion -as [string])
+ if (-not $entryVersion -or $entryVersion -ne $installedVersion) {
+ throw "The selected USBIP uninstall record does not exactly " +
+ "match installed version $installedVersion."
+ }
+
+ $uninstallCommand = $entry.QuietUninstallString -as [string]
+ if (-not $uninstallCommand) {
+ $uninstallCommand = $entry.UninstallString -as [string]
+ }
+ if (-not $uninstallCommand) {
+ throw "USBIP $installedVersion must be replaced with " +
+ "$targetVersion, but its uninstall command is unavailable."
+ }
+
+ $match = [regex]::Match($uninstallCommand,
+ '^\s*(?:"(?[^"]+)"|(?\S+))(?:\s+(?.*))?$')
+ if (-not $match.Success) {
+ throw "Could not parse the installed USBIP uninstall command."
+ }
+ $uninstaller = $match.Groups['exe'].Value
+ if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) {
+ throw "The installed USBIP uninstaller is missing: $uninstaller"
+ }
+
+ $canonicalUsbipDir = Split-Path -Parent $canonicalUsbipPath
+ $uninstallerDir = Split-Path -Parent (
+ (Resolve-Path -LiteralPath $uninstaller).Path)
+ if (-not [string]::Equals(
+ [IO.Path]::GetFullPath($uninstallerDir).TrimEnd('\'),
+ [IO.Path]::GetFullPath($canonicalUsbipDir).TrimEnd('\'),
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "The exact-version USBIP uninstall record points outside " +
+ "the canonical install directory: $uninstaller"
+ }
+
+ Write-Host (
+ "USBIP replacement phase 1 of 2: removing unsupported " +
+ "$installedVersion. Version $targetVersion will be installed " +
+ "only after you restart Windows and run setup again."
+ ) -ForegroundColor Yellow
+ Set-UsbipReplacementBoundary $installedVersion $targetVersion
+ try {
+ $uninstall = Start-Process -FilePath $uninstaller `
+ -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /RESTARTEXITCODE=3010" `
+ -Verb RunAs -PassThru
+ }
+ catch {
+ Remove-Item -LiteralPath $usbipReplacementStatePath -Force `
+ -ErrorAction SilentlyContinue
+ throw
+ }
+ if (-not $uninstall.WaitForExit(30000)) {
+ Write-Host (
+ "The USBIP uninstaller is still finishing in the background. " +
+ "It was intentionally left running. Save your work, restart " +
+ "Windows yourself, then run setup again."
+ ) -ForegroundColor Yellow
+ return $true
+ }
+ $uninstall.Refresh()
+ if ($uninstall.ExitCode -notin @(0, 1641, 3010)) {
+ throw "USBIP uninstall returned code $($uninstall.ExitCode). " +
+ "Its replacement marker was retained because the driver may " +
+ "be partially removed. Restart Windows before attempting " +
+ "any repair."
+ }
+
+ Write-Host "USBIP removal finished. Restart Windows yourself, then run setup again for phase 2." `
+ -ForegroundColor Yellow
+ return $true
+ }
+
+ function Disable-ViiperStartup {
+ $task = Get-ScheduledTask -TaskName "RunVIIPER" `
+ -ErrorAction SilentlyContinue
+ if ($task) {
+ try {
+ Unregister-ScheduledTask -TaskName "RunVIIPER" `
+ -Confirm:$false -ErrorAction Stop
+ }
+ catch {
+ $delete = Start-Process -FilePath "schtasks.exe" `
+ -ArgumentList '/Delete /TN "RunVIIPER" /F' `
+ -Verb RunAs -PassThru -Wait -WindowStyle Hidden
+ if ($delete.ExitCode -ne 0) {
+ throw "Could not disable the existing RunVIIPER task " +
+ "before the USBIP driver transition."
+ }
+ }
+ }
+ try {
+ Remove-ItemProperty `
+ -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
+ -Name "VIIPER" -ErrorAction Stop
+ }
+ catch [System.Management.Automation.PSArgumentException] { }
+ catch [System.Management.Automation.ItemNotFoundException] { }
+
+ if (Get-ScheduledTask -TaskName "RunVIIPER" `
+ -ErrorAction SilentlyContinue) {
+ throw "RunVIIPER startup remains enabled. No USBIP driver " +
+ "transition was started."
+ }
+ $runValue = Get-ItemPropertyValue `
+ -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
+ -Name "VIIPER" -ErrorAction SilentlyContinue
+ if ($null -ne $runValue) {
+ throw "The VIIPER Run entry remains enabled. No USBIP driver " +
+ "transition was started."
+ }
+ Write-Host "VIIPER startup is disabled until USBIP passes its ABI check." `
+ -ForegroundColor Green
+ }
+
+ function Invoke-ViiperInstallRegistration([string]$path) {
+ # `viiper install` launches the persistent server before the brief
+ # registration process exits. Start-Process -Wait follows descendants
+ # and hangs on that server, so wait only on the registration PID.
+ $registration = Start-Process -WindowStyle Hidden $path `
+ -ArgumentList "install" -PassThru
+ if (-not $registration.WaitForExit(15000)) {
+ try {
+ & taskkill.exe /PID $registration.Id /T /F | Out-Null
+ }
+ catch { }
+ throw "VIIPER registration did not finish within 15 seconds. Its process tree was stopped safely; run setup again."
+ }
+
+ $registration.Refresh()
+ if ($registration.ExitCode -ne 0) {
+ throw "VIIPER registration failed with exit code $($registration.ExitCode)."
+ }
+
+ if (Get-ScheduledTask -TaskName "RunVIIPER" `
+ -ErrorAction SilentlyContinue) {
+ throw "RunVIIPER was recreated alongside the VIIPER Run entry. Refusing duplicate startup ownership."
+ }
+ $runValue = Get-ItemPropertyValue `
+ -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
+ -Name "VIIPER" -ErrorAction SilentlyContinue
+ $expectedPrefix = '"' + [IO.Path]::GetFullPath($path) + '" server '
+ if ([string]::IsNullOrWhiteSpace($runValue) -or
+ -not ([string]$runValue).StartsWith($expectedPrefix,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "VIIPER startup registration does not target the managed binary: '$runValue'"
+ }
+
+ Start-Sleep -Milliseconds 500
+ $managedInstances = @(Get-CimInstance Win32_Process `
+ -Filter "Name='viiper.exe'" -ErrorAction SilentlyContinue |
+ Where-Object {
+ Test-ManagedViiperPath `
+ ($_.ExecutablePath -as [string]) $path
+ })
+ if ($managedInstances.Count -ne 1) {
+ throw "Expected one managed VIIPER server after registration; found $($managedInstances.Count)."
+ }
+ }
$newVersion = Get-ViiperVersion $tempViiper
if (-not $newVersion) { $newVersion = "unknown" }
Write-Host "Downloaded VIIPER version: $newVersion"
- $installDir = Join-Path $env:LOCALAPPDATA "VIIPER"
- $installPath = Join-Path $installDir "viiper.exe"
- $isUpdate = Test-Path $installPath
+ $installPath = Join-Path $installDir "viiper.exe"
+ Remove-ForeignViiperInstallations $installPath
+ Disable-ViiperStartup
+ Stop-ManagedViiperInstances $installPath
+ $isUpdate = Test-Path $installPath
$skipInstall = $false
$oldVersion = "unknown"
if ($isUpdate) {
- Write-Host "Existing VIIPER installation detected. Preserving startup/autostart configuration..."
+ Write-Host "Existing VIIPER installation detected."
$oldVersionRaw = Get-ViiperVersion $installPath
if ($oldVersionRaw) { $oldVersion = $oldVersionRaw }
Write-Host "Installed VIIPER version: $oldVersion"
@@ -104,9 +999,19 @@ try {
$newV = Parse-VersionOrNull $newVersion
$oldV = Parse-VersionOrNull $oldVersion
- if ($newVersion -eq $oldVersion -and $newVersion -ne "unknown") {
- Write-Host "Versions are identical. Skipping VIIPER install step."
- $skipInstall = $true
+ $sameBinary = $false
+ try {
+ $sameBinary = (Get-Item -LiteralPath $tempViiper).Length -eq
+ (Get-Item -LiteralPath $installPath).Length -and
+ (Get-FileHash -LiteralPath $tempViiper -Algorithm SHA256).Hash `
+ -eq (Get-FileHash -LiteralPath $installPath `
+ -Algorithm SHA256).Hash
+ }
+ catch { }
+
+ if ($sameBinary) {
+ Write-Host "Installed VIIPER binary already matches the release asset."
+ $skipInstall = $true
}
elseif ($newV -and $oldV -and $newV -lt $oldV) {
Write-Host "Detected potential downgrade (installed: $oldVersion, new: $newVersion). Skipping install." -ForegroundColor Yellow
@@ -118,115 +1023,266 @@ try {
Write-Host "Installing binary to $installPath..."
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
- if ($isUpdate) {
- $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
- Where-Object { $_.ExecutablePath -eq $installPath }
- if ($procs) {
- Write-Host "Stopping running VIIPER instance(s) so the binary can be updated..."
- foreach ($p in $procs) {
- try {
- Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
- }
- catch { }
- }
- Start-Sleep -Milliseconds 500
- }
- }
-
- Copy-Item $tempViiper $installPath -Force
+ Copy-Item $tempViiper $installPath -Force
}
- Write-Host ""
- Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan
-
- $usbipTargetVersion = [Version]"0.9.7.7"
- $usbipInstalledVersion = $null
-
- $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue |
- Where-Object { $_.DisplayName -like 'USBip version*' } |
- Select-Object -First 1
- if (-not $usbipEntry) {
- $usbipEntry = Get-ItemProperty "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue |
- Where-Object { $_.DisplayName -like 'USBip version*' } |
- Select-Object -First 1
- }
- if ($usbipEntry) {
- try { $usbipInstalledVersion = [Version]$usbipEntry.DisplayVersion } catch { }
- }
-
- if (-not $usbipInstalledVersion) {
- $driverPath = Join-Path $env:SystemRoot "System32\drivers\usbip2_ude.sys"
- if (Test-Path $driverPath) {
- try { $usbipInstalledVersion = [Version](Get-Item $driverPath).VersionInfo.FileVersion } catch { }
- }
- }
-
- $needsReboot = $false
- $needsUsbipInstall = $true
-
- if ($usbipInstalledVersion) {
- if ($usbipInstalledVersion -ge $usbipTargetVersion) {
- Write-Host "USBIP drivers already up to date (installed: $usbipInstalledVersion)" -ForegroundColor Green
- $needsUsbipInstall = $false
- }
- else {
- Write-Host "USBIP drivers outdated (installed: $usbipInstalledVersion, required: $usbipTargetVersion). Updating..." -ForegroundColor Yellow
- }
- }
- else {
- Write-Host "USBIP drivers not found. Installing..." -ForegroundColor Yellow
- }
-
- if ($needsUsbipInstall) {
- Write-Host "This requires administrator privileges." -ForegroundColor Yellow
-
- $usbipInstallerUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.7/USBip-0.9.7.7-x64.exe"
- $usbipInstaller = Join-Path $tempDir "USBip-setup.exe"
-
- try {
- Write-Host " Downloading usbip-win2 installer..." -ForegroundColor Cyan
- Invoke-WebRequest -Uri $usbipInstallerUrl -OutFile $usbipInstaller -ErrorAction Stop
- Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow
- Start-Process -FilePath $usbipInstaller -ArgumentList "/S" -Verb RunAs -Wait
- Write-Host "USBIP drivers installed/updated successfully" -ForegroundColor Green
- $needsReboot = $true
- }
- catch {
- Write-Host "Warning: Failed to install USBIP drivers - $($_.Exception.Message)" -ForegroundColor Yellow
- Write-Host "You may need to install usbip-win2 manually from:" -ForegroundColor Yellow
- Write-Host " https://github.com/vadimgrn/usbip-win2/releases" -ForegroundColor Yellow
- }
- }
+ Write-Host ""
+ Write-Host "[2/4] VIIPER binary installed." -ForegroundColor Green
+ Write-Host "[3/4] Checking USBIP drivers..." -ForegroundColor Cyan
+
+ Resolve-UsbipReplacementBoundary
+
+ $canonicalUsbipPresent = Test-Path -LiteralPath $canonicalUsbipPath `
+ -PathType Leaf
+ $driverPath = Join-Path $env:SystemRoot `
+ "System32\drivers\usbip2_ude.sys"
+ $usbipDriverPresent = Test-Path -LiteralPath $driverPath -PathType Leaf
+ $usbipInstalledVersion = $null
+
+ if ($canonicalUsbipPresent) {
+ $usbipInstalledVersion = Parse-VersionOrNull (
+ (Get-Item -LiteralPath $canonicalUsbipPath).
+ VersionInfo.ProductVersion)
+ }
+ if (-not $usbipInstalledVersion -and $usbipDriverPresent) {
+ $usbipInstalledVersion = Parse-VersionOrNull (
+ (Get-Item -LiteralPath $driverPath).VersionInfo.FileVersion)
+ }
+
+ $usbipRecords = @(Get-UsbipUninstallRecords)
+ if ($usbipRecords.Count -gt 1) {
+ throw "Multiple usbip-win2 uninstall records exist. Refusing an " +
+ "ambiguous driver transition; remove stale metadata manually."
+ }
+ $usbipEntry = $usbipRecords | Select-Object -First 1
+ $metadataVersion = $null
+ if ($usbipEntry) {
+ $displayName = $usbipEntry.DisplayName -as [string]
+ $publisher = $usbipEntry.Publisher -as [string]
+ $installLocation = $usbipEntry.InstallLocation -as [string]
+ $metadataVersion = Parse-VersionOrNull (
+ $usbipEntry.DisplayVersion -as [string])
+ if (-not $metadataVersion -or
+ -not [string]::Equals($publisher, "usbip-win2",
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not [string]::Equals($displayName,
+ "USBip version $metadataVersion",
+ [StringComparison]::OrdinalIgnoreCase) -or
+ [string]::IsNullOrWhiteSpace($installLocation) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($installLocation).TrimEnd('\', '/'),
+ [IO.Path]::GetFullPath(
+ (Split-Path -Parent $canonicalUsbipPath)).TrimEnd('\', '/'),
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "USBIP uninstall metadata is unknown or malformed. No " +
+ "driver transition was started."
+ }
+ }
+ if (-not $usbipInstalledVersion -and $metadataVersion) {
+ $usbipInstalledVersion = $metadataVersion
+ }
+ if (($canonicalUsbipPresent -or $usbipDriverPresent -or $usbipEntry) -and
+ -not $usbipInstalledVersion) {
+ throw "USBIP files or metadata exist, but their installed version " +
+ "cannot be read. Refusing an unknown driver ABI."
+ }
+ if ($metadataVersion -and $usbipInstalledVersion -and
+ $metadataVersion -ne $usbipInstalledVersion) {
+ throw "USBIP uninstall metadata version $metadataVersion does not " +
+ "match installed runtime version $usbipInstalledVersion."
+ }
+
+ $needsReboot = $false
+ $driverReplacementPhaseOne = $false
+ $usbipRuntimeBefore = Test-UsbipRuntime $usbipTargetVersion
+ $sameVersionProbeFailure = -not $usbipRuntimeBefore.Healthy -and
+ $usbipInstalledVersion -eq $usbipTargetVersion
+ $needsUsbipInstall = -not $usbipRuntimeBefore.Healthy -and
+ -not $sameVersionProbeFailure
+ if ($sameVersionProbeFailure) {
+ $needsReboot = $true
+ Disable-ViiperStartup
+ Stop-ControllerBackends
+ }
+
+ if (-not $needsUsbipInstall) {
+ if ($usbipRuntimeBefore.Healthy) {
+ Write-Host (
+ "USBIP 0.9.7.7 userspace/driver ABI is ready at the canonical " +
+ "Program Files path."
+ ) -ForegroundColor Green
+ }
+ else {
+ Write-Host (
+ "USBIP 0.9.7.7 is installed, but its live ABI probe failed: " +
+ "$($usbipRuntimeBefore.Detail). Setup will not overlay a " +
+ "running kernel driver. Restart Windows, then run setup " +
+ "again. If the probe still fails, uninstall 0.9.7.7, " +
+ "restart, and rerun setup."
+ ) -ForegroundColor Yellow
+ }
+ }
+ elseif ($usbipInstalledVersion -and
+ $usbipInstalledVersion -lt $usbipTargetVersion) {
+ Write-Host (
+ "USBIP drivers outdated (installed: $usbipInstalledVersion, " +
+ "required: $usbipTargetVersion). Updating..."
+ ) -ForegroundColor Yellow
+ }
+ elseif ($usbipInstalledVersion) {
+ Write-Host (
+ "USBIP reports version $usbipInstalledVersion, but its canonical " +
+ "0.9.7.7 runtime is not usable ($($usbipRuntimeBefore.Detail)). " +
+ "Repairing the exact 0.9.7.7 package..."
+ ) -ForegroundColor Yellow
+ }
+ else {
+ Write-Host (
+ "USBIP 0.9.7.7 is missing or unusable " +
+ "($($usbipRuntimeBefore.Detail)). Installing the exact package..."
+ ) -ForegroundColor Yellow
+ }
- if (-not $isUpdate) {
- Write-Host "Configuring system startup..."
- }
- Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "install"
+ if ($needsUsbipInstall) {
+ Write-Host "This requires administrator privileges." -ForegroundColor Yellow
+
+ Disable-ViiperStartup
+ Assert-PadSenseNotRunning
+ Stop-ControllerBackends
+
+ try {
+ if (($canonicalUsbipPresent -or $usbipDriverPresent -or
+ $usbipEntry) -and -not $canonicalUsbipPresent) {
+ throw "An existing USBIP installation has no canonical " +
+ "usbip.exe, so active imports cannot be inspected safely."
+ }
+ Assert-PadSenseNotRunning
+ Disconnect-UsbipImports (Get-CanonicalUsbipPath)
+
+ if ($usbipInstalledVersion -and
+ $usbipInstalledVersion -ne $usbipTargetVersion) {
+ $usbipEntry = Get-UsbipUninstallEntry `
+ $usbipInstalledVersion
+ }
+ $removedMismatchedPackage = Remove-MismatchedUsbipPackage `
+ $usbipEntry $usbipInstalledVersion $usbipTargetVersion
+
+ if ($removedMismatchedPackage) {
+ $driverReplacementPhaseOne = $true
+ $needsReboot = $true
+ Write-Host (
+ "USBIP replacement phase 1 of 2 is complete. " +
+ "$usbipTargetVersion was intentionally not installed " +
+ "in this Windows session."
+ ) -ForegroundColor Yellow
+ }
+ else {
+ $usbipInstallerUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.7/USBip-0.9.7.7-x64.exe"
+ $usbipInstaller = Join-Path $tempDir "USBip-setup.exe"
+ Write-Host " Downloading exact usbip-win2 0.9.7.7..." `
+ -ForegroundColor Cyan
+ Invoke-WebRequest -Uri $usbipInstallerUrl `
+ -OutFile $usbipInstaller -ErrorAction Stop
+ $usbipInstallerHash = (Get-FileHash `
+ -LiteralPath $usbipInstaller -Algorithm SHA256).
+ Hash.ToLowerInvariant()
+ if ($usbipInstallerHash -ne
+ "51620fa5f9f8be5932bc9d786deee557ce06d5407a99cab490dcfac71f185fea") {
+ throw "USBIP installer integrity check failed."
+ }
+
+ Write-Host "Installing USBIP drivers (UAC prompt will appear)..." `
+ -ForegroundColor Yellow
+ $installerProcess = Start-Process `
+ -FilePath $usbipInstaller `
+ -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /RESTARTEXITCODE=3010" `
+ -Verb RunAs -PassThru
+ if (-not $installerProcess.WaitForExit(120000)) {
+ $needsReboot = $true
+ Write-Host (
+ "USBIP setup is still finishing in the background. " +
+ "It was left running; restart Windows yourself " +
+ "before starting VIIPER."
+ ) -ForegroundColor Yellow
+ }
+ else {
+ $installerProcess.Refresh()
+ if ($installerProcess.ExitCode -notin @(0, 1641, 3010)) {
+ throw "USBIP installer exited with code " +
+ "$($installerProcess.ExitCode)."
+ }
+ Write-Host "USBIP drivers installed successfully." `
+ -ForegroundColor Green
+ $needsReboot = $installerProcess.ExitCode -in `
+ @(1641, 3010)
+ }
+ }
+ }
+ catch {
+ throw "Failed to install USBIP drivers: $($_.Exception.Message)"
+ }
+ }
+
+ Write-Host "[4/4] Verifying runtime readiness..." -ForegroundColor Cyan
+ $usbipRuntime = if ($driverReplacementPhaseOne -or $needsReboot) {
+ [pscustomobject]@{
+ Healthy = $false
+ Reason = "reboot-required"
+ Detail = "a user-controlled reboot is required"
+ }
+ }
+ else {
+ Test-UsbipRuntime $usbipTargetVersion
+ }
+ if (-not $usbipRuntime.Healthy -and -not $needsReboot) {
+ $needsReboot = $true
+ Write-Host "USBIP 0.9.7.7 is installed but not active yet: $($usbipRuntime.Detail)" -ForegroundColor Yellow
+ }
+
+ if ($usbipRuntime.Healthy -and -not $needsReboot) {
+ if (-not $isUpdate) {
+ Write-Host "Configuring system startup..."
+ }
+ Invoke-ViiperInstallRegistration $installPath
+ }
- Write-Host "VIIPER installed successfully!" -ForegroundColor Green
- Write-Host "Binary installed to: $installPath"
+ if ($usbipRuntime.Healthy -and -not $needsReboot) {
+ Write-Host "VIIPER installed successfully and is ready." `
+ -ForegroundColor Green
+ }
+ else {
+ Write-Host "VIIPER binary installed; setup is not Ready yet." `
+ -ForegroundColor Yellow
+ }
+ Write-Host "Binary installed to: $installPath"
if ($isUpdate) {
if ($skipInstall) {
Write-Host "Binary already at correct version or newer. Skipping binary copy."
}
else {
- Write-Host "Update complete. Startup/autostart configuration was left unchanged."
+ Write-Host "VIIPER binary update complete."
}
- Write-Host "VIIPER service has been restarted."
+ if (-not $needsReboot) {
+ Write-Host "VIIPER service has been restarted."
+ }
}
else {
- Write-Host "VIIPER server is now running and will start automatically on boot."
- }
-
- taskkill.exe /IM "viiper.exe" /F > $null 2>&1
- Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "server"
-
- if ($needsReboot) {
- Write-Host ""
- Write-Host "IMPORTANT: A system reboot is required for USBIP drivers to function properly." -ForegroundColor Yellow
- Write-Host "Please restart your computer before using VIIPER." -ForegroundColor Yellow
- }
-}
-finally {
- Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
-}
+ if (-not $needsReboot) {
+ Write-Host "VIIPER server is now running and will start automatically on boot."
+ }
+ }
+
+ if ($needsReboot) {
+ Write-Host ""
+ Write-Host "USBIP 0.9.7.7 requires a restart before VIIPER can run safely." -ForegroundColor Yellow
+ Write-Host "VIIPER startup remains disabled. Restart Windows yourself, then run this setup again." -ForegroundColor Yellow
+ }
+ # `viiper install` above starts the single verified server instance.
+}
+finally {
+ Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
+ if ($setupMutexAcquired) {
+ try { $setupMutex.ReleaseMutex() } catch { }
+ }
+ $setupMutex.Dispose()
+}
diff --git a/scripts/install.sh b/scripts/install.sh
index e96b418c..db44f532 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -4,7 +4,7 @@ set -e
VIIPER_VERSION="dev-snapshot"
-REPO="Alia5/VIIPER"
+REPO="hbashton/VIIPER"
API_URL="https://api.github.com/repos/${REPO}/releases/tags/${VIIPER_VERSION}"
echo "Fetching VIIPER release: $VIIPER_VERSION..."
@@ -174,7 +174,7 @@ else
;;
*)
echo "Warning: Could not detect package manager. Please install USBIP manually."
- echo "See: https://alia5.github.io/VIIPER/stable/getting-started/installation/"
+ echo "See: https://github.com/hbashton/VIIPER#installation"
;;
esac
fi
diff --git a/viipertypes/structs.go b/viipertypes/structs.go
index d6decafd..c3b5dc71 100644
--- a/viipertypes/structs.go
+++ b/viipertypes/structs.go
@@ -50,12 +50,14 @@ type BusRemoveResponse struct {
}
type Device struct {
- BusID uint32 `json:"busId"`
- DevID string `json:"devId"`
- Vid string `json:"vid"`
- Pid string `json:"pid"`
- Type string `json:"type"`
- DeviceSpecific map[string]any `json:"deviceSpecific"`
+ BusID uint32 `json:"busId"`
+ DevID string `json:"devId"`
+ Vid string `json:"vid"`
+ Pid string `json:"pid"`
+ Type string `json:"type"`
+ DeviceSpecific map[string]any `json:"deviceSpecific"`
+ USBIPPort int32 `json:"usbipPort,omitempty"`
+ USBIPOwnerSerial string `json:"usbipOwnerSerial,omitempty"`
}
type DevicesListResponse struct {
@@ -74,32 +76,6 @@ type DeviceCreateRequest struct {
DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"`
}
-type DualSenseTrafficSetRequest struct {
- Enabled bool `json:"enabled"`
- Clear bool `json:"clear"`
-}
-
-type DualSenseTrafficEvent struct {
- TimeUTC string `json:"timeUtc"`
- Direction string `json:"direction"`
- Source string `json:"source"`
- ReportType string `json:"reportType,omitempty"`
- ReportID string `json:"reportId,omitempty"`
- Request string `json:"request,omitempty"`
- Value string `json:"value,omitempty"`
- Index string `json:"index,omitempty"`
- Length int `json:"length"`
- Hex string `json:"hex,omitempty"`
- Summary string `json:"summary,omitempty"`
- DecodedOutput string `json:"decodedOutput,omitempty"`
-}
-
-type DualSenseTrafficResponse struct {
- Enabled bool `json:"enabled"`
- Count int `json:"count"`
- Events []DualSenseTrafficEvent `json:"events,omitempty"`
-}
-
// UnmarshalJSON implements custom unmarshaling to accept both uint16 and hex string formats
// for idVendor and idProduct (e.g., "0x12ac" or 4780).
func (d *DeviceCreateRequest) UnmarshalJSON(data []byte) error {