From bc6138b664ea5349ccc84c9213b3bf788a313ecc Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 30 Jul 2026 17:45:33 -0500 Subject: [PATCH 1/5] Release strict PadSense V5 transport Remove obsolete DualSense generations, split writers and traffic-debug APIs so DualSense and Edge expose only the validated atomic V5 contract. Add exact usbip-win2 0.9.7.8 ownership support, resilient installer behavior, hbashton update sources, and release-ready 0.0.6 metadata through the tagged build. --- .github/workflows/release.yml | 6 +- cmd/viiper/meta.go | 2 +- cmd/viiper/meta_test.go | 16 + device/device_attach_test.go | 17 + device/dualsense/audio_control_test.go | 16 +- device/dualsense/bthaptics.go | 38 +- device/dualsense/bthaptics_test.go | 47 -- device/dualsense/const.go | 40 +- device/dualsense/device.go | 316 +-------- device/dualsense/device_output_test.go | 143 +--- device/dualsense/diagnostics.go | 179 ----- device/dualsense/ds_handler.go | 363 ++-------- device/dualsense/ds_handler_test.go | 591 +++------------- device/dualsense/dsedge_handler.go | 135 +--- device/dualsense/native_audio_v5.go | 16 +- device/dualsense/native_audio_v5_test.go | 10 +- device/dualsense/output_writer.go | 111 +-- device/dualsense/output_writer_test.go | 646 ++++-------------- device/dualsense/state.go | 82 +-- docs/devices/dualsense.md | 362 +++------- docs/research/dualsense-bluetooth-haptics.md | 260 ------- .../dualsense-bluetooth-microphone.md | 119 ---- examples/go/virtual_ds_and_edge_cli/main.go | 122 +++- internal/cmd/server.go | 3 - internal/codegen/common/readme.go | 2 +- internal/codegen/generator/rust/project.go | 2 +- internal/server/api/autoattach.go | 15 + internal/server/api/autoattach_linux.go | 6 +- internal/server/api/autoattach_windows.go | 125 ++-- .../server/api/autoattach_windows_test.go | 62 ++ internal/server/api/device_stream_handler.go | 5 + internal/server/api/handler/bus_device_add.go | 19 +- .../handler/bus_device_add_internal_test.go | 68 ++ .../server/api/handler/bus_devices_list.go | 5 + .../server/api/handler/dualsense_traffic.go | 91 --- internal/server/api/stream_reconnect_test.go | 4 +- internal/updater/updater.go | 30 +- internal/updater/updater_test.go | 34 + mkdocs.yml | 4 +- scripts/install.ps1 | 302 ++++++-- scripts/install.sh | 4 +- viipertypes/structs.go | 40 +- 42 files changed, 1220 insertions(+), 3238 deletions(-) create mode 100644 cmd/viiper/meta_test.go delete mode 100644 device/dualsense/diagnostics.go delete mode 100644 docs/research/dualsense-bluetooth-haptics.md delete mode 100644 docs/research/dualsense-bluetooth-microphone.md create mode 100644 internal/server/api/autoattach_windows_test.go create mode 100644 internal/server/api/handler/bus_device_add_internal_test.go delete mode 100644 internal/server/api/handler/dualsense_traffic.go create mode 100644 internal/updater/updater_test.go 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/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/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/server.go b/internal/cmd/server.go index 0d445f19..4be3ff5c 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -102,9 +102,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/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..e90980d9 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -5,6 +5,7 @@ package api import ( "context" "fmt" + "hash/fnv" "log/slog" "os" "os/exec" @@ -63,6 +64,9 @@ type attachIOCTL struct { BusID [32]byte Service [niMaxServ]byte Host [niMaxHost]byte + // usbip-win2 0.9.7.8 added SERIAL_BUFSZ to plugin_hardware. + // An empty serial preserves the descriptor supplied by VIIPER. + Serial [16]byte } const ( @@ -73,54 +77,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 0.9.7.8 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 +128,73 @@ 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") + ownerSerial := buildDS4WindowsOwnerSerial(deviceExportMeta, usbipServerPort) + copy(ioctlData.Serial[:], ownerSerial) + 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.8 native attach failed (repair or reboot USBIP; no legacy 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, + USBIPOwnerSerial: ownerSerial, + }, nil +} - return nil +func buildDS4WindowsOwnerSerial(deviceExportMeta *usbip.ExportMeta, + usbipServerPort uint16) string { + // usbip-win2 0.9.7.8 exposes at most 15 ASCII-alphanumeric bytes. A stable + // fork-owned prefix lets DS4Windows clean only its imports instead of + // racing PadSense or another localhost USBIP controller manager. + hash := fnv.New64a() + _, _ = fmt.Fprintf(hash, "%d:%d:%d", deviceExportMeta.BusID, + deviceExportMeta.DevID, usbipServerPort) + return fmt.Sprintf("DS4W%011X", hash.Sum64()&0xFFFFFFFFFFF) } -func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error { +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) (AutoAttachResult, error) { logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID) cmd := exec.CommandContext( @@ -186,21 +212,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 0.9.7.7 userspace with a 0.9.7.8 + // driver (or vice versa). seen := make(map[string]struct{}) for _, root := range []string{ os.Getenv("ProgramW6432"), @@ -223,6 +246,10 @@ func resolveUsbipExecutable() string { } } + if path, err := exec.LookPath("usbip"); err == nil { + return path + } + return "usbip" } diff --git a/internal/server/api/autoattach_windows_test.go b/internal/server/api/autoattach_windows_test.go new file mode 100644 index 00000000..054c79a2 --- /dev/null +++ b/internal/server/api/autoattach_windows_test.go @@ -0,0 +1,62 @@ +//go:build windows + +package api + +import ( + "strings" + "testing" + "unsafe" + + "github.com/Alia5/VIIPER/usbip" + "github.com/stretchr/testify/require" +) + +func TestUsbipAttachABISizes(t *testing.T) { + require.Equal(t, uintptr(1116), unsafe.Sizeof(attachIOCTL{}), + "usbip-win2 0.9.7.8 plugin_hardware ABI changed") +} + +func TestDS4WindowsOwnerSerialFitsUsbip0978Contract(t *testing.T) { + first := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ + BusID: 17, + DevID: 3, + }, 3241) + same := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ + BusID: 17, + DevID: 3, + }, 3241) + different := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ + BusID: 17, + DevID: 4, + }, 3241) + + require.Len(t, first, 15) + require.True(t, strings.HasPrefix(first, "DS4W")) + require.Equal(t, first, same) + require.NotEqual(t, first, different) + for _, character := range first { + require.True(t, + character >= '0' && character <= '9' || + character >= 'A' && character <= 'Z') + } +} + +func TestNativeAutoAttachResultCarriesExactPortAndOwnerSerial(t *testing.T) { + meta := &usbip.ExportMeta{BusID: 17, DevID: 3} + ownerSerial := buildDS4WindowsOwnerSerial(meta, 3241) + + got := AutoAttachResult{ + USBIPPort: 7, + USBIPOwnerSerial: ownerSerial, + } + + require.Equal(t, int32(7), got.USBIPPort) + require.Equal(t, ownerSerial, got.USBIPOwnerSerial) + require.Len(t, got.USBIPOwnerSerial, 15) +} + +func TestUsbip0978SerialFieldIsAppended(t *testing.T) { + var current attachIOCTL + require.Equal(t, uintptr(1097), unsafe.Offsetof(current.Serial), + "the 0.9.7.8 serial field must follow the complete 0.9.7.7 ABI") +} 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/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..32991f56 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -61,7 +61,7 @@ try { return $null } - function Parse-VersionOrNull($ver) { + function Parse-VersionOrNull($ver) { if (-not $ver) { return $null } $clean = $ver.Trim().TrimStart('v', 'V') $clean = $clean.Split('-')[0] @@ -84,6 +84,153 @@ 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 Get-CanonicalUsbipPath { + foreach ($root in @($env:ProgramW6432, $env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if (-not $root) { continue } + $candidate = Join-Path $root "USBip\usbip.exe" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + 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 -lt $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.*(?:input|structure)") { + 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.8 ABI probe passed" + } + } + + 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)." + } + } $newVersion = Get-ViiperVersion $tempViiper if (-not $newVersion) { $newVersion = "unknown" } @@ -139,7 +286,7 @@ try { Write-Host "" Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan - $usbipTargetVersion = [Version]"0.9.7.7" + $usbipTargetVersion = [Version]"0.9.7.8" $usbipInstalledVersion = $null $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | @@ -159,49 +306,93 @@ try { 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 - } + } + + $needsReboot = $false + $usbipRuntimeBefore = Test-UsbipRuntime $usbipTargetVersion + $needsUsbipInstall = -not $usbipRuntimeBefore.Healthy -and + $usbipRuntimeBefore.Reason -ne "abi-mismatch" + if ($usbipRuntimeBefore.Reason -eq "abi-mismatch") { + $needsReboot = $true + } + + if (-not $needsUsbipInstall) { + if ($usbipRuntimeBefore.Healthy) { + Write-Host ( + "USBIP 0.9.7.8 userspace/driver ABI is ready at the canonical " + + "Program Files path." + ) -ForegroundColor Green + } + else { + Write-Host ( + "USBIP 0.9.7.8 is already installed. Skipping a redundant " + + "reinstall; restart Windows to load its matching driver." + ) -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.8 runtime is not usable ($($usbipRuntimeBefore.Detail)). " + + "Repairing the exact 0.9.7.8 package..." + ) -ForegroundColor Yellow + } + else { + Write-Host ( + "USBIP 0.9.7.8 is missing or unusable " + + "($($usbipRuntimeBefore.Detail)). Installing the exact package..." + ) -ForegroundColor Yellow + } - if ($needsUsbipInstall) { - Write-Host "This requires administrator privileges." -ForegroundColor Yellow + if ($needsUsbipInstall) { + Write-Host "This requires administrator privileges." -ForegroundColor Yellow + + Assert-PadSenseNotRunning + Stop-ControllerBackends - $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" + $usbipInstallerUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.8/USBip-0.9.7.8-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 - } - } - - if (-not $isUpdate) { - Write-Host "Configuring system startup..." - } - Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "install" + Write-Host " Downloading usbip-win2 installer..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $usbipInstallerUrl -OutFile $usbipInstaller -ErrorAction Stop + $usbipInstallerHash = (Get-FileHash -LiteralPath $usbipInstaller -Algorithm SHA256).Hash.ToLowerInvariant() + if ($usbipInstallerHash -ne "44451fe06f4186125c2a5ecd25b099c5560a61a60b1e56f5a0758e77a60afa44") { + throw "USBIP installer integrity check failed." + } + Assert-PadSenseNotRunning + Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow + $installerProcess = Start-Process -FilePath $usbipInstaller -ArgumentList "/S" -Verb RunAs -Wait -PassThru + if ($installerProcess.ExitCode -notin @(0, 1641, 3010)) { + throw "USBIP installer exited with code $($installerProcess.ExitCode)." + } + Write-Host "USBIP drivers installed/updated successfully" -ForegroundColor Green + $needsReboot = $installerProcess.ExitCode -in @(1641, 3010) + } + catch { + throw "Failed to install USBIP drivers: $($_.Exception.Message)" + } + } + + $usbipRuntime = Test-UsbipRuntime $usbipTargetVersion + if (-not $usbipRuntime.Healthy) { + $needsReboot = $true + Write-Host "USBIP 0.9.7.8 is installed but not active yet: $($usbipRuntime.Detail)" -ForegroundColor Yellow + } + + if (-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" @@ -212,20 +403,25 @@ try { else { Write-Host "Update complete. Startup/autostart configuration was left unchanged." } - 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 - } + 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.8 requires a restart before VIIPER can run safely." -ForegroundColor Yellow + Write-Host "VIIPER was intentionally left stopped; restart Windows, then launch DS4Windows." -ForegroundColor Yellow + } + else { + taskkill.exe /IM "viiper.exe" /F > $null 2>&1 + Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "server" + } } finally { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue 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 { From 86e7e4693213cb88c1d633f9e3a53480b7368e2b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 30 Jul 2026 18:24:51 -0500 Subject: [PATCH 2/5] Restore the safe usbip-win2 0.9.7.7 attach ABI 0.9.7.8 reproduced kernel pool corruption on first controller attach. Remove its serial extension, restore the exact 1100-byte 0.9.7.7 IOCTL contract, require the pinned package in setup, detach inactive imports before driver replacement, and time-bound an unloader that cannot quiesce. --- internal/server/api/autoattach_windows.go | 28 +---- .../server/api/autoattach_windows_test.go | 48 +------- scripts/install.ps1 | 108 +++++++++++++++--- 3 files changed, 104 insertions(+), 80 deletions(-) diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index e90980d9..46dd7c02 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -5,7 +5,6 @@ package api import ( "context" "fmt" - "hash/fnv" "log/slog" "os" "os/exec" @@ -64,9 +63,6 @@ type attachIOCTL struct { BusID [32]byte Service [niMaxServ]byte Host [niMaxHost]byte - // usbip-win2 0.9.7.8 added SERIAL_BUFSZ to plugin_hardware. - // An empty serial preserves the descriptor supplied by VIIPER. - Serial [16]byte } const ( @@ -80,7 +76,7 @@ const ( func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) (AutoAttachResult, error) { if useNativeIOCTL { // Never hide a native ABI mismatch behind usbip.exe. A mismatch means - // the 0.9.7.8 userspace and loaded driver are not a valid pair and must + // 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) } @@ -138,12 +134,10 @@ func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbip copy(ioctlData.BusID[:], busID) copy(ioctlData.Service[:], service) copy(ioctlData.Host[:], "localhost") - ownerSerial := buildDS4WindowsOwnerSerial(deviceExportMeta, usbipServerPort) - copy(ioctlData.Serial[:], ownerSerial) port, bytesReturned, err := submitAttachIOCTL(handle, unsafe.Pointer(&ioctlData), ioctlData.Size, &ioctlData.PortOutput) if err != nil { - return AutoAttachResult{}, fmt.Errorf("IOControl: usbip-win2 0.9.7.8 native attach failed (repair or reboot USBIP; no legacy fallback was attempted): %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", port) @@ -158,22 +152,10 @@ func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbip "usbPort", port) return AutoAttachResult{ - USBIPPort: port, - USBIPOwnerSerial: ownerSerial, + USBIPPort: port, }, nil } -func buildDS4WindowsOwnerSerial(deviceExportMeta *usbip.ExportMeta, - usbipServerPort uint16) string { - // usbip-win2 0.9.7.8 exposes at most 15 ASCII-alphanumeric bytes. A stable - // fork-owned prefix lets DS4Windows clean only its imports instead of - // racing PadSense or another localhost USBIP controller manager. - hash := fnv.New64a() - _, _ = fmt.Fprintf(hash, "%d:%d:%d", deviceExportMeta.BusID, - deviceExportMeta.DevID, usbipServerPort) - return fmt.Sprintf("DS4W%011X", hash.Sum64()&0xFFFFFFFFFFF) -} - func submitAttachIOCTL(handle windows.Handle, data unsafe.Pointer, size uint32, portOutput *int32) (int32, uint32, error) { var bytesReturned uint32 @@ -222,8 +204,8 @@ func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, u func resolveUsbipExecutable() string { // The usbip-win2 installer does not consistently add its directory to // PATH for already-running services. Prefer the canonical installation so - // a stale copy elsewhere cannot pair 0.9.7.7 userspace with a 0.9.7.8 - // driver (or vice versa). + // 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"), diff --git a/internal/server/api/autoattach_windows_test.go b/internal/server/api/autoattach_windows_test.go index 054c79a2..4e4dae82 100644 --- a/internal/server/api/autoattach_windows_test.go +++ b/internal/server/api/autoattach_windows_test.go @@ -3,60 +3,22 @@ package api import ( - "strings" "testing" "unsafe" - "github.com/Alia5/VIIPER/usbip" "github.com/stretchr/testify/require" ) func TestUsbipAttachABISizes(t *testing.T) { - require.Equal(t, uintptr(1116), unsafe.Sizeof(attachIOCTL{}), - "usbip-win2 0.9.7.8 plugin_hardware ABI changed") + require.Equal(t, uintptr(1100), unsafe.Sizeof(attachIOCTL{}), + "usbip-win2 0.9.7.7 plugin_hardware ABI changed") } -func TestDS4WindowsOwnerSerialFitsUsbip0978Contract(t *testing.T) { - first := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ - BusID: 17, - DevID: 3, - }, 3241) - same := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ - BusID: 17, - DevID: 3, - }, 3241) - different := buildDS4WindowsOwnerSerial(&usbip.ExportMeta{ - BusID: 17, - DevID: 4, - }, 3241) - - require.Len(t, first, 15) - require.True(t, strings.HasPrefix(first, "DS4W")) - require.Equal(t, first, same) - require.NotEqual(t, first, different) - for _, character := range first { - require.True(t, - character >= '0' && character <= '9' || - character >= 'A' && character <= 'Z') - } -} - -func TestNativeAutoAttachResultCarriesExactPortAndOwnerSerial(t *testing.T) { - meta := &usbip.ExportMeta{BusID: 17, DevID: 3} - ownerSerial := buildDS4WindowsOwnerSerial(meta, 3241) - +func TestNativeAutoAttachResultCarriesExactPort(t *testing.T) { got := AutoAttachResult{ - USBIPPort: 7, - USBIPOwnerSerial: ownerSerial, + USBIPPort: 7, } require.Equal(t, int32(7), got.USBIPPort) - require.Equal(t, ownerSerial, got.USBIPOwnerSerial) - require.Len(t, got.USBIPOwnerSerial, 15) -} - -func TestUsbip0978SerialFieldIsAppended(t *testing.T) { - var current attachIOCTL - require.Equal(t, uintptr(1097), unsafe.Offsetof(current.Serial), - "the 0.9.7.8 serial field must follow the complete 0.9.7.7 ABI") + require.Empty(t, got.USBIPOwnerSerial) } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 32991f56..f57f5a30 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -171,7 +171,7 @@ try { $actualVersion = $null try { $actualVersion = [Version](Get-Item -LiteralPath $usbipPath).VersionInfo.FileVersion } catch { } - if (-not $actualVersion -or $actualVersion -lt $expectedVersion) { + if (-not $actualVersion -or $actualVersion -ne $expectedVersion) { return [pscustomobject]@{ Healthy = $false Reason = "version" @@ -208,10 +208,86 @@ try { return [pscustomobject]@{ Healthy = $true Reason = "ready" - Detail = "0.9.7.8 ABI probe passed" + Detail = "0.9.7.7 ABI probe passed" } } + 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) } + finally { $ErrorActionPreference = $previousErrorActionPreference } + $portText = ($portOutput | ForEach-Object { [string]$_ }) -join ` + [Environment]::NewLine + foreach ($match in [regex]::Matches($portText, + '(?im)^\s*Port\s+(\d+):')) { + $port = [int]$match.Groups[1].Value + Write-Host "Detaching stopped USBIP import on port $port..." ` + -ForegroundColor Yellow + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { & $usbipPath detach -p $port 2>&1 | Out-Null } + finally { $ErrorActionPreference = $previousErrorActionPreference } + } + } + + function Remove-MismatchedUsbipPackage($entry, + [Version]$installedVersion, [Version]$targetVersion) { + if (-not $entry -or -not $installedVersion -or + $installedVersion -eq $targetVersion) { + return $false + } + + $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" + } + + Write-Host ( + "Removing unsupported USBIP $installedVersion before " + + "installing pinned safe $targetVersion..." + ) -ForegroundColor Yellow + $uninstall = Start-Process -FilePath $uninstaller ` + -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" ` + -Verb RunAs -PassThru + if (-not $uninstall.WaitForExit(30000)) { + try { + Start-Process taskkill.exe -Verb RunAs ` + -ArgumentList "/PID $($uninstall.Id) /T /F" ` + -WindowStyle Hidden -Wait | Out-Null + } + catch { } + throw "USBIP could not unload its active kernel driver within " + + "30 seconds. Restart Windows, then run setup again; it will " + + "resume with pinned $targetVersion." + } + $uninstall.Refresh() + if ($uninstall.ExitCode -notin @(0, 1641, 3010)) { + throw "USBIP uninstall failed with code $($uninstall.ExitCode)." + } + + return $true + } + function Invoke-ViiperInstallRegistration([string]$path) { # `viiper install` launches the persistent server before the brief # registration process exits. Start-Process -Wait follows descendants @@ -286,7 +362,7 @@ try { Write-Host "" Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan - $usbipTargetVersion = [Version]"0.9.7.8" + $usbipTargetVersion = [Version]"0.9.7.7" $usbipInstalledVersion = $null $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | @@ -319,13 +395,13 @@ try { if (-not $needsUsbipInstall) { if ($usbipRuntimeBefore.Healthy) { Write-Host ( - "USBIP 0.9.7.8 userspace/driver ABI is ready at the canonical " + + "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.8 is already installed. Skipping a redundant " + + "USBIP 0.9.7.7 is already installed. Skipping a redundant " + "reinstall; restart Windows to load its matching driver." ) -ForegroundColor Yellow } @@ -340,13 +416,13 @@ try { elseif ($usbipInstalledVersion) { Write-Host ( "USBIP reports version $usbipInstalledVersion, but its canonical " + - "0.9.7.8 runtime is not usable ($($usbipRuntimeBefore.Detail)). " + - "Repairing the exact 0.9.7.8 package..." + "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.8 is missing or unusable " + + "USBIP 0.9.7.7 is missing or unusable " + "($($usbipRuntimeBefore.Detail)). Installing the exact package..." ) -ForegroundColor Yellow } @@ -357,24 +433,28 @@ try { Assert-PadSenseNotRunning Stop-ControllerBackends - $usbipInstallerUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.8/USBip-0.9.7.8-x64.exe" + $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 { + try { Write-Host " Downloading usbip-win2 installer..." -ForegroundColor Cyan Invoke-WebRequest -Uri $usbipInstallerUrl -OutFile $usbipInstaller -ErrorAction Stop $usbipInstallerHash = (Get-FileHash -LiteralPath $usbipInstaller -Algorithm SHA256).Hash.ToLowerInvariant() - if ($usbipInstallerHash -ne "44451fe06f4186125c2a5ecd25b099c5560a61a60b1e56f5a0758e77a60afa44") { + if ($usbipInstallerHash -ne "51620fa5f9f8be5932bc9d786deee557ce06d5407a99cab490dcfac71f185fea") { throw "USBIP installer integrity check failed." } Assert-PadSenseNotRunning + Disconnect-UsbipImports (Get-CanonicalUsbipPath) + $removedMismatchedPackage = Remove-MismatchedUsbipPackage ` + $usbipEntry $usbipInstalledVersion $usbipTargetVersion Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow $installerProcess = Start-Process -FilePath $usbipInstaller -ArgumentList "/S" -Verb RunAs -Wait -PassThru if ($installerProcess.ExitCode -notin @(0, 1641, 3010)) { throw "USBIP installer exited with code $($installerProcess.ExitCode)." } Write-Host "USBIP drivers installed/updated successfully" -ForegroundColor Green - $needsReboot = $installerProcess.ExitCode -in @(1641, 3010) + $needsReboot = $removedMismatchedPackage -or + $installerProcess.ExitCode -in @(1641, 3010) } catch { throw "Failed to install USBIP drivers: $($_.Exception.Message)" @@ -384,7 +464,7 @@ try { $usbipRuntime = Test-UsbipRuntime $usbipTargetVersion if (-not $usbipRuntime.Healthy) { $needsReboot = $true - Write-Host "USBIP 0.9.7.8 is installed but not active yet: $($usbipRuntime.Detail)" -ForegroundColor Yellow + Write-Host "USBIP 0.9.7.7 is installed but not active yet: $($usbipRuntime.Detail)" -ForegroundColor Yellow } if (-not $needsReboot) { @@ -415,7 +495,7 @@ try { if ($needsReboot) { Write-Host "" - Write-Host "USBIP 0.9.7.8 requires a restart before VIIPER can run safely." -ForegroundColor Yellow + Write-Host "USBIP 0.9.7.7 requires a restart before VIIPER can run safely." -ForegroundColor Yellow Write-Host "VIIPER was intentionally left stopped; restart Windows, then launch DS4Windows." -ForegroundColor Yellow } else { From 103d377f5364ea06fd701f7a939b5813815e5d00 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 30 Jul 2026 18:46:23 -0500 Subject: [PATCH 3/5] Make VIIPER 0.0.6 setup explicit and observable Show the linker-injected release version in the tray, require Y/N confirmation before setup or uninstall, expose numbered setup status, use the verified quiet USBIP replacement commands, and keep fork documentation/install links on the audited hbashton installer. --- .github/workflows/snapshots.yml | 4 +-- docs/getting-started/installation.md | 4 +-- internal/cmd/install.go | 19 ++++++++++++- internal/tray/tray_windows.go | 9 ++++++ internal/tray/tray_windows_test.go | 18 ++++++++++++ scripts/install.ps1 | 42 +++++++++++++++++++++++----- 6 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 internal/tray/tray_windows_test.go 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/docs/getting-started/installation.md b/docs/getting-started/installation.md index ce0cf0f9..1b1197b8 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -135,7 +135,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 +151,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` 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/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/scripts/install.ps1 b/scripts/install.ps1 index f57f5a30..5ae288f6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,25 @@ +param( + [switch]$Yes +) + $ErrorActionPreference = "Stop" $viiperVersion = "dev-snapshot" +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 driver can require an automatic restart." -ForegroundColor Yellow + $answer = Read-Host "Continue with VIIPER setup? [Y/N]" + if ($answer -notmatch '^(?i:y|yes)$') { + Write-Host "Setup canceled. No changes were made." -ForegroundColor Yellow + exit 1223 + } +} + $repo = "hbashton/VIIPER" $apiUrl = if ($viiperVersion -eq "dev-snapshot" -or $viiperVersion -eq "latest") { "https://api.github.com/repos/$repo/releases/latest" @@ -10,7 +28,7 @@ else { "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 @@ -276,9 +294,15 @@ try { -WindowStyle Hidden -Wait | Out-Null } catch { } - throw "USBIP could not unload its active kernel driver within " + - "30 seconds. Restart Windows, then run setup again; it will " + - "resume with pinned $targetVersion." + Write-Host ( + "The old USBIP kernel driver requires a reboot to unload. " + + "Windows will restart automatically in 15 seconds; run " + + "setup again after sign-in to finish pinned $targetVersion." + ) -ForegroundColor Yellow + Start-Process shutdown.exe -Verb RunAs ` + -ArgumentList "/r /t 15 /c `"Completing safe USBIP $targetVersion replacement`"" ` + -WindowStyle Hidden -Wait | Out-Null + throw "USBIP requires a restart before $targetVersion can be installed." } $uninstall.Refresh() if ($uninstall.ExitCode -notin @(0, 1641, 3010)) { @@ -359,8 +383,9 @@ try { Copy-Item $tempViiper $installPath -Force } - Write-Host "" - Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan + Write-Host "" + Write-Host "[2/4] VIIPER binary installed." -ForegroundColor Green + Write-Host "[3/4] Checking USBIP drivers..." -ForegroundColor Cyan $usbipTargetVersion = [Version]"0.9.7.7" $usbipInstalledVersion = $null @@ -448,7 +473,9 @@ try { $removedMismatchedPackage = Remove-MismatchedUsbipPackage ` $usbipEntry $usbipInstalledVersion $usbipTargetVersion Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow - $installerProcess = Start-Process -FilePath $usbipInstaller -ArgumentList "/S" -Verb RunAs -Wait -PassThru + $installerProcess = Start-Process -FilePath $usbipInstaller ` + -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" ` + -Verb RunAs -Wait -PassThru if ($installerProcess.ExitCode -notin @(0, 1641, 3010)) { throw "USBIP installer exited with code $($installerProcess.ExitCode)." } @@ -461,6 +488,7 @@ try { } } + Write-Host "[4/4] Verifying runtime readiness..." -ForegroundColor Cyan $usbipRuntime = Test-UsbipRuntime $usbipTargetVersion if (-not $usbipRuntime.Healthy) { $needsReboot = $true From 781f91a10318e5a30e065b5424d39734e656f9f4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 30 Jul 2026 19:57:36 -0500 Subject: [PATCH 4/5] Gate VIIPER startup on the supported USBIP ABI --- internal/cmd/install_windows.go | 79 +++++++++- internal/cmd/server.go | 5 + internal/cmd/usbip_prerequisite_other.go | 7 + internal/cmd/usbip_prerequisite_windows.go | 142 ++++++++++++++++++ .../cmd/usbip_prerequisite_windows_test.go | 90 +++++++++++ internal/server/api/autoattach_windows.go | 5 +- 6 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/usbip_prerequisite_other.go create mode 100644 internal/cmd/usbip_prerequisite_windows.go create mode 100644 internal/cmd/usbip_prerequisite_windows_test.go 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 4be3ff5c..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) 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/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index 46dd7c02..fd9ab5a9 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -317,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") From 93247f1b44291facf134170796d085026127eb66 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 30 Jul 2026 19:57:42 -0500 Subject: [PATCH 5/5] Make VIIPER 0.0.6 setup fail closed and recoverable --- README.md | 10 +- docs/getting-started/installation.md | 34 +- docs/getting-started/quickstart.md | 7 +- docs/getting-started/usbip.md | 139 ++-- docs/index.md | 2 +- docs/libviiper/overview.md | 4 +- docs/misc/support.md | 2 +- scripts/install.ps1 | 1028 ++++++++++++++++++++++---- 8 files changed, 992 insertions(+), 234 deletions(-) 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/docs/getting-started/installation.md b/docs/getting-started/installation.md index 1b1197b8..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) @@ -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/scripts/install.ps1 b/scripts/install.ps1 index 5ae288f6..3c065555 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3,8 +3,21 @@ param( ) $ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" -$viiperVersion = "dev-snapshot" +$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 { + $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 @@ -12,41 +25,33 @@ Write-Host " 2. Verify or install signed usbip-win2 0.9.7.7." -ForegroundColor 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 driver can require an automatic restart." -ForegroundColor Yellow - $answer = Read-Host "Continue with VIIPER setup? [Y/N]" + 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 - exit 1223 + return } } $repo = "hbashton/VIIPER" -$apiUrl = if ($viiperVersion -eq "dev-snapshot" -or $viiperVersion -eq "latest") { - "https://api.github.com/repos/$repo/releases/latest" -} -else { - "https://api.github.com/repos/$repo/releases/tags/$viiperVersion" -} +$apiUrl = "https://api.github.com/repos/$repo/releases/tags/$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") @@ -64,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) { @@ -80,16 +106,189 @@ try { } 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 } - } + 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 @@ -165,13 +364,266 @@ try { } } - function Get-CanonicalUsbipPath { - foreach ($root in @($env:ProgramW6432, $env:ProgramFiles, ${env:ProgramFiles(x86)})) { - if (-not $root) { continue } - $candidate = Join-Path $root "USBip\usbip.exe" - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return $candidate + 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 } @@ -207,7 +659,7 @@ try { } $exitCode = $LASTEXITCODE $text = ($output | Out-String).Trim() - if ($text -match "(?i)ABI\s+mismatch|unexpected\s+size.*(?:input|structure)") { + 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" @@ -230,6 +682,73 @@ try { } } + 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)) { @@ -238,28 +757,72 @@ try { $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" - try { $portOutput = @(& $usbipPath port 2>&1) } + try { + $portOutput = @(& $usbipPath port 2>&1) + $portExitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousErrorActionPreference } $portText = ($portOutput | ForEach-Object { [string]$_ }) -join ` [Environment]::NewLine - foreach ($match in [regex]::Matches($portText, - '(?im)^\s*Port\s+(\d+):')) { - $port = [int]$match.Groups[1].Value - Write-Host "Detaching stopped USBIP import on port $port..." ` + 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 { & $usbipPath detach -p $port 2>&1 | Out-Null } + 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 $entry -or -not $installedVersion -or - $installedVersion -eq $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) { @@ -280,38 +843,96 @@ try { 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 ( - "Removing unsupported USBIP $installedVersion before " + - "installing pinned safe $targetVersion..." + "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 - $uninstall = Start-Process -FilePath $uninstaller ` - -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" ` - -Verb RunAs -PassThru + 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)) { - try { - Start-Process taskkill.exe -Verb RunAs ` - -ArgumentList "/PID $($uninstall.Id) /T /F" ` - -WindowStyle Hidden -Wait | Out-Null - } - catch { } Write-Host ( - "The old USBIP kernel driver requires a reboot to unload. " + - "Windows will restart automatically in 15 seconds; run " + - "setup again after sign-in to finish pinned $targetVersion." + "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 - Start-Process shutdown.exe -Verb RunAs ` - -ArgumentList "/r /t 15 /c `"Completing safe USBIP $targetVersion replacement`"" ` - -WindowStyle Hidden -Wait | Out-Null - throw "USBIP requires a restart before $targetVersion can be installed." + return $true } $uninstall.Refresh() if ($uninstall.ExitCode -notin @(0, 1641, 3010)) { - throw "USBIP uninstall failed with code $($uninstall.ExitCode)." + 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 @@ -330,20 +951,47 @@ try { 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" @@ -351,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 @@ -365,56 +1023,86 @@ 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 "[2/4] VIIPER binary installed." -ForegroundColor Green Write-Host "[3/4] 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 { } - } + + 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 - $usbipRuntimeBefore.Reason -ne "abi-mismatch" - if ($usbipRuntimeBefore.Reason -eq "abi-mismatch") { + -not $sameVersionProbeFailure + if ($sameVersionProbeFailure) { $needsReboot = $true + Disable-ViiperStartup + Stop-ControllerBackends } if (-not $needsUsbipInstall) { @@ -426,8 +1114,11 @@ try { } else { Write-Host ( - "USBIP 0.9.7.7 is already installed. Skipping a redundant " + - "reinstall; restart Windows to load its matching driver." + "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 } } @@ -455,33 +1146,77 @@ try { if ($needsUsbipInstall) { Write-Host "This requires administrator privileges." -ForegroundColor Yellow + Disable-ViiperStartup Assert-PadSenseNotRunning Stop-ControllerBackends - - $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 - $usbipInstallerHash = (Get-FileHash -LiteralPath $usbipInstaller -Algorithm SHA256).Hash.ToLowerInvariant() - if ($usbipInstallerHash -ne "51620fa5f9f8be5932bc9d786deee557ce06d5407a99cab490dcfac71f185fea") { - throw "USBIP installer integrity check failed." + 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 - Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow - $installerProcess = Start-Process -FilePath $usbipInstaller ` - -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" ` - -Verb RunAs -Wait -PassThru - if ($installerProcess.ExitCode -notin @(0, 1641, 3010)) { - throw "USBIP installer exited with code $($installerProcess.ExitCode)." + + 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) + } } - Write-Host "USBIP drivers installed/updated successfully" -ForegroundColor Green - $needsReboot = $removedMismatchedPackage -or - $installerProcess.ExitCode -in @(1641, 3010) } catch { throw "Failed to install USBIP drivers: $($_.Exception.Message)" @@ -489,27 +1224,43 @@ try { } Write-Host "[4/4] Verifying runtime readiness..." -ForegroundColor Cyan - $usbipRuntime = Test-UsbipRuntime $usbipTargetVersion - if (-not $usbipRuntime.Healthy) { + $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 (-not $needsReboot) { + 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." } if (-not $needsReboot) { Write-Host "VIIPER service has been restarted." @@ -524,13 +1275,14 @@ try { if ($needsReboot) { Write-Host "" Write-Host "USBIP 0.9.7.7 requires a restart before VIIPER can run safely." -ForegroundColor Yellow - Write-Host "VIIPER was intentionally left stopped; restart Windows, then launch DS4Windows." -ForegroundColor Yellow + Write-Host "VIIPER startup remains disabled. Restart Windows yourself, then run this setup again." -ForegroundColor Yellow } - else { - taskkill.exe /IM "viiper.exe" /F > $null 2>&1 - Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "server" + # `viiper install` above starts the single verified server instance. +} +finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue + if ($setupMutexAcquired) { + try { $setupMutex.ReleaseMutex() } catch { } } -} -finally { - Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue -} + $setupMutex.Dispose() +}