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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ There is no console window, so a panic writes `frametap-panic.txt` next to the e

If the exe cannot write to its own folder, the file goes to your temp directory.

## Probing a controller that is not supported yet

`hid_probe.exe`, attached to every release, reads a device and prints the raw reports. Use it to find out whether an unsupported pad speaks a format this tool could decode.

```
hid_probe.exe --list list every connected HID device
hid_probe.exe --vid 0x0F0D --pid 0x0084 open one of them and dump its reports
```

`--list` only enumerates; it never opens a device, so it will not fight with a game over one. The dump prints the first three reports as hex and the min, median and max interval between 300 reports.

Open an issue with that output and the controller's name.

## Limitations

- Windows only. The HID and timing code has no other implementation
Expand Down
256 changes: 221 additions & 35 deletions src/bin/hid_probe.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,164 @@
//! DualSense / DualShock 4 の HID を読み取り専用で開けるかを実機で確かめる調査用バイナリ。
//! HID を読み取り専用で開けるかを実機で確かめる調査用バイナリ。
//!
//! 設計 spec の「未検証の前提」のうち、Steam 起動中に物理 HID を開けること、
//! ブロッキング read でレポートが届くこと、レポート間隔が 4ms 付近に収まることを観測する。
//! output report と feature report は一切送らない。Steam と feature report を取り合うと
//! 別のレポートのデータが返るため、書き込み系の API はここでは呼ばない。
//!
//! 引数なしなら Sony の既知の機種を探す。`--list` は繋がっている HID を全部並べ、
//! `--vid` と `--pid` は指定した 1 台を開く。対応していない機種のレポートを読むために要る。

mod args {
//! 引数の解析。[`probe`] の外に置くのは、Windows 以外でも test を走らせるため。

/// 引数を付けないときに探す機種の説明。
pub const USAGE: &str = "\
使い方:
hid_probe Sony の既知の機種を探して 1 台開く
hid_probe --list 繋がっている HID を全部並べる (開かない)
hid_probe --vid <16進> --pid <16進> 指定した 1 台を開く

例:
hid_probe --vid 0x0F0D --pid 0x0084";

/// 何をするか。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
/// Sony の既知の機種を探して開く。
KnownDevice,
/// 繋がっている HID を並べるだけ。
List,
/// 指定した 1 台を開く。
Open { vendor_id: u16, product_id: u16 },
}

/// `argv[0]` を除いた引数を読む。
pub fn parse(args: &[String]) -> Result<Command, String> {
let mut vendor_id = None;
let mut product_id = None;
let mut list = false;
let mut rest = args.iter();

while let Some(arg) = rest.next() {
match arg.as_str() {
"--list" => list = true,
"--vid" => vendor_id = Some(hex(&mut rest, "--vid")?),
"--pid" => product_id = Some(hex(&mut rest, "--pid")?),
other => return Err(format!("知らない引数: {other}")),
}
}

match (list, vendor_id, product_id) {
(true, None, None) => Ok(Command::List),
// --list と VID/PID を同時に受けると、並べたのか開いたのかが出力から読めなくなる。
(true, _, _) => Err("--list と --vid や --pid は一緒に使えない".to_owned()),
(false, None, None) => Ok(Command::KnownDevice),
(false, Some(vendor_id), Some(product_id)) => Ok(Command::Open {
vendor_id,
product_id,
}),
// 片方だけを受けると、同じ VID の別のデバイスを開いたときに何を読んだのか分からない。
(false, Some(_), None) => Err("--vid には --pid も要る".to_owned()),
(false, None, Some(_)) => Err("--pid には --vid も要る".to_owned()),
}
}

/// 次の引数を 16 進として読む。`0x` は付いていても付いていなくてもよい。
fn hex<'a>(rest: &mut impl Iterator<Item = &'a String>, name: &str) -> Result<u16, String> {
let value = rest.next().ok_or_else(|| format!("{name} に値が無い"))?;
let digits = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.unwrap_or(value);

u16::from_str_radix(digits, 16)
.map_err(|_| format!("{name} の {value} を 16 進として読めない"))
}

