Esodland/EyeToyPSVita
GitHub: Esodland/EyeToyPSVita
PS Vita / PS TV 自制软件项目,实现了基于摄像头的动作检测游戏引擎以及端到端的 PlayStation Move 手柄蓝牙驱动,并附带了详尽的硬件逆向文档。
Stars: 0 | Forks: 0
# EyeToy PS Vita — 以及适用于 PS Vita / PS TV 的可用 PS Move 驱动
PS Vita / PS TV Homebrew:一个基于摄像头动作捕捉的游戏引擎,
秉承 *EyeToy: Play* 的理念,完全 *from scratch*(从零开始)编写 —— 外加对
原版 PS2 游戏的研究。
个人学习项目。发布以下内容是因为**多处硬件探测结果在其他地方从未有过记录**,并且
可能会对其他人有所帮助,特别是关于 PS Move 和 Vita 的 Bluetooth 的部分。
## 状态
| 阶段 | 主题 | 状态 |
|---|---|---|
| **1** | 前置摄像头的动作捕捉 | **已完成**,在主机上验证通过 |
| **2.1** | USB Host 内核插件 (PS TV) | 待办 |
| **2.2** | PS Eye 驱动 (OV534) | 待办 |
| **2.3** | PS Move | **可用** — 配对,HID 数据流,传感器以 g 和 °/s 为单位 |
| **3** | *EyeToy: Play* (PS2) 的逆向工程 | 待办 |
**阶段 1** 以稳定的 60 fps 运行:640×480 的摄像头画面以零拷贝(zero-copy)方式显示
(YUV 转换由 GPU 完成),在 160×120 的网格上进行动作检测耗时约
3 ms,具有自适应环境噪声阈值,以及一个“Bubble Pop”游戏原型。
**阶段 2.3 — PS Move 手柄在 PS Vita 上实现了端到端的运行**,据我们所知,
这是前所未有的:
- 取得了 Bluetooth 配对,随后**由 `SceBt` 自身进行记录** —— 此后
连接无需任何 hook 即可成功;
- 完整的 HID 数据流:178 字节的 descriptor,连续接收
input report `0x01`,sphere(光球)和振动器可控;
- 传感器已解码并在**主机上通过物理学验证**,而不仅仅是在
PC 上(见下表);
- **解码了出厂校准**(*feature* report `0x10`):手柄提供的是
**g** 和 **°/s**,而不是任意单位;
- 一个暴露给应用程序的 API —— `psmove_lire_etat`、
`psmove_couleur_sphere`、`psmove_vibreur`、`psmove_lire_calibration`、
`psmove_remesurer_zeros` —— 提供整合后的按键、扳机、电池、
加速度计、陀螺仪和磁力计数据;
- 手柄的地址是在**运行时**从主机上的一个文本文件中读取的:
使用一个已编译好的、自带手柄地址的 `.skprx` 不需要
VitaSDK。
这方面剩下的工作:在摄像头画面中对光球进行追踪,这将最终使项目的两
部分交汇。
## 可能对其他人有帮助的内容
### PS Move 手柄的 HID 协议
完全通过测量记录,未借用任何现有文档 — —
参见 **[`docs/psmove_protocole.md`](docs/psmove_protocole.md)**。
- Input report `0x01`(49 字节):按键、trigger、加速度计、
陀螺仪、磁力计、电池、时间戳。
- Output report `0x02`:光球的 RGB 颜色和振动器。
- *Feature* report `0x04` / `0x05`:在 vendor collection `0xff02` 上读取和
**写入**已配对主机的地址。
- 传感器以 **120 Hz** 采样(每个 report 包含两组测量数据)。
- **USB 不会传输任何数据**:所有数据流都通过 Bluetooth。因此,
配对是不可或缺的,没有任何线缆可以绕过它。
- ⚠️ 字节 `[4]` 混合了两个按键(第 7-6 位)和一个**序列计数器**
(第 3-0 位)。直接读取原始数据会在每次递增时产生幽灵按压。
验证方法:与其盲目相信假定的位拆分,不如验证一个**物理不变量** ——
在静止状态下,加速度计矢量的模长必须为 1 g,而磁力计矢量的模长必须为地磁场强度。
在 **0.25%** 和 **0.65%** 的误差下测量保持稳定,这绝非错误的字节分组
所能产生的结果。正是这个测试揭示了磁力计的 Y 轴打包方式与
另外两个轴完全相反。
同样的测试在 **Vita** 上,跨越整个 Bluetooth 链路,手柄保持静止的状态下重新进行了
—— 并且给出了相同的结论:
| 物理量 | 在 PC 上 | 在 Vita 上 |
|---|---|---|
| 加速度模长稳定性 | 0.25% | **0.30%** |
| 磁力计模长稳定性 | 0.65% | **1.17%** |
| 静止状态下的陀螺仪零偏 | (−23, +55, −3) | **(−26, +53, +7)** |
在两个完全不同的软件栈上,精确到单位地重新找出了该传感器的特定硬件偏差,这为解码问题画上了句号。
### 出厂校准 — *feature* report `0x10`
完全通过测量进行解码([协议的第 8 节](docs/psmove_protocole.md)),
正是它将整数转换为 g 和 °/s。
- **三个 49 字节的数据块**,以**轮询方式**提供:连续两次读取
会得到 `00 01 82`,随后是 `01 82 00`。根据字节 `[1]` 进行排序,其
第 7 位标记了最后一个数据块。按照到达顺序拼接会产生
混合的 payload,而且没有任何标识指出这一点。
- 141 个连续字节:六个加速度计矢量(每个面对应一个)、静止状态下的陀螺仪、
以及标准旋转状态、温度,接着是**八个作用尚未确定的浮点数** ——
如实标记,而不是生搬硬套某种解释。
- 标准速率为 **480 °/s**(80 转/分)。单位是通过
饱和度来确定的:如果单位是十分之一度,满量程将降至 330 °/s,
手柄稍微一动就会达到饱和。
- ❌ **无法从 Vita 读取此 report。** 带有
类型 2 和 3 的 `ksceBtHidTransfer` 接受了请求,发出了一个 `0x0C` 事件 ——
在其他情况下从未见过 —— 而且**从不写入缓冲区**。收到回执并不代表
有了响应。因此,校准只能在 PC 上通过 USB 获取一次。
**最有用的结果是由测量强加的一种划分:**
| | 结论 |
|---|---|
| **出厂增益** | 良好 —— 在超定测试中残差为 0.57% |
| **出厂零点** | 已失效 —— 需要在运行时重新测量 |
陀螺仪毫不含糊地证明了这一点,将手柄平放即可直接读出其零点:
该数据块显示为 (−27, −125, +105),PC 测量值为 (−23, +55, −3),而
Vita 为 (−24, +52, +6)。两次独立的测量相互吻合,但
两者都偏离了数据块。公认的合理解释 —— **漂移**:比例因子在
时间内是稳定的,而 offset 则不然,何况这个手柄已经有十四年的历史了。
因此,插件的工作原理是:**增益来自出厂设置,零点在运行中测量**。
陀螺仪在静止一秒钟后即可归零;
加速度计则需要将每个轴在两个方向上分别呈现,其零点位于
两个极值的中点。获取到的零点保存在
`ux0:data/psmove_zeros.txt` 中,并在下次启动时重新读取。
在十二个姿态下测得的效果:模长范围 **10.84% → 1.72%**,平均值
**0.9996**。该平均值验证了 *增益* —— 调整后的零点缩小了范围
但并未重新修正中心。
⚠️ **覆盖率安全限制必须严格收紧。** 第一个版本允许 10% 的偏差:
一个轴的增益达到了 90.4%,而其中一个方向从未被呈现过,
发布了一个带有 300 个单位误差的虚假零点,表面看起来却很好。采用的阈值
是 **98%**,这是可以论证的:静止时没有任何一个分量
超过 1 g,因此要求半振幅 ≥ 98% 会迫使两个极值
都超过 96%。*过于宽松的限制只会起到安抚作用,而不能提供保护。*
### PS Vita 的 Bluetooth (`SceBt`, kernel)
- **`mac0`/`mac1` 上的地址拆分**:48 位地址被切成
两部分 —— `mac1` = 高 16 位,`mac0` = 低 32 位。
文档中未记载,但缺了它什么都无法运行。
- **事件表**:`0x01` 搜索结果,`0x02` 搜索结束,
`0x04` 请求 link key,`0x05` 连接被接受,`0x06`
断开连接,`0x08` 请求连接,`0x09` 未经配对的连接请求,`0x0A`–`0x0C` HID 响应。
- **`ksceBtReadEvent` 不会阻塞**:当无事发生时,它会返回成功
并带有一个空事件。一个简单的 naïve 循环会使主机死机。
- **一个 callback 属于注册它的线程。** 如果在其他地方注册,
`ksceBtRegisterCallback` 会返回 `0`,但随后的每次读取都会失败并返回
`CB_NOT_REGISTERED` —— 这种无声的故障表现得就像
设备不存在一样。
- **正在进行的 inquiry 会阻止 `ksceBtStartConnect`** (`CONNECT_START_BUSY`)。
- **主机的 Bluetooth 地址等于其 Wi-Fi MAC + 1**,没有任何 API 能在
主机上获取它:必须从外部读取。
- **配对数据库位于 registry 中**,
即 `/CONFIG/BT/NN/info`,`ksceRegMgrSetKeyBin` 可向其写入 —— 即使在读取被拒绝时也是如此。**但是千万不要**
**使用这种方法**,请看紧接着的下文。
### ⚠️ 让我们白白浪费了两天时间的陷阱
自己手动构造一个配对条目**表面上看起来可行,但实际上会彻底破坏一切**。
固件接受了它,手柄出现在了 Bluetooth 设置中,连接得以建立 —— 但之后什么也
无法正常工作。
因为此时 SceBt 看到的是一个“已知设备”,它会**跳过
SDP 查询**,导致既没有 descriptor 也没有 HID 通道。所有的这些
症状,全都是由这一个条目引起的:
- `ksceBtHidTransfer` 返回 **`HID_NO_CAP`**;
- `ksceBtHidGetReportDescriptor` 返回 0 且缓冲区为空;
- `ksceBtGetVidPid` 返回 **`0006:0001`** 而不是 `054C:03D5`;
- 配对条目显示为 **`vid=0000 pid=0000`**;
- interrupt 通道 `0x13` 不存在,control 通道 `0x11` 容量为零,
而 vendor API `ksceBtHidVu*` 返回 **`0x802F4006`** / **`0x802F4007`**。
**解决方法全靠一个调用:`ksceBtDeleteRegisteredInfo(mac0, mac1)`。**
这五个症状随之消失。SDP 得以执行,真实身份显现,
178 字节的 descriptor 被成功读取,input report 随之开始涌入。
推论:不要试图通过 `taiInjectDataForKernel` 来绕过那些检测
HID 容量缺失的校验。我们试过,它掩盖了症状但并未修复任何问题
—— 并且我们在 `SceBt+0x12DA6` 处移除大小检查的 patch 引入了一个
**主机上的内核缓冲区溢出漏洞,这影响了主机上所有**
Bluetooth 设备的共同调用路径。
### 首次配对真正必需的条件
- 通过 USB 使用 *feature* report `0x05`(文档的第 4 节),
将主机的 Bluetooth 地址写入手柄。Vita 的地址等于其
**Wi-Fi MAC + 1**。
- 两个针对 `SceBt` 的 taiHEN hook,借鉴自 `ds3vita` / `ds4vita`
(`SceBt+0x199C8` 和 `SceBt+0x147E4`),否则连接会被拒绝并返回
`NO_REG`。这些 offset 专属于固件 3.60。
- **一旦取得了合法的配对,这些 hook 就不再需要了**:SceBt
会自行记录设备,没有它们连接也能成功。因此,
只要不存在有效条目,插件就会安装它们,这会自动关闭主机处于
宽松状态的窗口期。
- 位于 `SceBt0x147E4` 处函数的第三个参数是
**设备的 Bluetooth 地址**(高位为 `mac1`),这就允许将绕过行为限制在
特定的手柄上,而不是接受任何传入连接 —— 原版就是这样做的。
### 从应用程序中调用内核插件
- 应用程序可以在运行时加载 `.skprx`
(`taiLoadStartKernelModuleForUser`),**但在同一会话中将无法调用其导出的
任何内容**:应用程序的导入是在进程启动时解析的。
调用未解析的 symbol 会导致应用程序崩溃 (`psp2dmp`)。
- **weak linkage 无法解决这种情况**:即使模块确实已驻留,
`lib*_stub_weak.a` 中的 symbol 仍然保持为 null。
- 可靠的信号是**加载的返回代码**:
`SCE_KERNEL_ERROR_MODULEMGR_OLD_LIB` (**`0x8002D013`**) 表示该
库已注册,因此该模块先于进程存在,
所以 API 是可用的。
- 加载内核模块需要 eboot 为 **`UNSAFE`** 属性:否则 taiHEN 会返回
`TAI_ERROR_NOT_ALLOWED` (**`0x90010009`**)。
- ⚠️ **`taiLoadStartKernelModule` 无法读取 `app0:`** —— 它返回
**`0x80010013`** (ENODEV)。事后想想这很符合逻辑:`app0:` 是专属于
调用进程的挂载点,内核端完全没有理由知道如何解析它。因此,一个 VPK **可以**
携带自己的插件,但应用程序必须先将其**复制**到
`ux0:data/` —— 它有权读取 `app0:`,而内核则没有。这就是
`src/drivers/psmove_plugin.cpp` 所做的操作,它将插件重写为一个独立的名称,
并且每次启动都会重新写入,以便 VPK 的更新始终能被考虑在内,而
永远不会覆盖开发版本。
- 实际操作中的必然结果:**由于内核模块永远不会被卸载,新的
`.skprx` 只有在重启后才会生效。** 覆盖文件并不能替换内存中的模块
—— 这时你以为在测试新二进制文件,实际上测试的还是旧的。
- 内核插件的线程**寿命长于加载它的应用程序**。因此它
即使在没有任何程序打开时也能提供服务:在这里,它会检测一个
标记文件并调用 `kscePowerRequestColdReset()`,这样可以实现重启的
自动化,**而无需启动任何其他程序**。
### Vita 的前置摄像头
- 640×480 的 `YUV422_PACKED` 格式:实际的字节顺序是 **YVYU**
(`SCE_GXM_TEXTURE_FORMAT_YVYU422_CSC0`)。
- 摄像头缓冲区位于**非缓存**内存中:CPU 进行 YUV → RGBA 转换的代价
是 **113 ms 每 frame**。解决方法是让 GPU 通过 `sceGxmMapMemory` 读取缓冲区
—— 零拷贝,零 CPU 开销。
- 图像设置(对比度、亮度、清晰度)**会被接受但没有实际效果**,
并且 `sceCameraGetGain` 会失败。只有曝光起作用。
## 构建
前提条件:[VitaSDK](https://vitasdk.org/),CMake ≥ 3.16,Ninja。
```
cmake -B build -S . -G Ninja
cmake --build build
```
VPK 输出在 `build/EyeToyPSVita.vpk` 中,并且**它内置了内核插件**:
只需安装它即可,无需通过 FTP 发送 `.skprx`。应用程序会首
先在 `ux0:data/` 中查找 —— `deploy.ps1` 会将正在开发的版本放在这里 —— 然后查找 `app0:`,这是正常安装
后的路径。
内核插件需单独构建 (`plugin/`),并且在应用程序**之前**构建:
它既会生成应用程序嵌入的 `.skprx`,也会生成应用程序所链接的
导入库 (`plugin/build/stubs/`)。
```
cmake -B plugin/build -S plugin -G Ninja
cmake --build plugin/build
```
**手柄的地址无需在编译时知晓**:插件会在启动时读取主机上的
`ux0:data/psmove_adresse.txt` —— 类似于
`00:06:F7:11:22:33` 这样的一行。根目录下的 `.env` 可以提供一个备选值,
但它是可选的。
⚠️ **插件没有列在 `config.txt` 中**:由应用程序负责加载,
这可以避免有缺陷的模块导致主机进入 bootloop —— 我们
已经遇到过两次这种情况。相关的代价如前文所述:重启后,
第一次启动会装载插件,第二次启动才会使用它。
### 开发周期
`tools/deploy.ps1` 依次执行 构建 → 关闭 → 发送 → 启动,无需经过
VPK 的安装过程。加上 `-Reboot` 参数,它还会重启主机并
连续执行两次必要的启动 —— 只要 `.skprx` 发生了改变,这就是
**必须的**,否则你测试的还是旧模块。
```
.\tools\deploy.ps1 # application seule
.\tools\deploy.ps1 -Reboot # après une modification du plugin
```
在游戏中,手柄上有两个命令:**按住 SELECT 键 1 秒** 会清除零点并
重新启动校准向导,**按住 START 键 2 秒** 会重启主机
(松开则取消)。
## 组织结构
```
src/ moteur : caméra, détection de mouvement, rendu, jeu
plugin/ plugin kernel .skprx (Bluetooth PS Move)
plugin/psmove_api.h contrat partagé noyau <-> application
tools/ sondes et scripts d'analyse (Python, PowerShell)
docs/ relevés de protocole
re/ reverse engineering d'EyeToy: Play (Voie B)
```
主机上所需的文件:
| 路径 | 作用 | 写入者 |
|---|---|---|
| `ux0:data/psmove_adresse.txt` | 手柄的 Bluetooth 地址,单独一行 | **您** |
| `ux0:data/psmove_calib.bin` | 出厂校准,通过 PC 上的 USB 获取 | 您 |
| `ux0:data/psmove_zeros.txt` | 测量到的零点,可在不同会话间复用 | 插件 |
| `ux0:data/psmove_bt.log` | 插件日志,上限 400 行 | 插件 |
| `ux0:data/psmove_bt_app.skprx` | 嵌入的插件副本,每次启动都会重写 | 应用程序 |
| `ux0:data/psmove_bt.skprx` | 开发版插件,优先级高于前者 | `deploy.ps1` |
**只有第一个是必需的** —— 插件会随 VPK 一起发布。如果没有
校准,传感器将保持原始状态;如果没有零点,它们将在屏幕向导的
指引下在大约三十秒内重新测量完毕。要生成校准文件,请使用 USB 线连接到 PC:
```
python tools/psmove_hid.py calibration usb # -> captures/psmove_calib.bin
```
零点文件除了包含零点外,还包含增益,**作为手柄的指纹**:
如果插上另一个手柄,在校准文件名称不变的情况下会改变校准结果,
而且在其他地方测量的零点如果不加提示直接使用将会是错误的。
`CLAUDE.md` 包含了详细的工作笔记:测得的 frame 预算、
遇到的陷阱以及技术决策的历史记录。
## 方法论
项目依靠测量来推进,而不是依靠直觉。走不通的死胡同与
成功的方法被同等记录 —— 一个负面结果连同尝试过的内容,可以避免
其他人重蹈覆辙。
几条非常有用的原则:
1. **在未知中寻找已知。** 如果去追踪两个已经在其他地方确立的数值,解码
一个格式的速度要比盲目猜测其结构快得多。
2. **在构建理论之前先记录返回代码。** 有好几次,被认为是硬件导致的
沉默,实际上是因为一个失败的调用没有被记录下来。
3. **绝对不要用推导出模型的数据来验证模型本身。** 仅仅因为
这个陷阱,校准工作就连续遭遇了三次虚假的成功:一个
0.012% 的极小范围其实什么也没测量到,因为独立变量在其中
的权重只有 5·10⁻⁵ ;一个在六个姿态上进行六参数拟合的模型,在构造上
本身就是精确的;还有一个探针,使用它
希望读到的响应预填充了它自己的缓冲区。**计算自由度,并在
完全不相关的数据集上进行交叉验证。**
4. **检查测量是否在假定的条件下进行的。** 一个结论 —— “出厂
偏差导致性能下降” —— 被证明与事实完全相反,因为它来自于*在运动*
状态下采集的姿态,而在运动状态下,加速度的模长根本没有
理由等于 1 g。
5. **当预期行为无法理解时,你缺的是一块仪表盘,而不是一句话。** 手动
校准从一个晦涩的练习变成了只需三十秒的操作,这一切就发生在有两根进度条
直接显示对齐和静止状态的那一天。
## 许可证和内容
专为该项目编写的代码。此处未对任何来自原版游戏的素材、二进制文件或反编译代码
进行版本控制 —— 逆向工程部分仅包含
笔记和重写的代码。
标签:PS Vita, 外设逆向工程, 嵌入式开发, 摄像头动作检测, 游戏开发, 自制软件, 蓝牙驱动