diff --git a/Cargo.toml b/Cargo.toml index db21efe..a89fc77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "frametap" version = "0.1.2" edition = "2021" +license = "MIT" +description = "Frame-by-frame controller input display for Windows" +repository = "https://github.com/torabit/frametap" [dependencies] bitflags = "2.13.2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c989580 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 torabit + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0334de6 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# frametap + +Read your own controller inputs frame by frame, next to the game. + +![The input list, showing held inputs with the frame each one started and how long it lasted](docs/images/window.png) + +## What it is + +A small always-on-top window that lists what you pressed and for how long, counted in frames. It reads the controller directly over HID, so it works with any game. It never writes to the controller. + +It was built for one question: *was that attack 1 frame late, or did the game drop it?* The list answers the first half. Each row carries the frame the input started and the number of frames it was held, both to one decimal place. + +## What you need + +- Windows 10 or 11 +- A DualSense, DualSense Edge, or DualShock 4, over USB or Bluetooth +- Nothing else. No driver, no runtime, no Visual C++ redistributable + +## Install + +Download the latest [release](https://github.com/torabit/frametap/releases), put `frametap.exe` and `frametap.toml` in the same folder, and run the exe. + +The exe is unsigned, so SmartScreen will warn on first run. Choose **More info** and then **Run anyway**. If your antivirus quarantines it, please open an issue with a screenshot rather than adding an exclusion. + +## Reading the list + +``` +frametap 0.1.2 connected: DualSense +device clock: 0.3274 us/tick +USB reports every 4ms, so each frame count carries +/-0.24F of error +counted on a private 60fps grid, may differ from the game by up to 1F + +input press hold +LS8 0.0 1.0 +LS8 Circle 1.0 2.0 +LS8 3.0 15.0 +LS8 R1 18.0 2.0 +``` + +| Column | Meaning | +| --- | --- | +| `input` | Everything held during that interval, in the order it was pressed | +| `press` | Frames since the first press of the attempt | +| `hold` | How long that exact combination lasted. Capped at `99+` | + +A new row appears only when the set of held inputs changes. Holding a direction for two seconds is one row whose `hold` keeps growing, not a hundred rows. + +Directions use numpad notation. `DP8` is d-pad up, `LS8` is left stick up, `LS6` is right, `LS2` is down. The prefix separates the d-pad from the stick, because the game sees both but you may want to know which one you used. + +A horizontal rule separates attempts. An attempt ends after 300ms with nothing held. + +The window keeps recording while it is completely covered by another window, so you can leave it behind the game and read it afterwards. + +A row with a grey background means a report was dropped between it and the row above. The gap is real and the tool will not interpolate across it, so treat that interval's timing as unknown. + +## The frame count is not the game's frame count + +The tool samples the controller at 250Hz and divides elapsed time by a 60fps grid of its own. The game reads the pad once per frame, at an instant nothing outside the game can observe. Those two grids are not in phase. + +A number here can therefore be one frame away from what the game counted. Two inputs 10ms apart may land in the same game frame or in adjacent ones, and this tool cannot tell you which. That is why the counts carry a decimal: `3.0` and `3.4` are different measurements even though both would print as `3` in a game's own input display. + +## Configuration + +`frametap.toml` sits next to the exe. It is optional; without it the defaults apply. Anything the tool cannot accept is reported in red at the top of the window rather than silently ignored. + +| Key | Default | Meaning | +| --- | --- | --- | +| `fps` | `60` | The frame rate the counts are expressed in | +| `trial_gap_ms` | `300` | Idle time that ends an attempt | +| `stick_deadzone` | `0.5` | Normalized distance before the stick counts as a direction | +| `trials_shown` | `5` | How many attempts to keep on screen | +| `show_released` | `false` | Also list the intervals where nothing was held | + +## When it crashes + +There is no console window, so a panic writes `frametap-panic.txt` next to the exe instead, appending rather than overwriting. Please attach that file to an issue. + +If the exe cannot write to its own folder, the file goes to your temp directory. + +## Limitations + +- Windows only. The HID and timing code has no other implementation +- It opens the controller read-only and never sends output or feature reports, so it cannot fight with Steam Input over the device +- The byte layout for DualShock 4 is taken from Linux's `hid-playstation` and has not been checked against real hardware. DualSense has been + +## Building + +``` +cargo build --release +``` + +Requires a Rust toolchain. `cargo run --example demo` opens the window with synthetic input and no controller attached, which is useful for working on the display itself. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/docs/images/window.png b/docs/images/window.png new file mode 100755 index 0000000..63e03f4 Binary files /dev/null and b/docs/images/window.png differ diff --git a/examples/demo.rs b/examples/demo.rs new file mode 100644 index 0000000..06208a7 --- /dev/null +++ b/examples/demo.rs @@ -0,0 +1,123 @@ +//! 実機とデバイスを使わずに画面を出す。 +//! +//! README に載せる画像と、表示の手直しを Windows へ渡す前に見るために使う。合成した +//! 入力を [`History`] に積み、実際に配るものと同じ [`frametap::ui::show`] を呼ぶ。 +//! +//! ``` +//! cargo run --example demo +//! ``` + +use std::sync::{Arc, Mutex}; + +use eframe::egui::ViewportBuilder; +use frametap::history::{History, DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US}; +use frametap::report_decode::{Buttons, Direction}; +use frametap::timeline::{EventKind, InputEvent, Target}; +use frametap::ui::{self, Settings, Status}; + +/// 60fps の 1F。 +const FRAME_US: u64 = 16_667; + +fn press(target: Target, at_us: u64) -> InputEvent { + InputEvent { + kind: EventKind::Press, + target, + at_us, + gap_before: false, + } +} + +fn release(target: Target, at_us: u64) -> InputEvent { + InputEvent { + kind: EventKind::Release, + ..press(target, at_us) + } +} + +/// ローリングの直後に攻撃を入れた形を 3 回。1 回ごとに 1F ずつ遅らせる。 +/// +/// この道具が読みたいのは、その 1F の差が数字に出るかどうかになる。 +fn history() -> History { + let mut history = History::new(DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US); + let mut at_us = 0; + + for delay_frames in 0..3u64 { + // 前方向を入れてローリング。 + history.push(&[press(Target::Stick(Direction::N), at_us)], at_us); + history.push( + &[press(Target::Button(Buttons::CIRCLE), at_us + FRAME_US)], + at_us + FRAME_US, + ); + history.push( + &[release( + Target::Button(Buttons::CIRCLE), + at_us + 3 * FRAME_US, + )], + at_us + 3 * FRAME_US, + ); + + // 攻撃。試行ごとに 1F ずつ遅れる。 + let attack_us = at_us + (18 + delay_frames) * FRAME_US; + history.push(&[press(Target::Button(Buttons::R1), attack_us)], attack_us); + history.push( + &[release( + Target::Button(Buttons::R1), + attack_us + 2 * FRAME_US, + )], + attack_us + 2 * FRAME_US, + ); + history.push( + &[release( + Target::Stick(Direction::N), + attack_us + 4 * FRAME_US, + )], + attack_us + 4 * FRAME_US, + ); + + at_us = attack_us + 4 * FRAME_US + 4 * DEFAULT_TRIAL_GAP_US; + } + + history.push(&[], at_us); + history +} + +struct Demo { + history: Arc>, + status: Status, + settings: Settings, +} + +impl eframe::App for Demo { + fn ui(&mut self, ui: &mut eframe::egui::Ui, _frame: &mut eframe::Frame) { + let trials = { + let history = self.history.lock().expect("毒されていない"); + ui::recent_trials(&history, &self.settings) + }; + + ui::show(ui, &trials, &self.status, &self.settings, &[]); + } +} + +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: ViewportBuilder::default() + .with_title("frametap") + .with_inner_size([440.0, 620.0]), + ..Default::default() + }; + + eframe::run_native( + "frametap demo", + options, + Box::new(|_cc| { + Ok(Box::new(Demo { + history: Arc::new(Mutex::new(history())), + status: Status { + scale_us_per_tick: Some(0.3274), + ..Status::connected("DualSense".to_owned()) + }, + settings: Settings::default(), + })) + }), + ) +} diff --git a/src/ui.rs b/src/ui.rs index aef8b59..8a282ab 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -262,9 +262,11 @@ fn show_column_headers(ui: &mut Ui) { ))); } -/// 見出しの文字。数字より小さく弱くして、行の数字から視線を奪わない。 +/// 見出しの文字。薄くして行の数字から視線を奪わない。 +/// +/// 小さくはしない。等幅でも字送りが本文と変わるので、見出しだけ列がずれる。 fn header_text(text: &str) -> RichText { - RichText::new(text).monospace().small().weak() + RichText::new(text).monospace().weak() } fn show_line(ui: &mut Ui, line: &Line) {