#[cfg(test)]
mod tests {
use super::*;

fn parse_args(args: &[&str]) -> Result<Command, String> {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
parse(&owned)
}

#[test]
fn no_argument_looks_for_the_known_devices() {
assert_eq!(parse_args(&[]), Ok(Command::KnownDevice));
}

#[test]
fn list_only_lists() {
assert_eq!(parse_args(&["--list"]), Ok(Command::List));
}

#[test]
fn a_vid_and_a_pid_open_that_device() {
let expected = Ok(Command::Open {
vendor_id: 0x0F0D,
product_id: 0x0084,
});

assert_eq!(
parse_args(&["--vid", "0x0F0D", "--pid", "0x0084"]),
expected
);
// 0x は省ける。大文字と小文字も区別しない。
assert_eq!(parse_args(&["--vid", "0f0d", "--pid", "84"]), expected);
// 順番は問わない。
assert_eq!(
parse_args(&["--pid", "0x0084", "--vid", "0x0F0D"]),
expected
);
}

#[test]
fn one_of_the_two_is_refused() {
assert!(parse_args(&["--vid", "0x0F0D"]).is_err());
assert!(parse_args(&["--pid", "0x0084"]).is_err());
}

#[test]
fn list_does_not_combine_with_an_address() {
assert!(parse_args(&["--list", "--vid", "0x0F0D", "--pid", "0x0084"]).is_err());
}

#[test]
fn a_value_that_is_not_hexadecimal_is_refused() {
let err = parse_args(&["--vid", "ZZZZ", "--pid", "0x0084"]).unwrap_err();

assert!(err.contains("ZZZZ"), "{err}");
}

/// 16 進で 4 桁を超える値は u16 に入らない。
#[test]
fn a_value_wider_than_u16_is_refused() {
assert!(parse_args(&["--vid", "0x10F0D", "--pid", "0x0084"]).is_err());
}

#[test]
fn a_missing_value_is_refused() {
let err = parse_args(&["--vid"]).unwrap_err();

assert!(err.contains("--vid"), "{err}");
}

#[test]
fn an_unknown_argument_is_refused() {
let err = parse_args(&["--all"]).unwrap_err();

assert!(err.contains("--all"), "{err}");
}
}
}

#[cfg(windows)]
mod probe {
use hidapi::{DeviceInfo, HidApi, HidDevice};

use crate::args::Command;
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};

