feat: add Vita network gamepad
This commit is contained in:
commit
8a16d2e1f1
|
|
@ -0,0 +1,7 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
vita/build/
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Vita Gamepad contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
# Vita Gamepad
|
||||
|
||||
把一台已安装 HENkaku/enso 的 PlayStation Vita 通过局域网变成 Windows 或
|
||||
macOS 游戏手柄。
|
||||
|
||||
## 功能
|
||||
|
||||
- 同一 Wi-Fi 下自动发现电脑,无需在 Vita 上输入 IP
|
||||
- 约 120 Hz 的 UDP 输入传输
|
||||
- 双摇杆、方向键、四个动作键、L1/R1、Start/Select
|
||||
- 后触摸板左右半区模拟 L2/R2
|
||||
- 前触摸板底部左右角模拟 L3/R3
|
||||
- 断线 300 ms 后自动释放全部按键,避免卡键
|
||||
- Windows 输出标准 Xbox 360/XInput 虚拟手柄
|
||||
- macOS 输出通用 HID 游戏手柄
|
||||
|
||||
## 按键映射
|
||||
|
||||
| PS Vita | Xbox 游戏内名称 | PlayStation 名称 |
|
||||
|---|---|---|
|
||||
| × | A | Cross |
|
||||
| ○ | B | Circle |
|
||||
| □ | X | Square |
|
||||
| △ | Y | Triangle |
|
||||
| L / R | LB / RB | L1 / R1 |
|
||||
| 后触摸板左 / 右 | LT / RT | L2 / R2 |
|
||||
| Select / Start | Back / Start | Share / Options |
|
||||
| 前触摸板左下 / 右下 | LS / RS | L3 / R3 |
|
||||
|
||||
## 电脑端
|
||||
|
||||
需要 Python 3.10 或更高版本。建议先创建虚拟环境。
|
||||
|
||||
### Windows
|
||||
|
||||
1. 安装 [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases)。
|
||||
2. 在 PowerShell 中运行:
|
||||
|
||||
```powershell
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -e ".[windows]"
|
||||
vitapad
|
||||
```
|
||||
|
||||
首次运行时允许 Windows 防火墙的“专用网络”访问。程序创建的是 Xbox 360
|
||||
控制器,Steam、Xbox 应用和绝大多数 Windows 游戏都能直接识别。
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
sudo .venv/bin/vitapad
|
||||
```
|
||||
|
||||
macOS 后端通过 `IOHIDUserDevice` 创建通用 HID 手柄,通常需要管理员权限。
|
||||
Apple 并未把用户态虚拟游戏手柄作为稳定的 Game Controller API 保证;个别只
|
||||
接受 Apple Game Controller Framework 设备的游戏可能不会显示它。Steam 游戏可
|
||||
在“控制器”设置中启用 Steam Input 以提高兼容性。
|
||||
|
||||
### 仅测试网络,不创建手柄
|
||||
|
||||
```bash
|
||||
vitapad --backend debug
|
||||
```
|
||||
|
||||
收到数据后会在终端显示按键和摇杆状态,适合排查防火墙或 Wi-Fi 隔离问题。
|
||||
|
||||
可选参数:
|
||||
|
||||
```text
|
||||
--port 5000 输入端口
|
||||
--discovery-port 5001 自动发现端口
|
||||
--bind 0.0.0.0 监听地址
|
||||
--allow 192.168.1.25 只接受指定 Vita IP
|
||||
--timeout-ms 300 断线释放时间
|
||||
```
|
||||
|
||||
## PSVita 端构建与安装
|
||||
|
||||
需要安装 [VitaSDK](https://vitasdk.org/) 和 `vita2dlib`:
|
||||
|
||||
```bash
|
||||
cd vita
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
将 `build/vita-gamepad.vpk` 传到 Vita 并用 VitaShell 安装。启动电脑端
|
||||
`vitapad` 后再打开 Vita Gamepad;两台设备必须在同一局域网,且路由器不能启用
|
||||
客户端/AP 隔离。
|
||||
|
||||
Vita 界面显示电脑 IP 和已发送包数。按住 `Start + Select` 两秒可退出。
|
||||
|
||||
## 网络协议
|
||||
|
||||
协议说明见 [docs/protocol.md](docs/protocol.md)。它只有固定 20 字节,不执行
|
||||
远程命令,也不接收来自电脑的代码。自动发现只发送一个固定文本信标。
|
||||
|
||||
## 开发测试
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Vita Gamepad 网络协议 v1
|
||||
|
||||
电脑每秒向 UDP `5001` 广播一次 ASCII 文本 `VGPD_HOST_V1`。Vita 收到后,将该
|
||||
数据包的来源 IP 作为接收端,并向其 UDP `5000` 端口发送输入包。
|
||||
|
||||
所有多字节整数均为网络字节序。输入包固定为 20 字节:
|
||||
|
||||
| 偏移 | 大小 | 字段 | 含义 |
|
||||
|---:|---:|---|---|
|
||||
| 0 | 4 | magic | `VGPD` |
|
||||
| 4 | 1 | version | `1` |
|
||||
| 5 | 1 | flags | 保留,当前为 0 |
|
||||
| 6 | 2 | size | `20` |
|
||||
| 8 | 4 | sequence | 每包递增,允许 32 位回绕 |
|
||||
| 12 | 2 | buttons | 按键位图 |
|
||||
| 14 | 1 | lx | 左摇杆 X,0–255,中心 128 |
|
||||
| 15 | 1 | ly | 左摇杆 Y,0–255,中心 128 |
|
||||
| 16 | 1 | rx | 右摇杆 X,0–255,中心 128 |
|
||||
| 17 | 1 | ry | 右摇杆 Y,0–255,中心 128 |
|
||||
| 18 | 1 | lt | 左扳机,0–255 |
|
||||
| 19 | 1 | rt | 右扳机,0–255 |
|
||||
|
||||
按键位从低到高依次为:A、B、X、Y、LB、RB、Back、Start、L3、R3、Up、
|
||||
Down、Left、Right;第 14、15 位保留。
|
||||
|
||||
协议不包含鉴权或加密,设计用途是可信的家庭局域网。可在电脑端使用
|
||||
`--allow <Vita IP>` 限制来源。
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: vita-gamepad-initial
|
||||
- [2026-07-17 17:23:15]
|
||||
- **执行原因**: 从零实现 PSVita 在同一 Wi-Fi 下作为 Windows/macOS 游戏手柄使用的基础项目。
|
||||
- **执行过程**:
|
||||
1. 设计 20 字节低延迟 UDP 输入协议、局域网自动发现机制和断线松键保护。
|
||||
2. 实现 PSVita 端双摇杆、物理按键及前后触摸板采集,并以约 120 Hz 发送。
|
||||
3. 实现 Windows ViGEm/XInput 虚拟 Xbox 360 手柄后端。
|
||||
4. 实现 macOS IOHIDUserDevice 通用 HID 游戏手柄后端。
|
||||
5. 补充电脑端安装入口、VitaSDK 构建配置、按键映射、协议和排障文档。
|
||||
6. 完成协议编解码、序列号回绕、UDP 接收、旧包过滤和断线保护测试。
|
||||
- **执行结果**: 已形成可构建的跨端 MVP;电脑端 6 项自动化测试全部通过。当前环境未安装 VitaSDK,VPK 需在配置 VitaSDK 的机器上完成编译;macOS 创建虚拟 HID 需要管理员权限。
|
||||
|
||||
## 会话 ID: vita-gamepad-publish
|
||||
- [2026-07-17 17:34:57]
|
||||
- **执行原因**: 将 Vita Gamepad 项目发布到用户指定的 Git 远程仓库。
|
||||
- **执行过程**:
|
||||
1. 检查本地工作区、当前分支和提交历史。
|
||||
2. 确认目标远程仓库为空,避免覆盖既有提交。
|
||||
3. 提交项目全部源码、测试与文档。
|
||||
4. 配置远程地址并推送 main 分支。
|
||||
- **执行结果**: Vita Gamepad 项目已提交并推送至指定仓库。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "vita-gamepad"
|
||||
version = "0.1.0"
|
||||
description = "Use a PlayStation Vita as a low-latency network gamepad"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Vita Gamepad contributors" }]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
windows = ["vgamepad>=0.1.0"]
|
||||
|
||||
[project.scripts]
|
||||
vitapad = "vitapad.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["vitapad*"]
|
||||
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import unittest
|
||||
|
||||
from vitapad.protocol import (
|
||||
Buttons,
|
||||
InputState,
|
||||
PACKET_SIZE,
|
||||
decode_packet,
|
||||
encode_packet,
|
||||
sequence_is_newer,
|
||||
)
|
||||
|
||||
|
||||
class ProtocolTests(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
state = InputState(
|
||||
sequence=123456,
|
||||
buttons=Buttons.A | Buttons.UP,
|
||||
lx=1,
|
||||
ly=2,
|
||||
rx=253,
|
||||
ry=254,
|
||||
lt=17,
|
||||
rt=240,
|
||||
)
|
||||
encoded = encode_packet(state)
|
||||
self.assertEqual(len(encoded), PACKET_SIZE)
|
||||
self.assertEqual(decode_packet(encoded), state)
|
||||
|
||||
def test_rejects_bad_packets(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode_packet(b"")
|
||||
packet = bytearray(encode_packet(InputState()))
|
||||
packet[0] = 0
|
||||
with self.assertRaises(ValueError):
|
||||
decode_packet(packet)
|
||||
|
||||
def test_sequence_comparison_wraps(self) -> None:
|
||||
self.assertTrue(sequence_is_newer(11, 10))
|
||||
self.assertFalse(sequence_is_newer(10, 10))
|
||||
self.assertFalse(sequence_is_newer(9, 10))
|
||||
self.assertTrue(sequence_is_newer(0, 0xFFFFFFFF))
|
||||
|
||||
def test_axis_validation(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
encode_packet(InputState(lx=256))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from vitapad.protocol import Buttons, InputState, encode_packet
|
||||
from vitapad.receiver import Receiver
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
name = "fake"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.states: list[InputState] = []
|
||||
self.closed = False
|
||||
|
||||
def update(self, state: InputState) -> None:
|
||||
self.states.append(state)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def unused_udp_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
class ReceiverTests(unittest.TestCase):
|
||||
def test_receive_and_failsafe(self) -> None:
|
||||
backend = FakeBackend()
|
||||
port = unused_udp_port()
|
||||
receiver = Receiver(
|
||||
backend,
|
||||
bind="127.0.0.1",
|
||||
port=port,
|
||||
discovery_port=unused_udp_port(),
|
||||
timeout_ms=50,
|
||||
)
|
||||
thread = threading.Thread(target=receiver.run)
|
||||
thread.start()
|
||||
try:
|
||||
state = InputState(sequence=7, buttons=Buttons.A, lx=200)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
|
||||
# Give the receiver thread time to bind.
|
||||
time.sleep(0.02)
|
||||
sender.sendto(encode_packet(state), ("127.0.0.1", port))
|
||||
deadline = time.monotonic() + 1.0
|
||||
while len(backend.states) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(backend.states[0], state)
|
||||
self.assertEqual(backend.states[1], InputState.neutral())
|
||||
finally:
|
||||
receiver.stop()
|
||||
thread.join(timeout=2)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertTrue(backend.closed)
|
||||
|
||||
def test_rejects_old_sequence(self) -> None:
|
||||
backend = FakeBackend()
|
||||
receiver = Receiver(backend)
|
||||
receiver._connected_address = "127.0.0.1"
|
||||
receiver._last_sequence = 20
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.settimeout(1)
|
||||
sender.sendto(
|
||||
encode_packet(InputState(sequence=19, buttons=Buttons.B)),
|
||||
listener.getsockname(),
|
||||
)
|
||||
receiver._receive_once(listener)
|
||||
finally:
|
||||
listener.close()
|
||||
sender.close()
|
||||
self.assertEqual(backend.states, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
if(NOT DEFINED CMAKE_TOOLCHAIN_FILE)
|
||||
if(DEFINED ENV{VITASDK})
|
||||
set(CMAKE_TOOLCHAIN_FILE "$ENV{VITASDK}/share/vita.toolchain.cmake"
|
||||
CACHE PATH "Vita toolchain file")
|
||||
else()
|
||||
message(FATAL_ERROR "Please define VITASDK to point to your SDK path")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
project(vita_gamepad C)
|
||||
include("${VITASDK}/share/vita.cmake" REQUIRED)
|
||||
|
||||
set(VITA_APP_NAME "Vita Gamepad")
|
||||
set(VITA_TITLEID "VITAPAD01")
|
||||
set(VITA_VERSION "01.00")
|
||||
|
||||
add_executable(vita-gamepad src/main.c)
|
||||
target_compile_options(vita-gamepad PRIVATE -Wall -Wextra -Werror -O2)
|
||||
target_link_libraries(vita-gamepad
|
||||
vita2d
|
||||
SceDisplay_stub
|
||||
SceGxm_stub
|
||||
SceSysmodule_stub
|
||||
SceCtrl_stub
|
||||
SceTouch_stub
|
||||
SceNet_stub
|
||||
SceNetCtl_stub
|
||||
ScePgf_stub
|
||||
SceCommonDialog_stub
|
||||
SceAppMgr_stub
|
||||
m
|
||||
)
|
||||
|
||||
vita_create_self(vita-gamepad.self vita-gamepad UNSAFE)
|
||||
vita_create_vpk(vita-gamepad.vpk ${VITA_TITLEID} vita-gamepad.self
|
||||
VERSION ${VITA_VERSION}
|
||||
NAME ${VITA_APP_NAME}
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<livearea style="a1" format-ver="01.00" content-rev="1">
|
||||
<livearea-background>
|
||||
<image>bg.png</image>
|
||||
</livearea-background>
|
||||
<gate>
|
||||
<startup-image>startup.png</startup-image>
|
||||
</gate>
|
||||
</livearea>
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
#include <psp2/ctrl.h>
|
||||
#include <psp2/kernel/processmgr.h>
|
||||
#include <psp2/kernel/threadmgr.h>
|
||||
#include <psp2/net/net.h>
|
||||
#include <psp2/net/netctl.h>
|
||||
#include <psp2/sysmodule.h>
|
||||
#include <psp2/touch.h>
|
||||
#include <vita2d.h>
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define INPUT_PORT 5000
|
||||
#define DISCOVERY_PORT 5001
|
||||
#define SEND_INTERVAL_US 8333
|
||||
#define DISCOVERY_MAGIC "VGPD_HOST_V1"
|
||||
#define PACKET_MAGIC "VGPD"
|
||||
#define PACKET_VERSION 1
|
||||
|
||||
enum ProtocolButton {
|
||||
BTN_A = 1u << 0,
|
||||
BTN_B = 1u << 1,
|
||||
BTN_X = 1u << 2,
|
||||
BTN_Y = 1u << 3,
|
||||
BTN_LB = 1u << 4,
|
||||
BTN_RB = 1u << 5,
|
||||
BTN_BACK = 1u << 6,
|
||||
BTN_START = 1u << 7,
|
||||
BTN_L3 = 1u << 8,
|
||||
BTN_R3 = 1u << 9,
|
||||
BTN_UP = 1u << 10,
|
||||
BTN_DOWN = 1u << 11,
|
||||
BTN_LEFT = 1u << 12,
|
||||
BTN_RIGHT = 1u << 13,
|
||||
};
|
||||
|
||||
typedef struct __attribute__((packed)) InputPacket {
|
||||
char magic[4];
|
||||
uint8_t version;
|
||||
uint8_t flags;
|
||||
uint16_t size;
|
||||
uint32_t sequence;
|
||||
uint16_t buttons;
|
||||
uint8_t lx;
|
||||
uint8_t ly;
|
||||
uint8_t rx;
|
||||
uint8_t ry;
|
||||
uint8_t lt;
|
||||
uint8_t rt;
|
||||
} InputPacket;
|
||||
|
||||
_Static_assert(sizeof(InputPacket) == 20, "InputPacket must be exactly 20 bytes");
|
||||
|
||||
static uint8_t net_memory[1024 * 1024];
|
||||
static volatile bool exiting = false;
|
||||
|
||||
static int exit_callback(int argc, const void *args) {
|
||||
(void)argc;
|
||||
(void)args;
|
||||
exiting = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int callback_thread(SceSize args, void *argp) {
|
||||
(void)args;
|
||||
(void)argp;
|
||||
int callback = sceKernelCreateCallback("Exit callback", exit_callback, NULL);
|
||||
sceKernelRegisterExitCallback(callback);
|
||||
sceKernelDelayThreadCB(0xFFFFFFFF);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void setup_callbacks(void) {
|
||||
int thread = sceKernelCreateThread(
|
||||
"callback thread", callback_thread, 0x10000100, 0x1000, 0, 0, NULL
|
||||
);
|
||||
if (thread >= 0) {
|
||||
sceKernelStartThread(thread, 0, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static int init_network(void) {
|
||||
int result = sceSysmoduleLoadModule(SCE_SYSMODULE_NET);
|
||||
if (result < 0) {
|
||||
return result;
|
||||
}
|
||||
SceNetInitParam params;
|
||||
memset(¶ms, 0, sizeof(params));
|
||||
params.memory = net_memory;
|
||||
params.size = sizeof(net_memory);
|
||||
result = sceNetInit(¶ms);
|
||||
if (result < 0) {
|
||||
return result;
|
||||
}
|
||||
return sceNetCtlInit();
|
||||
}
|
||||
|
||||
static int create_socket(void) {
|
||||
int sock = sceNetSocket(
|
||||
"vita-gamepad", SCE_NET_AF_INET, SCE_NET_SOCK_DGRAM, 0
|
||||
);
|
||||
if (sock < 0) {
|
||||
return sock;
|
||||
}
|
||||
|
||||
int nonblocking = 1;
|
||||
sceNetSetsockopt(
|
||||
sock, SCE_NET_SOL_SOCKET, SCE_NET_SO_NBIO,
|
||||
&nonblocking, sizeof(nonblocking)
|
||||
);
|
||||
|
||||
SceNetSockaddrIn local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
local.sin_len = sizeof(local);
|
||||
local.sin_family = SCE_NET_AF_INET;
|
||||
local.sin_port = sceNetHtons(DISCOVERY_PORT);
|
||||
local.sin_addr.s_addr = sceNetHtonl(SCE_NET_INADDR_ANY);
|
||||
int result = sceNetBind(sock, (SceNetSockaddr *)&local, sizeof(local));
|
||||
if (result < 0) {
|
||||
sceNetSocketClose(sock);
|
||||
return result;
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
|
||||
static bool find_host(int sock, SceNetSockaddrIn *host) {
|
||||
char buffer[32];
|
||||
SceNetSockaddrIn source;
|
||||
unsigned int source_length = sizeof(source);
|
||||
int count = sceNetRecvfrom(
|
||||
sock, buffer, sizeof(buffer), 0,
|
||||
(SceNetSockaddr *)&source, &source_length
|
||||
);
|
||||
if (count != (int)strlen(DISCOVERY_MAGIC) ||
|
||||
memcmp(buffer, DISCOVERY_MAGIC, strlen(DISCOVERY_MAGIC)) != 0) {
|
||||
return false;
|
||||
}
|
||||
*host = source;
|
||||
host->sin_port = sceNetHtons(INPUT_PORT);
|
||||
return true;
|
||||
}
|
||||
|
||||
static uint16_t map_buttons(uint32_t vita_buttons) {
|
||||
uint16_t buttons = 0;
|
||||
if (vita_buttons & SCE_CTRL_CROSS) buttons |= BTN_A;
|
||||
if (vita_buttons & SCE_CTRL_CIRCLE) buttons |= BTN_B;
|
||||
if (vita_buttons & SCE_CTRL_SQUARE) buttons |= BTN_X;
|
||||
if (vita_buttons & SCE_CTRL_TRIANGLE) buttons |= BTN_Y;
|
||||
if (vita_buttons & SCE_CTRL_LTRIGGER) buttons |= BTN_LB;
|
||||
if (vita_buttons & SCE_CTRL_RTRIGGER) buttons |= BTN_RB;
|
||||
if (vita_buttons & SCE_CTRL_SELECT) buttons |= BTN_BACK;
|
||||
if (vita_buttons & SCE_CTRL_START) buttons |= BTN_START;
|
||||
if (vita_buttons & SCE_CTRL_UP) buttons |= BTN_UP;
|
||||
if (vita_buttons & SCE_CTRL_DOWN) buttons |= BTN_DOWN;
|
||||
if (vita_buttons & SCE_CTRL_LEFT) buttons |= BTN_LEFT;
|
||||
if (vita_buttons & SCE_CTRL_RIGHT) buttons |= BTN_RIGHT;
|
||||
return buttons;
|
||||
}
|
||||
|
||||
static void read_touch(uint16_t *buttons, uint8_t *lt, uint8_t *rt) {
|
||||
SceTouchData touch;
|
||||
*lt = 0;
|
||||
*rt = 0;
|
||||
|
||||
memset(&touch, 0, sizeof(touch));
|
||||
if (sceTouchPeek(SCE_TOUCH_PORT_BACK, &touch, 1) >= 0) {
|
||||
for (uint32_t i = 0; i < touch.reportNum; ++i) {
|
||||
if (touch.report[i].x < 960) {
|
||||
*lt = 255;
|
||||
} else {
|
||||
*rt = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memset(&touch, 0, sizeof(touch));
|
||||
if (sceTouchPeek(SCE_TOUCH_PORT_FRONT, &touch, 1) >= 0) {
|
||||
for (uint32_t i = 0; i < touch.reportNum; ++i) {
|
||||
if (touch.report[i].y >= 850 && touch.report[i].x < 480) {
|
||||
*buttons |= BTN_L3;
|
||||
} else if (touch.report[i].y >= 850 &&
|
||||
touch.report[i].x > 1440) {
|
||||
*buttons |= BTN_R3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void make_packet(
|
||||
InputPacket *packet, const SceCtrlData *pad, uint32_t sequence
|
||||
) {
|
||||
memset(packet, 0, sizeof(*packet));
|
||||
memcpy(packet->magic, PACKET_MAGIC, sizeof(packet->magic));
|
||||
packet->version = PACKET_VERSION;
|
||||
packet->size = sceNetHtons(sizeof(*packet));
|
||||
packet->sequence = sceNetHtonl(sequence);
|
||||
uint16_t buttons = map_buttons(pad->buttons);
|
||||
read_touch(&buttons, &packet->lt, &packet->rt);
|
||||
packet->buttons = sceNetHtons(buttons);
|
||||
packet->lx = pad->lx;
|
||||
packet->ly = pad->ly;
|
||||
packet->rx = pad->rx;
|
||||
packet->ry = pad->ry;
|
||||
}
|
||||
|
||||
static void format_ip(const SceNetSockaddrIn *address, char *out, size_t size) {
|
||||
uint32_t ip = sceNetNtohl(address->sin_addr.s_addr);
|
||||
snprintf(
|
||||
out, size, "%u.%u.%u.%u",
|
||||
(unsigned)((ip >> 24) & 0xff), (unsigned)((ip >> 16) & 0xff),
|
||||
(unsigned)((ip >> 8) & 0xff), (unsigned)(ip & 0xff)
|
||||
);
|
||||
}
|
||||
|
||||
static void draw_ui(
|
||||
vita2d_pgf *font, bool connected, const char *host_ip,
|
||||
uint32_t sent, int network_error
|
||||
) {
|
||||
vita2d_start_drawing();
|
||||
vita2d_clear_screen();
|
||||
vita2d_draw_rectangle(0, 0, 960, 544, RGBA8(18, 24, 38, 255));
|
||||
vita2d_draw_rectangle(0, 0, 960, 8, RGBA8(38, 190, 125, 255));
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 92, RGBA8(255, 255, 255, 255), 1.6f, "Vita Gamepad"
|
||||
);
|
||||
if (network_error < 0) {
|
||||
char error[96];
|
||||
snprintf(error, sizeof(error), "Network error: 0x%08X", network_error);
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 176, RGBA8(255, 105, 105, 255), 1.0f, error
|
||||
);
|
||||
} else if (!connected) {
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 176, RGBA8(245, 200, 80, 255), 1.0f,
|
||||
"Waiting for a computer on the same Wi-Fi..."
|
||||
);
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 218, RGBA8(180, 190, 205, 255), 0.8f,
|
||||
"Start 'vitapad' on Windows or macOS."
|
||||
);
|
||||
} else {
|
||||
char status[128];
|
||||
snprintf(status, sizeof(status), "Connected: %s", host_ip);
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 176, RGBA8(80, 225, 155, 255), 1.0f, status
|
||||
);
|
||||
snprintf(status, sizeof(status), "Packets sent: %u", (unsigned)sent);
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 218, RGBA8(180, 190, 205, 255), 0.8f, status
|
||||
);
|
||||
}
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 390, RGBA8(180, 190, 205, 255), 0.75f,
|
||||
"Rear touch: L2 / R2 Front bottom corners: L3 / R3"
|
||||
);
|
||||
vita2d_pgf_draw_text(
|
||||
font, 54, 438, RGBA8(140, 150, 165, 255), 0.7f,
|
||||
"Hold START + SELECT for 2 seconds to exit"
|
||||
);
|
||||
vita2d_end_drawing();
|
||||
vita2d_swap_buffers();
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
setup_callbacks();
|
||||
sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG_WIDE);
|
||||
sceTouchSetSamplingState(SCE_TOUCH_PORT_FRONT, SCE_TOUCH_SAMPLING_STATE_START);
|
||||
sceTouchSetSamplingState(SCE_TOUCH_PORT_BACK, SCE_TOUCH_SAMPLING_STATE_START);
|
||||
|
||||
vita2d_init();
|
||||
vita2d_set_clear_color(RGBA8(18, 24, 38, 255));
|
||||
vita2d_pgf *font = vita2d_load_default_pgf();
|
||||
|
||||
int network_result = init_network();
|
||||
bool network_initialized = network_result >= 0;
|
||||
int sock = network_result >= 0 ? create_socket() : network_result;
|
||||
if (sock < 0) {
|
||||
network_result = sock;
|
||||
}
|
||||
|
||||
bool connected = false;
|
||||
SceNetSockaddrIn host;
|
||||
memset(&host, 0, sizeof(host));
|
||||
char host_ip[32] = "";
|
||||
uint32_t sequence = 0;
|
||||
uint32_t sent = 0;
|
||||
uint32_t exit_frames = 0;
|
||||
uint32_t draw_divider = 0;
|
||||
|
||||
while (!exiting) {
|
||||
if (sock >= 0 && find_host(sock, &host)) {
|
||||
connected = true;
|
||||
format_ip(&host, host_ip, sizeof(host_ip));
|
||||
}
|
||||
|
||||
SceCtrlData pad;
|
||||
memset(&pad, 0, sizeof(pad));
|
||||
if (sceCtrlPeekBufferPositive(0, &pad, 1) > 0) {
|
||||
if ((pad.buttons & (SCE_CTRL_START | SCE_CTRL_SELECT)) ==
|
||||
(SCE_CTRL_START | SCE_CTRL_SELECT)) {
|
||||
exit_frames++;
|
||||
if (exit_frames >= 240) exiting = true;
|
||||
} else {
|
||||
exit_frames = 0;
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
InputPacket packet;
|
||||
make_packet(&packet, &pad, sequence++);
|
||||
int result = sceNetSendto(
|
||||
sock, &packet, sizeof(packet), 0,
|
||||
(SceNetSockaddr *)&host, sizeof(host)
|
||||
);
|
||||
if (result == (int)sizeof(packet)) {
|
||||
sent++;
|
||||
} else if (result < 0) {
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (++draw_divider >= 4) {
|
||||
draw_ui(font, connected, host_ip, sent, network_result);
|
||||
draw_divider = 0;
|
||||
}
|
||||
sceKernelDelayThread(SEND_INTERVAL_US);
|
||||
}
|
||||
|
||||
if (sock >= 0) sceNetSocketClose(sock);
|
||||
if (network_initialized) {
|
||||
sceNetCtlTerm();
|
||||
sceNetTerm();
|
||||
sceSysmoduleUnloadModule(SCE_SYSMODULE_NET);
|
||||
}
|
||||
vita2d_free_pgf(font);
|
||||
vita2d_fini();
|
||||
sceKernelExitProcess(0);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""Vita Gamepad desktop receiver."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from vitapad.cli import main
|
||||
|
||||
main()
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Protocol
|
||||
|
||||
from vitapad.protocol import InputState
|
||||
|
||||
|
||||
class GamepadBackend(Protocol):
|
||||
name: str
|
||||
|
||||
def update(self, state: InputState) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
def create_backend(name: str) -> GamepadBackend:
|
||||
selected = name
|
||||
if selected == "auto":
|
||||
selected = "windows" if sys.platform == "win32" else "macos" if sys.platform == "darwin" else "debug"
|
||||
if selected == "windows":
|
||||
from .windows import WindowsGamepad
|
||||
|
||||
return WindowsGamepad()
|
||||
if selected == "macos":
|
||||
from .macos import MacOSGamepad
|
||||
|
||||
return MacOSGamepad()
|
||||
if selected == "debug":
|
||||
from .debug import DebugGamepad
|
||||
|
||||
return DebugGamepad()
|
||||
raise ValueError(f"unknown backend: {name}")
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from vitapad.protocol import InputState
|
||||
|
||||
|
||||
class DebugGamepad:
|
||||
name = "debug"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_print = 0.0
|
||||
|
||||
def update(self, state: InputState) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_print >= 0.1:
|
||||
print(
|
||||
f"\rseq={state.sequence:<10} buttons=0x{state.buttons:04x} "
|
||||
f"L=({state.lx:3},{state.ly:3}) R=({state.rx:3},{state.ry:3}) "
|
||||
f"LT={state.lt:3} RT={state.rt:3}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
self._last_print = now
|
||||
|
||||
def close(self) -> None:
|
||||
print()
|
||||
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import struct
|
||||
|
||||
from vitapad.protocol import Buttons, InputState
|
||||
|
||||
# Report 1: 16 buttons, 8-way hat, four 16-bit axes and two 8-bit triggers.
|
||||
REPORT_DESCRIPTOR = bytes(
|
||||
[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x15, 0x00,
|
||||
0x25, 0x01, 0x75, 0x01, 0x95, 0x10, 0x81, 0x02,
|
||||
0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
|
||||
0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x75,
|
||||
0x04, 0x95, 0x01, 0x81, 0x03, 0x09, 0x30, 0x09,
|
||||
0x31, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26,
|
||||
0xFF, 0xFF, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02,
|
||||
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF,
|
||||
0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0xC0,
|
||||
]
|
||||
)
|
||||
|
||||
BUTTON_ORDER = (
|
||||
Buttons.A, Buttons.B, Buttons.X, Buttons.Y,
|
||||
Buttons.LB, Buttons.RB, Buttons.BACK, Buttons.START,
|
||||
Buttons.L3, Buttons.R3,
|
||||
)
|
||||
|
||||
|
||||
def _hat(buttons: int) -> int:
|
||||
up, down = bool(buttons & Buttons.UP), bool(buttons & Buttons.DOWN)
|
||||
left, right = bool(buttons & Buttons.LEFT), bool(buttons & Buttons.RIGHT)
|
||||
if up and right:
|
||||
return 1
|
||||
if right and down:
|
||||
return 3
|
||||
if down and left:
|
||||
return 5
|
||||
if left and up:
|
||||
return 7
|
||||
if up and not down:
|
||||
return 0
|
||||
if right and not left:
|
||||
return 2
|
||||
if down and not up:
|
||||
return 4
|
||||
if left and not right:
|
||||
return 6
|
||||
return 8
|
||||
|
||||
|
||||
class MacOSGamepad:
|
||||
name = "Generic HID gamepad (IOHIDUserDevice)"
|
||||
|
||||
def __init__(self) -> None:
|
||||
cf_path = ctypes.util.find_library("CoreFoundation")
|
||||
io_path = ctypes.util.find_library("IOKit")
|
||||
if not cf_path or not io_path:
|
||||
raise RuntimeError("找不到 macOS CoreFoundation/IOKit")
|
||||
self._cf = ctypes.CDLL(cf_path)
|
||||
self._io = ctypes.CDLL(io_path)
|
||||
self._configure_functions()
|
||||
properties = self._create_properties()
|
||||
self._device = self._io.IOHIDUserDeviceCreate(None, properties)
|
||||
self._cf.CFRelease(properties)
|
||||
if not self._device:
|
||||
raise RuntimeError(
|
||||
"无法创建 macOS 虚拟 HID 手柄;请尝试使用 sudo 运行"
|
||||
)
|
||||
|
||||
def _configure_functions(self) -> None:
|
||||
self._cf.CFStringCreateWithCString.restype = ctypes.c_void_p
|
||||
self._cf.CFStringCreateWithCString.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32
|
||||
]
|
||||
self._cf.CFDataCreate.restype = ctypes.c_void_p
|
||||
self._cf.CFDataCreate.argtypes = [
|
||||
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_long
|
||||
]
|
||||
self._cf.CFNumberCreate.restype = ctypes.c_void_p
|
||||
self._cf.CFNumberCreate.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p
|
||||
]
|
||||
self._cf.CFDictionaryCreateMutable.restype = ctypes.c_void_p
|
||||
self._cf.CFDictionaryCreateMutable.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p
|
||||
]
|
||||
self._cf.CFDictionarySetValue.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p
|
||||
]
|
||||
self._cf.CFRelease.argtypes = [ctypes.c_void_p]
|
||||
self._io.IOHIDUserDeviceCreate.restype = ctypes.c_void_p
|
||||
self._io.IOHIDUserDeviceCreate.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
self._io.IOHIDUserDeviceHandleReport.argtypes = [
|
||||
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_long
|
||||
]
|
||||
self._io.IOHIDUserDeviceHandleReport.restype = ctypes.c_int
|
||||
self._dictionary_key_callbacks = ctypes.c_byte.in_dll(
|
||||
self._cf, "kCFTypeDictionaryKeyCallBacks"
|
||||
)
|
||||
self._dictionary_value_callbacks = ctypes.c_byte.in_dll(
|
||||
self._cf, "kCFTypeDictionaryValueCallBacks"
|
||||
)
|
||||
|
||||
def _string(self, value: str) -> int:
|
||||
# kCFStringEncodingUTF8
|
||||
return self._cf.CFStringCreateWithCString(None, value.encode(), 0x08000100)
|
||||
|
||||
def _create_properties(self) -> int:
|
||||
dictionary = self._cf.CFDictionaryCreateMutable(
|
||||
None,
|
||||
0,
|
||||
ctypes.byref(self._dictionary_key_callbacks),
|
||||
ctypes.byref(self._dictionary_value_callbacks),
|
||||
)
|
||||
retained: list[int] = []
|
||||
|
||||
def put_number(key: str, value: int) -> None:
|
||||
key_ref = self._string(key)
|
||||
raw = ctypes.c_int(value)
|
||||
value_ref = self._cf.CFNumberCreate(None, 9, ctypes.byref(raw))
|
||||
self._cf.CFDictionarySetValue(dictionary, key_ref, value_ref)
|
||||
retained.extend((key_ref, value_ref))
|
||||
|
||||
def put_string(key: str, value: str) -> None:
|
||||
key_ref, value_ref = self._string(key), self._string(value)
|
||||
self._cf.CFDictionarySetValue(dictionary, key_ref, value_ref)
|
||||
retained.extend((key_ref, value_ref))
|
||||
|
||||
descriptor_array = (ctypes.c_uint8 * len(REPORT_DESCRIPTOR)).from_buffer_copy(
|
||||
REPORT_DESCRIPTOR
|
||||
)
|
||||
descriptor = self._cf.CFDataCreate(
|
||||
None, descriptor_array, len(REPORT_DESCRIPTOR)
|
||||
)
|
||||
descriptor_key = self._string("ReportDescriptor")
|
||||
self._cf.CFDictionarySetValue(dictionary, descriptor_key, descriptor)
|
||||
retained.extend((descriptor_key, descriptor))
|
||||
put_number("VendorID", 0x054C)
|
||||
put_number("ProductID", 0x09CC)
|
||||
put_number("VersionNumber", 0x0100)
|
||||
put_number("PrimaryUsagePage", 0x01)
|
||||
put_number("PrimaryUsage", 0x05)
|
||||
put_string("Manufacturer", "Vita Gamepad")
|
||||
put_string("Product", "PS Vita Network Gamepad")
|
||||
for item in retained:
|
||||
self._cf.CFRelease(item)
|
||||
return dictionary
|
||||
|
||||
def update(self, state: InputState) -> None:
|
||||
hid_buttons = 0
|
||||
for index, mask in enumerate(BUTTON_ORDER):
|
||||
if state.buttons & mask:
|
||||
hid_buttons |= 1 << index
|
||||
report = struct.pack(
|
||||
"<BHBHHHHBB",
|
||||
1,
|
||||
hid_buttons,
|
||||
_hat(state.buttons),
|
||||
state.lx * 257,
|
||||
state.ly * 257,
|
||||
state.rx * 257,
|
||||
state.ry * 257,
|
||||
state.lt,
|
||||
state.rt,
|
||||
)
|
||||
buffer = (ctypes.c_uint8 * len(report)).from_buffer_copy(report)
|
||||
result = self._io.IOHIDUserDeviceHandleReport(
|
||||
self._device, buffer, len(report)
|
||||
)
|
||||
if result != 0:
|
||||
raise RuntimeError(f"IOHIDUserDeviceHandleReport 失败: 0x{result:08x}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self._device:
|
||||
self.update(InputState.neutral())
|
||||
self._cf.CFRelease(self._device)
|
||||
self._device = None
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vitapad.protocol import Buttons, InputState
|
||||
|
||||
try:
|
||||
import vgamepad as vg
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'Windows 后端需要 vgamepad,请运行: pip install -e ".[windows]"'
|
||||
) from exc
|
||||
|
||||
|
||||
BUTTON_MAP = (
|
||||
(Buttons.A, vg.XUSB_BUTTON.XUSB_GAMEPAD_A),
|
||||
(Buttons.B, vg.XUSB_BUTTON.XUSB_GAMEPAD_B),
|
||||
(Buttons.X, vg.XUSB_BUTTON.XUSB_GAMEPAD_X),
|
||||
(Buttons.Y, vg.XUSB_BUTTON.XUSB_GAMEPAD_Y),
|
||||
(Buttons.LB, vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER),
|
||||
(Buttons.RB, vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER),
|
||||
(Buttons.BACK, vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK),
|
||||
(Buttons.START, vg.XUSB_BUTTON.XUSB_GAMEPAD_START),
|
||||
(Buttons.L3, vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB),
|
||||
(Buttons.R3, vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB),
|
||||
(Buttons.UP, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP),
|
||||
(Buttons.DOWN, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN),
|
||||
(Buttons.LEFT, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT),
|
||||
(Buttons.RIGHT, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT),
|
||||
)
|
||||
|
||||
|
||||
def _axis(value: int, *, invert: bool = False) -> int:
|
||||
if value >= 128:
|
||||
result = round((value - 128) * 32767 / 127)
|
||||
else:
|
||||
result = round((value - 128) * 32768 / 128)
|
||||
return -result if invert else result
|
||||
|
||||
|
||||
class WindowsGamepad:
|
||||
name = "Xbox 360 (ViGEm)"
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
self._pad = vg.VX360Gamepad()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"无法创建虚拟手柄,请确认已安装 ViGEmBus 驱动"
|
||||
) from exc
|
||||
|
||||
def update(self, state: InputState) -> None:
|
||||
self._pad.reset()
|
||||
for mask, button in BUTTON_MAP:
|
||||
if state.buttons & mask:
|
||||
self._pad.press_button(button=button)
|
||||
self._pad.left_joystick(
|
||||
x_value=_axis(state.lx), y_value=_axis(state.ly, invert=True)
|
||||
)
|
||||
self._pad.right_joystick(
|
||||
x_value=_axis(state.rx), y_value=_axis(state.ry, invert=True)
|
||||
)
|
||||
self._pad.left_trigger(value=state.lt)
|
||||
self._pad.right_trigger(value=state.rt)
|
||||
self._pad.update()
|
||||
|
||||
def close(self) -> None:
|
||||
self.update(InputState.neutral())
|
||||
del self._pad
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from vitapad.backends import create_backend
|
||||
from vitapad.receiver import Receiver
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="vitapad", description="将 PSVita 输入转换为电脑虚拟游戏手柄"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend", choices=("auto", "windows", "macos", "debug"), default="auto"
|
||||
)
|
||||
parser.add_argument("--bind", default="0.0.0.0", help="UDP 监听地址")
|
||||
parser.add_argument("--port", type=int, default=5000, help="输入端口")
|
||||
parser.add_argument(
|
||||
"--discovery-port", type=int, default=5001, help="自动发现广播端口"
|
||||
)
|
||||
parser.add_argument("--allow", metavar="IP", help="只接受指定 PSVita IP")
|
||||
parser.add_argument(
|
||||
"--timeout-ms", type=int, default=300, help="断线释放按键时间"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_parser().parse_args()
|
||||
if not 1 <= args.port <= 65535 or not 1 <= args.discovery_port <= 65535:
|
||||
raise SystemExit("端口必须在 1 到 65535 之间")
|
||||
if not 50 <= args.timeout_ms <= 10000:
|
||||
raise SystemExit("--timeout-ms 必须在 50 到 10000 之间")
|
||||
try:
|
||||
backend = create_backend(args.backend)
|
||||
receiver = Receiver(
|
||||
backend=backend,
|
||||
bind=args.bind,
|
||||
port=args.port,
|
||||
discovery_port=args.discovery_port,
|
||||
allow=args.allow,
|
||||
timeout_ms=args.timeout_ms,
|
||||
)
|
||||
receiver.run()
|
||||
except KeyboardInterrupt:
|
||||
print("\n已停止")
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
MAGIC = b"VGPD"
|
||||
VERSION = 1
|
||||
PACKET_SIZE = 20
|
||||
DISCOVERY_MAGIC = b"VGPD_HOST_V1"
|
||||
PACKET_STRUCT = struct.Struct("!4sBBHIH6B")
|
||||
|
||||
|
||||
class Buttons:
|
||||
A = 1 << 0
|
||||
B = 1 << 1
|
||||
X = 1 << 2
|
||||
Y = 1 << 3
|
||||
LB = 1 << 4
|
||||
RB = 1 << 5
|
||||
BACK = 1 << 6
|
||||
START = 1 << 7
|
||||
L3 = 1 << 8
|
||||
R3 = 1 << 9
|
||||
UP = 1 << 10
|
||||
DOWN = 1 << 11
|
||||
LEFT = 1 << 12
|
||||
RIGHT = 1 << 13
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InputState:
|
||||
sequence: int = 0
|
||||
buttons: int = 0
|
||||
lx: int = 128
|
||||
ly: int = 128
|
||||
rx: int = 128
|
||||
ry: int = 128
|
||||
lt: int = 0
|
||||
rt: int = 0
|
||||
flags: int = 0
|
||||
|
||||
@classmethod
|
||||
def neutral(cls) -> "InputState":
|
||||
return cls()
|
||||
|
||||
|
||||
def decode_packet(data: bytes) -> InputState:
|
||||
if len(data) != PACKET_SIZE:
|
||||
raise ValueError(f"invalid packet size: {len(data)}")
|
||||
magic, version, flags, size, sequence, buttons, *axes = PACKET_STRUCT.unpack(data)
|
||||
if magic != MAGIC:
|
||||
raise ValueError("invalid packet magic")
|
||||
if version != VERSION:
|
||||
raise ValueError(f"unsupported protocol version: {version}")
|
||||
if size != PACKET_SIZE:
|
||||
raise ValueError(f"invalid declared packet size: {size}")
|
||||
return InputState(sequence, buttons, *axes, flags)
|
||||
|
||||
|
||||
def encode_packet(state: InputState) -> bytes:
|
||||
values = (state.lx, state.ly, state.rx, state.ry, state.lt, state.rt)
|
||||
if any(not 0 <= value <= 255 for value in values):
|
||||
raise ValueError("axis and trigger values must be between 0 and 255")
|
||||
return PACKET_STRUCT.pack(
|
||||
MAGIC,
|
||||
VERSION,
|
||||
state.flags,
|
||||
PACKET_SIZE,
|
||||
state.sequence & 0xFFFFFFFF,
|
||||
state.buttons & 0xFFFF,
|
||||
*values,
|
||||
)
|
||||
|
||||
|
||||
def sequence_is_newer(candidate: int, previous: int) -> bool:
|
||||
"""Compare wrapping unsigned 32-bit sequence numbers."""
|
||||
difference = (candidate - previous) & 0xFFFFFFFF
|
||||
return 0 < difference < 0x80000000
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from vitapad.backends import GamepadBackend
|
||||
from vitapad.protocol import (
|
||||
DISCOVERY_MAGIC,
|
||||
InputState,
|
||||
decode_packet,
|
||||
sequence_is_newer,
|
||||
)
|
||||
|
||||
|
||||
class Receiver:
|
||||
def __init__(
|
||||
self,
|
||||
backend: GamepadBackend,
|
||||
bind: str = "0.0.0.0",
|
||||
port: int = 5000,
|
||||
discovery_port: int = 5001,
|
||||
allow: str | None = None,
|
||||
timeout_ms: int = 300,
|
||||
) -> None:
|
||||
self.backend = backend
|
||||
self.bind = bind
|
||||
self.port = port
|
||||
self.discovery_port = discovery_port
|
||||
self.allow = allow
|
||||
self.timeout = timeout_ms / 1000.0
|
||||
self._stop = threading.Event()
|
||||
self._last_packet = 0.0
|
||||
self._connected_address: str | None = None
|
||||
self._last_sequence: int | None = None
|
||||
self._neutral_sent = True
|
||||
|
||||
def _broadcast_discovery(self) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as beacon:
|
||||
beacon.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
while not self._stop.wait(1.0):
|
||||
try:
|
||||
beacon.sendto(
|
||||
DISCOVERY_MAGIC, ("255.255.255.255", self.discovery_port)
|
||||
)
|
||||
except OSError:
|
||||
# Interfaces can disappear during sleep/network changes.
|
||||
pass
|
||||
|
||||
def run(self) -> None:
|
||||
discovery = threading.Thread(
|
||||
target=self._broadcast_discovery, name="discovery", daemon=True
|
||||
)
|
||||
discovery.start()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as listener:
|
||||
listener.bind((self.bind, self.port))
|
||||
listener.settimeout(0.05)
|
||||
print(
|
||||
f"正在监听 {self.bind}:{self.port},后端: {self.backend.name}\n"
|
||||
"等待 PSVita(Ctrl+C 退出)..."
|
||||
)
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
self._receive_once(listener)
|
||||
self._apply_failsafe()
|
||||
finally:
|
||||
self._stop.set()
|
||||
discovery.join(timeout=1.2)
|
||||
self.backend.close()
|
||||
|
||||
def _receive_once(self, listener: socket.socket) -> None:
|
||||
try:
|
||||
data, address = listener.recvfrom(256)
|
||||
except socket.timeout:
|
||||
return
|
||||
if self.allow and address[0] != self.allow:
|
||||
return
|
||||
try:
|
||||
state = decode_packet(data)
|
||||
except ValueError:
|
||||
return
|
||||
if self._connected_address != address[0]:
|
||||
self._connected_address = address[0]
|
||||
self._last_sequence = None
|
||||
print(f"已连接 PSVita: {address[0]}")
|
||||
if self._last_sequence is not None and not sequence_is_newer(
|
||||
state.sequence, self._last_sequence
|
||||
):
|
||||
return
|
||||
self._last_sequence = state.sequence
|
||||
self._last_packet = time.monotonic()
|
||||
self._neutral_sent = False
|
||||
self.backend.update(state)
|
||||
|
||||
def _apply_failsafe(self) -> None:
|
||||
if (
|
||||
not self._neutral_sent
|
||||
and time.monotonic() - self._last_packet >= self.timeout
|
||||
):
|
||||
self.backend.update(InputState.neutral())
|
||||
self._neutral_sent = True
|
||||
print("连接超时,已释放全部按键;等待重连...")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
Loading…
Reference in New Issue