Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ jobs:
<summary><b>Windows (PowerShell)</b></summary>

```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`
</details>
Expand All @@ -238,12 +238,12 @@ jobs:
<summary><b>Linux</b></summary>

```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`
</details>
files: release_files/*
prerelease: false
draft: true
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ jobs:
<summary><b>Windows (PowerShell)</b></summary>

```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`
</details>
Expand All @@ -134,7 +134,7 @@ jobs:
<summary><b>Linux</b></summary>

```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`
</details>
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion cmd/viiper/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`

Expand Down
16 changes: 16 additions & 0 deletions cmd/viiper/meta_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 17 additions & 0 deletions device/device_attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package device_test

import (
"context"
"sort"
"strings"
"testing"
"time"

Expand All @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions device/dualsense/audio_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 3 additions & 35 deletions device/dualsense/bthaptics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 0 additions & 47 deletions device/dualsense/bthaptics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 12 additions & 28 deletions device/dualsense/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down
Loading
Loading