/// Sony Interactive Entertainment。
Expand Down Expand Up @@ -36,56 +187,72 @@ mod probe {
/// `read_timeout` に渡すミリ秒。負値はレポート到着までブロックする。
const BLOCK_UNTIL_REPORT: i32 = -1;

pub fn run() -> Result<(), String> {
pub fn run(command: Command) -> Result<(), String> {
let api = HidApi::new().map_err(|err| format!("HidApi の初期化に失敗した: {err}"))?;

let sony_devices: Vec<&DeviceInfo> = api
.device_list()
.filter(|info| info.vendor_id() == SONY_VENDOR_ID)
.collect();

print_sony_devices(&sony_devices);
match command {
Command::List => {
list_devices(&api);
Ok(())
}
Command::KnownDevice => {
let sony_devices: Vec<&DeviceInfo> = api
.device_list()
.filter(|info| info.vendor_id() == SONY_VENDOR_ID)
.collect();

print_devices(&format!("Sony (VID {SONY_VENDOR_ID:#06X})"), &sony_devices);

let target = sony_devices
.iter()
.find(|info| product_name(info.product_id()).is_some())
.ok_or_else(|| {
format!("対象デバイスが見つからない (VID {SONY_VENDOR_ID:#06X} の既知 PID なし)")
})?;

open_and_read(&api, target.vendor_id(), target.product_id())
}
Command::Open {
vendor_id,
product_id,
} => open_and_read(&api, vendor_id, product_id),
}
}

let target = sony_devices
.iter()
.find(|info| product_name(info.product_id()).is_some())
.ok_or_else(|| {
format!("対象デバイスが見つからない (VID {SONY_VENDOR_ID:#06X} の既知 PID なし)")
})?;
/// 繋がっている HID を全部並べる。開かない。開くと他のアプリと device を取り合う。
fn list_devices(api: &HidApi) {
let devices: Vec<&DeviceInfo> = api.device_list().collect();
print_devices("繋がっている HID", &devices);
}

/// VID/PID に一致する最初のデバイスを開いて読む。同一 VID/PID が複数並ぶ場合に
/// どれが開くかは hidapi の列挙順に従う。一覧と突き合わせて確かめる。
fn open_and_read(api: &HidApi, vendor_id: u16, product_id: u16) -> Result<(), String> {
println!(
"\n開く: {} (VID {:#06X} PID {:#06X})",
product_name(target.product_id()).unwrap_or("unknown"),
target.vendor_id(),
target.product_id(),
"\n開く: {} (VID {vendor_id:#06X} PID {product_id:#06X})",
product_name(product_id).unwrap_or("未知の PID"),
);

// VID/PID に一致する最初のデバイスを開く。同一 VID/PID が複数並ぶ場合にどれが開くかは
// hidapi の列挙順に従う。上の一覧と突き合わせて確かめる。
let device = api
.open(target.vendor_id(), target.product_id())
.map_err(|err| err.to_string())?;
let device = api.open(vendor_id, product_id).map_err(|err| {
format!("VID {vendor_id:#06X} PID {product_id:#06X} を開けない: {err}")
})?;

let reception_ticks = read_reports(&device)?;
print_interval_stats(&reception_ticks)?;

Ok(())
print_interval_stats(&reception_ticks)
}

fn print_sony_devices(devices: &[&DeviceInfo]) {
fn print_devices(label: &str, devices: &[&DeviceInfo]) {
if devices.is_empty() {
println!("Sony (VID {SONY_VENDOR_ID:#06X}) のデバイスは見つからなかった");
println!("{label} のデバイスは見つからなかった");
return;
}

println!(
"Sony (VID {SONY_VENDOR_ID:#06X}) のデバイス {} 件",
devices.len()
);
println!("{label} のデバイス {} 件", devices.len());
for info in devices {
let identified = product_name(info.product_id()).unwrap_or("未知の PID");
println!(
" PID {:#06X} [{}] usage_page={:#06X} usage={:#06X} interface={} product={:?} manufacturer={:?}",
" VID {:#06X} PID {:#06X} [{}] usage_page={:#06X} usage={:#06X} interface={} product={:?} manufacturer={:?}",
info.vendor_id(),
info.product_id(),
identified,
info.usage_page(),
Expand Down Expand Up @@ -200,13 +367,32 @@ mod probe {
}
}

/// 引数を読む。読めなければ使い方を出して終了コード 2 で終わる。
/// 1 は実行時の失敗に取ってあるので、指定の誤りと読み分けられるようにする。
fn command() -> args::Command {
let argv: Vec<String> = std::env::args().skip(1).collect();

match args::parse(&argv) {
Ok(command) => command,
Err(message) => {
eprintln!("{message}\n\n{}", args::USAGE);
std::process::exit(2);
}
}
}

#[cfg(windows)]
fn main() {
if let Err(message) = probe::run() {
if let Err(message) = probe::run(command()) {
eprintln!("{message}");
std::process::exit(1);
}
}

#[cfg(not(windows))]
fn main() {}
fn main() {
// 引数の誤りは Windows 以外でも同じように断る。HID の読み取りだけが Windows に依る。
let _ = command();
eprintln!("HID の読み取りは Windows でのみ動く");
std::process::exit(1);
}
Loading