feat: add Vita network gamepad

This commit is contained in:
zwt13703 2026-07-17 17:35:07 +08:00
commit 8a16d2e1f1
20 changed files with 1289 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
*.egg-info/
vita/build/
.DS_Store

21
LICENSE Normal file
View File

@ -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.

107
README.md Normal file
View File

@ -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
```

28
docs/protocol.md Normal file
View File

@ -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 | 左摇杆 X0255中心 128 |
| 15 | 1 | ly | 左摇杆 Y0255中心 128 |
| 16 | 1 | rx | 右摇杆 X0255中心 128 |
| 17 | 1 | ry | 右摇杆 Y0255中心 128 |
| 18 | 1 | lt | 左扳机0255 |
| 19 | 1 | rt | 右扳机0255 |
按键位从低到高依次为A、B、X、Y、LB、RB、Back、Start、L3、R3、Up、
Down、Left、Right第 14、15 位保留。
协议不包含鉴权或加密,设计用途是可信的家庭局域网。可在电脑端使用
`--allow <Vita IP>` 限制来源。

View File

@ -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 项自动化测试全部通过。当前环境未安装 VitaSDKVPK 需在配置 VitaSDK 的机器上完成编译macOS 创建虚拟 HID 需要管理员权限。
## 会话 ID: vita-gamepad-publish
- [2026-07-17 17:34:57]
- **执行原因**: 将 Vita Gamepad 项目发布到用户指定的 Git 远程仓库。
- **执行过程**:
1. 检查本地工作区、当前分支和提交历史。
2. 确认目标远程仓库为空,避免覆盖既有提交。
3. 提交项目全部源码、测试与文档。
4. 配置远程地址并推送 main 分支。
- **执行结果**: Vita Gamepad 项目已提交并推送至指定仓库。

23
pyproject.toml Normal file
View File

@ -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*"]

50
tests/test_protocol.py Normal file
View File

@ -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()

82
tests/test_receiver.py Normal file
View File

@ -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()

40
vita/CMakeLists.txt Normal file
View File

@ -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}
)

View File

@ -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>

341
vita/src/main.c Normal file
View File

@ -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(&params, 0, sizeof(params));
params.memory = net_memory;
params.size = sizeof(net_memory);
result = sceNetInit(&params);
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;
}

4
vitapad/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""Vita Gamepad desktop receiver."""
__version__ = "0.1.0"

4
vitapad/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from vitapad.cli import main
main()

View File

@ -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}")

28
vitapad/backends/debug.py Normal file
View File

@ -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()

181
vitapad/backends/macos.py Normal file
View File

@ -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

View File

@ -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

55
vitapad/cli.py Normal file
View File

@ -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()

79
vitapad/protocol.py Normal file
View File

@ -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

106
vitapad/receiver.py Normal file
View File

@ -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"
"等待 PSVitaCtrl+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()