diff --git a/docs/current-precision-status.md b/docs/current-precision-status.md index 4cb849a..3545eaa 100644 --- a/docs/current-precision-status.md +++ b/docs/current-precision-status.md @@ -4,6 +4,8 @@ 最新单宏补充见 [尾部优化与真实接收实测](single-macro-tail-and-end-to-end-2026-10-14.md):极限档固定核五轮十万事件,最大迟到对照 1,230.4µs、保留方案 893.4µs;物理热键 30 次到独立 Win32 接收窗口,中位 3.461ms、最大 8.708ms。最终版本另有 Win32/WPF 各 2,000 次自动真实输入接收,全部有效。托管回归 388 项通过。仍不能声称微秒级端到端或硬实时最大值保证。 +2026-10-15 输入 API 对照(低层钩子 / Raw Input / RegisterHotKey / 轮询,以及 SendInput 各写法)的结果见 [输入 API 对照](input-api-comparison-2026-10-15.md):合成输入下没有任何候选优于现有路径,不接入;物理按键并列组(修正后重测,每组 30 次)同样没有候选早于低层钩子。这不是游戏内延迟结论。 + 三档策略、准备缓存、共享双线程原生调度、图像缓冲优化、界面状态与验收工具已实现。本轮功能回归通过;真实热键端到端、真实 SendInput 接收延迟及游戏帧时间联合验收尚未完成。以下数据不能视为硬实时保证,也不能推广到未测试设备或游戏。 ## 2026-10-14 单宏增量 diff --git a/docs/input-api-comparison-2026-10-15.md b/docs/input-api-comparison-2026-10-15.md new file mode 100644 index 0000000..eefc198 --- /dev/null +++ b/docs/input-api-comparison-2026-10-15.md @@ -0,0 +1,148 @@ +# 输入 API 对照实验结果 + +日期:2026-10-15。设计见 [输入 API 对照实验设计](superpowers/specs/2026-10-15-input-api-comparison-design.md),计划见 [实现计划](superpowers/plans/2026-10-15-input-api-comparison.md)。工具为 `src/tools/InputApiProbe`,统计为 `scripts/summarize_input_api.py`。 + +**结论先行:** 在这台机器上,把低层键盘钩子换成 Raw Input、RegisterHotKey 或轮询,没有任何一种在延迟上更好;把 `SendInput` 换成扫描码写法或 `keybd_event` 也没有可测量的差别。所有候选的判定都是「无净收益」,没有任何一个进入「值得接入」名单。用户态 API 替换不是降低端到端延迟的有效手段。 + +## 环境与口径 + +- Windows 11 10.0.26200,AMD Ryzen 9 7945HX(32 个逻辑处理器),.NET 8.0.22,电源方案 Turbo。测量时申请 1ms 计时器分辨率。机器上同时运行着 Armoury Crate 和 Razer Synapse 等后台软件,没有关闭。 +- 探针二进制 `InputApiProbe.dll` SHA-256:`46B9EB11…E4621B3`,其余指纹见各 `*-provenance.json`。 +- 测量的是**合成输入**的延迟。实验 A 为 `SendInput` 调用前到各捕获方式回调入口;实验 B 为 `SendInput` 调用前到另一进程 Win32 窗口(Normal 优先级,`GetMessage` 循环)收到 `WM_KEYDOWN`。合成输入与物理键盘的路径不完全相同,数值只适合候选之间互相比较,不代表物理键盘到回调的延迟,更不代表游戏内延迟。 +- 分位数使用 nearest-rank;每个候选五轮,每轮 2,000 次,各轮候选顺序轮换。预热样本不计入。全部样本无丢失、无重复。 +- 原始数据保存在本地 `tmp/input-api-20261015/`(不随仓库提交)。SHA-256: + - `capture-samples.csv`:`a5efcd4ecf0608dcb9a6499f4e7f702f1dbc0fac3c937972c92a04e55a7dec57` + - `inject-samples.csv`:`7304e02adf81e0cfe99b281420333544b6bcbe572f4fa2bde29e3da79e8984c0` + - `capture-blocks.csv`:`435bef065c7d655fd7b2cd0e77d3134e492f9f232a1b82b71dc653e6ab96426f` + - `capture-paired.csv`:`aabca4eaea771cfb43c381a282fc74f9cd3698599d2e79d0684715f42dd51cf2`;第一次(按住过久)`capture-paired-run1-held-keys.csv`:`e531461bf21bb2ae14d4c8054f6d18bd63202bb6e66f5defe37f57d9254d3b62`(**第一版并列组,已作废**,见并列组一节) + - 修正后的并列组与 Raw Input 诊断: + - `capture-paired-main.csv`:`9C330FC7EA7D20B4F1952DB194A014ECF9305DF6ED01952E6FD1727D2927014C` + - `capture-paired-hotkey.csv`:`49EE1B088E2C8411E1F62C7812CB4125DFED032C1C46AC68AC8FB0B578063C43` + - `raw-diag-f8-alone.csv`(单独运行、物理 F8):`26C3579E4B3610485EC00568EDD238064C8053958F848B42A6F5F076227F64E5` + - 合成 F24 的 `raw-diag --also` 对照(`raw-diag-synthetic/`,5 次按键四组、20 次按键三组;测试期间键盘上有人打字,所以总记录数多于 F24 的次数,只统计 F24): + - `also-alone.csv`:`81F1B7E2F35A3C5F911A2D60945F391A2CF31A1952A60D2FD7C0940BDC13E28E` + - `also-ll-hook.csv`:`8017CCD0A5AE140A9620AE6D127E073C5AF8FBA5942625070783A57D841E176E` + - `also-poll-spin.csv`:`A9B06B74B9C26C92CEDA53B022732D6F1E50F6D22C49FBCDDF1FD8341569989C` + - `also-register-hotkey.csv`:`E4427736B60383E87B78A499869C825DC308FE01AB58BFE7EB101EF3FBB12538` + - `also20-spin-only.csv`:`54CBEC63F0A695FD1A43D7301E06A6EC4EDF2574DE7463EBBF3E421A64418C04` + - `also20-hotkey+spin.csv`:`7D79F7992F016B6C77B349D0539370E12726864D0B18F3BE3BBB9D6F1DE19C49` + - `also20-all5.csv`:`B195F225AA328F359C98D541E67857D6EA2C913019BB81BAB54044745D3A752F` + +## 实验 A:触发捕获(单独组) + +每个候选 N=10,000(5 轮 × 2,000),单位 µs。 + +| 候选 | P50 | P95 | P99 | P99.9 | 最大 | 五轮 P50 范围 | 每 1000 次触发 CPU | 判定 | +|---|---:|---:|---:|---:|---:|---|---:|---| +| ll-hook(基线,生产 `GlobalKeyboardHook`) | 27.7 | 55.4 | 303.7 | 912.4 | 1,180.7 | 25.9–28.7 | 287ms | 基线 | +| raw-input | 182.6 | 313.5 | 862.0 | 1,204.5 | 3,440.7 | 176.0–187.9 | 291ms | 无净收益 | +| register-hotkey | 154.3 | 268.3 | 796.8 | 1,232.5 | 2,039.2 | 149.6–160.7 | 208ms | 无净收益 | +| poll-spin(持续自旋) | 198.2 | 353.8 | 952.6 | 1,282.6 | 2,107.0 | 192.0–215.7 | 10,474ms | 无净收益 | +| poll-1ms(睡眠 1ms) | 1,496.3 | 2,002.9 | 2,009.8 | 2,074.1 | 2,789.3 | 1495.4–1497.2 | 280ms | 无净收益 | + +要点: +- 现用低层钩子的 P50 最低,比其他方式低约 125–170µs(poll-1ms 受 `Sleep(1)` 粒度限制,约 1.5ms,符合预期)。基线五轮 P50 极差仅 2.8µs,底噪远小于 100µs 门槛,所以结论不是「无结论」。 +- poll-spin 每 1000 次触发占用约 10.5 秒 CPU,等于占满一个逻辑核,且延迟并不更好。 +- 低层钩子 P99 与最大值较重,但仍好于其余各方式的同口径数值。 +- 此项只比较了到回调入口的时间,没有比较各方式能做到的事情(见语义表)。 + +### 并列组(物理按键) + +目的是用同一次物理 F8 按键同时触发各种方式,比较到达先后。**第一版并列组的数据作废**,原因见下;修正后重测得到有效数据。 + +#### 第一版为什么作废(原先的解读也有误) + +最初两次尝试(低层钩子 32 / 30 次,其余方式约一半,Raw Input 为 0)当时被记作「原因不明」,并且写成「低层钩子正好 30 次,说明按键本身没有问题」。这个判断是错的,实际有两处缺陷: + +1. **低层钩子把自动重复也算成按下。** 按住键时 Windows 每约 31ms 重复一次按下,基线因此虚高。核实旧文件:低层钩子 30 次里,相邻间隔小于 100ms 的有 11 个(即自动重复),去掉后为 19 次;另有 3 个间隔在 100–400ms,无法区分是重复还是快速连按。其余方式为 16、15、15 次。**这只能说明基线多算了一部分,不能说明剩下的差异已经完全消除**;旧数据本身不再使用,以重测为准。 +2. **RegisterHotKey 会吞掉 Raw Input 的按下。** 用 `raw-diag` 做了单变量对照(合成 F24,每次只改 Raw Input 的伙伴): + + | Raw Input 的伙伴 | 按键次数 | Raw Input 收到的按下 | 收到的抬起 | + |---|---:|---:|---:| + | 无(单独) | 5 | 5 | 5 | + | 低层钩子 | 5 | 5 | 5 | + | 自旋轮询 | 5 | 5 | 5 | + | **RegisterHotKey** | 5 | **0** | 5 | + | 自旋轮询(单开) | 20 | 20 | 20 | + | **RegisterHotKey + 自旋轮询** | 20 | **0** | 20 | + | **全部四种(低层钩子、RegisterHotKey、两种轮询)** | 20 | **0** | 20 | + + 也就是说,被 `RegisterHotKey` 注册的键,Raw Input 只收到抬起、收不到按下;而 Raw Input 的探针只统计按下,所以记成 0。这与「厂商软件吞掉 F8」无关。单独运行时,Raw Input 对物理 F8 完全正常(9 次按键:按下 15 次含自动重复、抬起 9 次,设备 1 个,读取失败 0 次)。 + +修正:低层钩子和 Raw Input 只计「抬起之后的第一次按下」;并列组拆成两组,`RegisterHotKey` 不再与 Raw Input 同组。 + +#### 修正后的结果 + +每组物理 F8 轻点 30 次,每个方式与低层钩子按最近时间戳配对(±50ms),30 对 30,无未匹配;低层钩子相邻捕获最短间隔 523ms,已无自动重复。数值为「该方式的捕获时刻减去低层钩子的捕获时刻」,单位 µs,正数表示更晚。 + +| 组 | 方式 | n | P50 | P95 | P99 | 最大 | +|---|---|---:|---:|---:|---:|---:| +| main(同组并发) | raw-input | 30 | +527.4 | +1008.2 | +1357.0 | +1357.0 | +| main | poll-spin | 30 | +496.1 | +922.1 | +1326.8 | +1326.8 | +| main | poll-1ms | 30 | +996.8 | +2203.0 | +2467.4 | +2467.4 | +| hotkey(两方式并发) | register-hotkey | 30 | +435.1 | +540.7 | +3169.4 | +3169.4 | + +- **方向与单独组一致:每一种都晚于低层钩子,没有任何一种更早。** 物理按键下也没有候选值得接入。 +- 数值比单独组大(单独组的 P50 差约为 raw-input +155、poll-spin +170、register-hotkey +127µs)。并发的几个方式互相拖慢(设计文档已说明并列组不与单独组的绝对值混用),自旋轮询还会占满一个核,所以这里**只看先后,不看绝对大小**,也不据此推断物理路径本身更慢。 +- n=30、单次会话,不按正式判定门槛(五轮、每轮 2,000 次)判定,P99/最大值只是最大的几个样本,仅供参考。 +- 两组并列组使用同一个探针二进制(SHA-256 `02E8E80D…02A02C`),与本文前面实验 A、B 的二进制(`46B9EB11…`)不同,因为之后又改了代码;`raw-diag` 的几次运行是不同时间构建的,各自的指纹见对应的 `*-provenance.json`(`raw-diag-f8-alone` 为 `AFB4AF02…601DAAC`)。实验 A、B 的分位数不受自动重复影响,合成输入是「按下、等待、抬起」,没有按住。 + +## 实验 B:注入到接收 + +每个条件 N=10,000,单位 µs。判定对象是同一钩子条件下的 `vk`(见设计文档)。 + +| 条件 | P50 | P95 | P99 | P99.9 | 最大 | 五轮 P50 范围 | 判定 | +|---|---:|---:|---:|---:|---:|---|---| +| vk \| none(基线) | 766.2 | 1149.9 | 1424.3 | 2026.5 | 6087.2 | 734.8–798.0 | 基线 | +| scan \| none | 756.3 | 1122.0 | 1415.5 | 1944.9 | 2553.8 | 731.9–770.2 | 无净收益 | +| vk-scan \| none | 760.1 | 1115.8 | 1395.8 | 1932.5 | 4234.1 | 720.8–784.3 | 无净收益 | +| keybd-event \| none | 755.6 | 1105.3 | 1395.8 | 1907.7 | 3202.6 | 739.0–761.5 | 无净收益 | +| merged-pair \| none | 865.9 | 1316.4 | 1609.1 | 1994.4 | 3773.9 | 848.8–892.7 | 无净收益 | +| vk \| ll-empty(基线) | 785.7 | 1145.2 | 1460.3 | 1918.0 | 2707.9 | 756.7–815.8 | 基线 | +| scan \| ll-empty | 792.8 | 1175.3 | 1470.5 | 2140.1 | 2634.4 | 752.2–811.4 | 无净收益 | +| vk-scan \| ll-empty | 815.4 | 1172.7 | 1469.0 | 2068.9 | 6118.2 | 781.9–845.9 | 无净收益 | +| keybd-event \| ll-empty | 801.0 | 1171.6 | 1468.8 | 2027.5 | 3685.6 | 789.6–813.1 | 无净收益 | +| merged-pair \| ll-empty | 960.7 | 1512.4 | 1822.3 | 3041.8 | 4200.3 | 938.5–1000.7 | 无净收益 | + +- 虚拟键码、扫描码、键码加扫描码、`keybd_event` 四种写法的按下延迟 P50 相差不到 11µs,在底噪范围内,**没有可测量的差别**;五轮 P50 极差在 22–64µs,也小于 100µs 门槛。 +- 按下和抬起合并为一次 `SendInput` 的写法,按下消息反而慢约 100µs(P50 865.9 对 766.2)。原因没有深究。 +- 键抬起方向(不含 merged-pair):P50 650–670µs(hook=none)、705–718µs(hook=ll-empty),写法之间同样无差别。 +- 注入侧存在约 0.75ms 的 P50 下限:从调用 `SendInput` 到 Normal 优先级的 Win32 接收窗口收到消息,与具体 API 写法无关。 + +### 钩子税(发送进程内是否有空的低层钩子) + +池化后的 `ll-empty` 减 `none`,单位 µs;每项只是一个差值,不是分布。 + +| 变体 | P50 差 | P99 差 | +|---|---:|---:| +| vk | 19.5 | 36.0 | +| scan | 36.5 | 55.0 | +| vk-scan | 55.3 | 73.2 | +| keybd-event | 45.4 | 73.0 | +| merged-pair | 94.8 | 213.2 | + +一个什么都不做的低层钩子只给这条路径增加约 20–95µs(P50)。此前端到端实测里 F24 钩子入口到转发返回约 0.87ms,远大于此值;两者条件不同(那一段含有探针自身的记录工作),所以这里**不推断**该 0.87ms 的成因,只说明钩子机制本身的代价较小。 + +## 语义兼容表 + +「文档」指依据 Win32 文档,「实测」指本次观察,「未验证」表示本次没有测。 + +| 方式 | 能否吞键 | 区分注入与物理 | 抬起事件 | 鼠标键 | 修饰键组合 | 需要消息泵 | +|---|---|---|---|---|---|---| +| 低层钩子 | 能(返回非零,文档) | 能(`LLKHF_INJECTED`,实测探针靠 `dwExtraInfo` 区分) | 有 | 有(`WH_MOUSE_LL`) | 自行跟踪状态 | 需要,在安装线程上 | +| Raw Input | 不能(只观察,文档) | 未验证 | 有(`RI_KEY_BREAK`) | 有(鼠标用途页,文档) | 自行跟踪状态 | 需要 | +| RegisterHotKey | 会消耗该键(文档) | 未验证 | 没有 | 只支持键盘 | 支持修饰键加主键 | 需要 | +| 轮询 | 不能 | 不能 | 靠边沿检测 | 可轮询鼠标键 | 自行跟踪状态 | 不需要,但要专用线程;按下时间短于周期会漏 | + +实测补充: +- Raw Input 对合成输入和对物理 F8 都能收到按下与抬起(单独运行)。 +- **被 `RegisterHotKey` 注册的键,Raw Input 收不到按下,只收到抬起**(合成 F24,20 次:按下 0、抬起 20;换成低层钩子或轮询同开则不受影响)。两者不能依赖同一个键。 +- 按住键时,低层钩子和 Raw Input 都会重复收到按下(约每 31ms),需要自己用「按下后抬起前」的状态去重;`RegisterHotKey` 加 `MOD_NOREPEAT` 则不会重复。 + +## 结论与后续 + +1. **不接入任何候选。** 现用低层钩子在捕获侧的 P50 最低,且是唯一能吞键的方式;注入侧各写法没有差别。保持产品默认路径不变。 +2. 本次实验排除了「换一种用户态 API 即可明显降低延迟」的可能,剩余的毫秒级延迟更可能在 Windows 输入路由、目标线程调度和消息队列一侧。要进一步定位,需要对最慢样本做 ETW 追踪(线程调度、DPC/ISR、GC/JIT),这是此前文档中的第一项后续工作,仍未做。 +3. 注入侧约 0.75ms 的下限说明,仅替换注入 API 无法越过它。驱动级注入(虚拟 HID)仍会经过同样的 Windows 输入线程和目标消息队列,是否有收益目前**没有证据**,需要原型对照后才能判断。按之前的决定,这部分另开新仓库研究,不放进本仓库;新仓库的名称待定(本仓库产品已叫 MacroHID,需避免混淆)。 +4. 并列组物理数据已补全(见上):物理按键下各候选同样晚于低层钩子,结论与单独组一致。最初「Raw Input 为 0」是 `RegisterHotKey` 同开造成的,不是厂商软件,也不是 Raw Input 收不到物理按键。 +5. 局限:合成输入、单台机器、单一负载状态;没有游戏内延迟、帧时间或跨设备数据;「最大值不变差」这一门槛基于池化后的单个极值,噪声较大。 diff --git a/docs/next-steps-latency-2026-10-15.md b/docs/next-steps-latency-2026-10-15.md new file mode 100644 index 0000000..9f15379 --- /dev/null +++ b/docs/next-steps-latency-2026-10-15.md @@ -0,0 +1,105 @@ +# 低延迟后续工作清单 + +日期:2026-10-15。承接 [输入 API 对照结果](input-api-comparison-2026-10-15.md)。本文只列待办与验收标准,下次会话按需选做;每一项做之前先走 brainstorming,再写计划。 + +## 已知事实(不要重复做) + +- 调度器本身已到微秒级(极限档 P99.9 约 3µs),端到端中位 3.5ms、最大 8.7ms,主要耗时在调度器之外。见 [单宏尾部优化与真实输入接收实测](single-macro-tail-and-end-to-end-2026-10-14.md)。 +- 合成输入下,现有低层钩子的捕获 P50 最低(27.7µs),Raw Input、RegisterHotKey、轮询都更慢;SendInput 各写法无差别(P50 约 756–766µs);空钩子只增加约 20–95µs。**不要再提议换这几个 API 来降延迟。** +- 阶段 2.2、3.1 已撤回,3.2 因底噪未做,见 [调度延迟优化分阶段计划](superpowers/plans/2026-10-14-scheduling-latency-phases.md)。 + +## 当前代码状态 + +- 分支 `input-api-comparison`,已提 [PR #2](https://github.com/NotBadSlime/Macro/pull/2),**待合并到 main**。内容:`src/tools/InputApiProbe`、`scripts/summarize_input_api.py`、`tests/test_input_api_summary.py`、设计/计划/结果文档。 +- 原始数据在本地 `tmp/input-api-20261015/`(已被 gitignore,不随仓库)。 +- 重跑方法: + ```bash + dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release + src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture --rounds 5 --per-round 2000 --output tmp/<目录> + src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe inject --rounds 5 --per-block 2000 --output tmp/<目录> # 开始后点击接收窗口 + src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture-paired --physical 30 --key F8 --group main --output tmp/<目录> # 物理按键;--group hotkey 单独测 RegisterHotKey + src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe raw-diag --seconds 20 --key F8 [--also register-hotkey,ll-hook,poll-spin,poll-1ms] --output tmp/<目录> # Raw Input 不过滤的诊断 + python scripts/summarize_input_api.py capture|inject|paired --output + python scripts/summarize_input_api.py raw-diag --vk 0x77 + ``` + 测量期间不要碰键鼠,也不要同时构建或跑其他负载。 + +## 待办一览(建议顺序) + +| 序 | 项 | 目的 | 规模 | +|---|---|---|---| +| 1 | 合并分支(已提 [PR #2](https://github.com/NotBadSlime/Macro/pull/2),待合并) | 把探针和文档放进 main | 小 | +| 2 | 修复并列组物理数据(已完成) | 弄清 Raw Input 对物理按键为 0 次、其余只有一半的原因 | 小 | +| 3 | ETW 追踪最慢样本 | 定位毫秒级尾部的真实来源 | 中 | +| 4 | 接收端对照(0.75ms 下限) | 判断下限来自消息路由还是接收方式 | 中 | +| 5 | 驱动级注入原型(新仓库) | 判断虚拟 HID 是否有可测量收益 | 大 | + +### 1. 合并分支 + +- 前置:`python -m unittest discover -s tests -p "test_input_api_summary.py"` 通过;`dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release` 0 个错误。 +- 做法:用 `superpowers:finishing-a-development-branch` 走合并或 PR;用户决定方式。 +- 验收:main 上能构建探针,文档里的链接不失效。 + +### 2. 修复并列组物理数据(已完成) + +结果见 [输入 API 对照结果](input-api-comparison-2026-10-15.md) 的「并列组」一节。原因有两个,都在探针里,不在系统或厂商软件里: + +1. `RegisterHotKey` 会让同一个键的 Raw Input 只收到抬起、收不到按下(合成 F24 对照:0 对 20)。两者不能放在同一组。 +2. 低层钩子把自动重复也算成按下,基线虚高。 + +做了什么:新增 `raw-diag` 子命令(不过滤的 `WM_INPUT` 记录,`--also` 可单变量伴随其他方式);并列组拆成 `--group main`(低层钩子、Raw Input、两种轮询)和 `--group hotkey`(低层钩子、RegisterHotKey);低层钩子与 Raw Input 只计抬起后的第一次按下。重测每组 30 次物理 F8,30 对 30,所有候选都晚于低层钩子。 + +**没有做的**:原计划中的换键(F9)、关闭 Armoury Crate / Razer Synapse 的对照不再需要,原因已查明与它们无关。 + +### 3. ETW 追踪最慢样本 + +目的:回答「毫秒级尾部来自哪里」。这是 [单宏尾部优化](single-macro-tail-and-end-to-end-2026-10-14.md) 第一项后续,至今未做。 + +做法: + +1. 在 `InputApiProbe inject` 增加可选 `--etw`:为每个样本发出带 `row`、`qpc` 的 EventSource 标记(发送前、接收后),保证默认关闭、关闭时不增加开销。 +2. 用 WPR/xperf 录制:线程上下文切换(CSwitch)、ReadyThread、DPC/ISR、页错误、GC/JIT,以及输入栈相关提供者;已有 `scripts/Trace-LatencyOutliers.ps1` 可作起点,先读它。 +3. 取最慢的 0.1% 样本,按 `row` 对齐,把发送线程、系统输入线程、接收线程放在同一条时间轴上,逐个归类:被抢占、等待调度、DPC/ISR、GC/JIT、未知。无法解释的保留「未知」,不要一概归咎于某个原因。 +4. 记录记录本身的开销:分别跑关闭、仅标记、完整追踪三组,写明追踪是否改变了分布。 + +验收:产出一张「最慢样本归因表」(各类原因的个数与时间占比)和一份结论文档;不修改产品代码。 + +### 4. 接收端对照(0.75ms 下限) + +事实:无论 `SendInput` 怎么写,到 Normal 优先级的 Win32 `GetMessage` 接收窗口的 P50 都有约 0.75ms。需要区分这是系统输入路由的固有时间,还是接收方式造成的。 + +在 `ReceiverProcess` 里增加接收模式开关,每次只改一个变量,沿用 `inject` 的五轮方法: + +| 模式 | 说明 | +|---|---| +| 基线 | 现状:`GetMessage` 循环,`WM_KEYDOWN`,Normal 优先级 | +| PeekMessage 自旋 | 排除消息循环唤醒延迟 | +| Raw Input 接收 | 接收端注册 Raw Input,记录 `WM_INPUT` 到达时间,比较与 `WM_KEYDOWN` 的先后 | +| 轮询 `GetAsyncKeyState` | 贴近许多游戏的读取方式,用来看「游戏如果轮询,实际能多快看到」 | +| 接收线程提高优先级 / MMCSS | 仅用于判断下限是否随调度而变,不代表真实目标 | + +验收:给出每种接收模式的 P50/P99,并判断「下限」是否主要由接收方式决定。结论要写明:不同目标程序读取输入的方式不同,本机结果不能直接外推到某个具体游戏。 + +### 5. 驱动级注入原型(新仓库) + +决定(见项目记忆):驱动工作放进**新的独立仓库**,不放进本仓库。 + +- **仓库命名待用户确认。** 用户提议叫 MacroHID,但当前产品、解决方案和命名空间都已叫 MacroHID,容易混淆;建议先确认是否改用区分名(如 `MacroHID-Driver`)。 +- 前置判断:第 4 项的结果。若下限主要在接收方式一侧,驱动注入的预期收益就小,应先停下评估是否值得做。 +- 原型范围(若继续):用 VHF 建一个虚拟键盘,能按指定时刻发送 F24 的按下/抬起报告;仅此,不做宏逻辑。 +- 对照方法:复用本仓库的 `InputApiProbe inject` 接收端与统计脚本,把「SendInput」与「虚拟 HID」放在同一接收进程、同样的五轮 × 2,000 样本、同样的判定门槛(P50 改善不少于 100µs,P99 与最大值不变差,至少四轮方向一致)下比较。 +- 风险需提前写明:驱动签名与测试签名模式、对反作弊软件的影响、安装与卸载、稳定性与蓝屏风险、在用户机器上部署的合规问题。这些都不应被「延迟更低」的结果掩盖。 + +验收:有数据证明或否定虚拟 HID 相对 SendInput 的收益;若收益低于门槛,记录后终止,不继续投入。 + +## 不做的事 + +- 不再比较 Raw Input / RegisterHotKey / 轮询作为触发方式(已有结论)。 +- 不做绕过公开接口的直接系统调用,不做 `PostMessage` 类后台输入(语义不同)。 +- 不为追求数字而降低判定门槛,不丢弃难看的样本。 +- 不把合成输入的数据说成游戏内延迟;游戏帧时间与真实游戏消费需要另行验收(PresentMon)。 + +## 相关文档 + +- [输入 API 对照结果](input-api-comparison-2026-10-15.md)、[设计](superpowers/specs/2026-10-15-input-api-comparison-design.md)、[实现计划](superpowers/plans/2026-10-15-input-api-comparison.md) +- [当前精度状态](current-precision-status.md)、[调度研究](research/macro-scheduling-research-2026-10-14.md) diff --git a/docs/superpowers/plans/2026-10-15-input-api-comparison.md b/docs/superpowers/plans/2026-10-15-input-api-comparison.md new file mode 100644 index 0000000..df40afc --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-input-api-comparison.md @@ -0,0 +1,1544 @@ +# 输入 API 对照实验 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新增独立探针 `InputApiProbe`,在同一台机器上对比触发捕获(低层钩子、Raw Input、RegisterHotKey、轮询)与注入(SendInput 虚拟键码/扫描码/合并、keybd_event)各 API 的延迟,并给出是否值得接入的判定。 + +**Architecture:** 一个 .NET 8 Windows 控制台工具,含四种子命令:`capture`(单独组)、`capture-paired`(物理按键并列组)、`inject`(注入到独立 Win32 接收进程)、`--receiver`(接收子进程)。探针只写原始 QPC CSV 与 provenance;统计和判定由新的 Python 脚本 `scripts/summarize_input_api.py` 完成,并有 unittest 覆盖。产品代码不改,A0 基线通过链接生产 `GlobalKeyboardHook.cs` 源码复用(与 `EndToEndProbe` 做法相同)。 + +**Tech Stack:** C# / .NET 8 (net8.0-windows, P/Invoke user32/winmm),Python 3 stdlib(csv/json/math/unittest)。 + +设计依据:`docs/superpowers/specs/2026-10-15-input-api-comparison-design.md`。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|---|---| +| `src/tools/InputApiProbe/InputApiProbe.csproj` | 工程;链接 `GlobalKeyboardHook.cs`,引用 `MacroHid.Runtime` | +| `src/tools/InputApiProbe/Native.cs` | 全部 P/Invoke、键盘注入(各变体)、消息循环原语 | +| `src/tools/InputApiProbe/MessageWindow.cs` | 带独立消息线程的窗口(隐藏/可见),处理函数首行取时间戳 | +| `src/tools/InputApiProbe/EmptyHook.cs` | 空 `WH_KEYBOARD_LL` 钩子(实验 B 的「钩子税」因子) | +| `src/tools/InputApiProbe/Capture/ICaptureStrategy.cs` | 捕获方式的统一接口 | +| `src/tools/InputApiProbe/Capture/LowLevelHookCapture.cs` | A0:生产低层钩子 | +| `src/tools/InputApiProbe/Capture/RawInputCapture.cs` | A1:Raw Input | +| `src/tools/InputApiProbe/Capture/RegisterHotKeyCapture.cs` | A2:RegisterHotKey | +| `src/tools/InputApiProbe/Capture/PollingCapture.cs` | A3:GetAsyncKeyState 轮询 | +| `src/tools/InputApiProbe/Capture/CaptureExperiment.cs` | 实验 A 单独组 | +| `src/tools/InputApiProbe/Capture/CapturePairedExperiment.cs` | 实验 A 并列组(物理按键) | +| `src/tools/InputApiProbe/Inject/ReceiverProcess.cs` | 实验 B 的 Win32 接收进程 | +| `src/tools/InputApiProbe/Inject/InjectExperiment.cs` | 实验 B 发送端 | +| `src/tools/InputApiProbe/Provenance.cs` | 写环境与二进制指纹 | +| `src/tools/InputApiProbe/Program.cs` | 子命令分发 | +| `scripts/summarize_input_api.py` | 统计、判定、并列差值 | +| `tests/test_input_api_summary.py` | 汇总脚本的 unittest | +| `docs/input-api-comparison-2026-10-15.md` | 结果文档(Task 8 产出) | + +约定:触发键使用 **F24(VK 0x87)**,避免对前台程序产生副作用;物理并列组使用 F8。所有时间戳是 `Stopwatch.GetTimestamp()`(QPC)。构建命令 `dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release`;提交信息末尾都带 `Co-Authored-By: Claude Sonnet 5.5 `。 + +--- + +### Task 1: 修订设计文档并建立工程骨架 + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-15-input-api-comparison-design.md` +- Create: `src/tools/InputApiProbe/InputApiProbe.csproj` +- Create: `src/tools/InputApiProbe/Native.cs` +- Create: `src/tools/InputApiProbe/MessageWindow.cs` +- Create: `src/tools/InputApiProbe/Program.cs` + +- [ ] **Step 1: 修订设计文档的三处细节** + +在设计文档中做三处精确替换。 + +替换一(A3 行): + +旧:`| A3 | 专用线程轮询 `GetAsyncKeyState`,分别用 250µs 与 1ms 周期 | 记录 CPU 占用;可能漏掉很短的按键 |` + +新:`| A3 | 专用线程轮询 `GetAsyncKeyState`,两个变体:持续自旋(poll-spin,占满一个核)与 1ms 睡眠轮询(poll-1ms,配合 `timeBeginPeriod(1)`) | 记录 CPU 占用;按下时间短于轮询周期会漏掉 |` + +替换二(在「方法」小节的列表第一条之前加一行): + +新增一行:`- 触发键使用 F24(VK 0x87)而非 F8,避免对前台程序产生副作用;物理并列组按 `--key` 指定的键(默认 F8)。触发为「按下 → 等待捕获 → 抬起」,不合并,因为轮询方式会漏掉瞬间合并的按下抬起。` + +替换三(判定门槛第 3 条之后新增第 4 条): + +新增:`4. 基线自身各轮 P50 的极差不小于 100µs 时,该组判为「无结论」(底噪大于要检测的改善量);候选出现任何丢失或重复事件,判为「无效」,不进入名单。` + +- [ ] **Step 2: 创建工程文件** + +`src/tools/InputApiProbe/InputApiProbe.csproj`: + +```xml + + + Exe + net8.0-windows + true + enable + enable + $(DefineConstants);MACROHID_HOTKEY_PROBE + + + + + + +``` + +- [ ] **Step 3: 创建 `Native.cs`** + +```csharp +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +internal enum InjectVariant { Vk, Scan, VkScan, KeybdEvent, MergedPair } + +/// All P/Invoke used by the probe, plus the keyboard injection variants under test. +internal static class Native +{ + public const ushort VkF24 = 0x87; + public const ulong Tag = 0x4D484150; // "MHAP", dwExtraInfo on injected input + + private const uint InputKeyboard = 1, KeyUpFlag = 0x2, ScanCodeFlag = 0x8; + + /// Scan code of F24, resolved once so MapVirtualKey is never inside a timed region. 0 if unmapped. + public static readonly ushort ScanF24 = (ushort)MapVirtualKey(VkF24, 0); + + [StructLayout(LayoutKind.Sequential)] + private struct KeyboardInput { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Explicit, Size = 32)] + private struct InputUnion { [FieldOffset(0)] public KeyboardInput Keyboard; } + [StructLayout(LayoutKind.Sequential)] + private struct Input { public uint Type; public InputUnion Union; } + + [StructLayout(LayoutKind.Sequential)] + public struct Msg + { + public IntPtr Hwnd; + public uint Id; + public UIntPtr W; + public IntPtr L; + public uint Time; + public int X, Y; + public uint Private; + } + + [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint count, Input[] inputs, int size); + [DllImport("user32.dll")] private static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); + [DllImport("user32.dll")] private static extern uint MapVirtualKey(uint code, uint mapType); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vk); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetMessage(out Msg message, IntPtr hwnd, uint minimum, uint maximum); + [DllImport("user32.dll")] public static extern bool TranslateMessage(ref Msg message); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr DispatchMessage(ref Msg message); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint message, UIntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool PostThreadMessage(uint thread, uint message, UIntPtr w, IntPtr l); + [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); + [DllImport("winmm.dll")] public static extern uint timeBeginPeriod(uint milliseconds); + [DllImport("winmm.dll")] public static extern uint timeEndPeriod(uint milliseconds); + + private static Input Key(ushort vk, ushort scan, uint flags) => new() + { + Type = InputKeyboard, + Union = new() { Keyboard = new() { Vk = vk, Scan = scan, Flags = flags, Extra = new UIntPtr(Tag) } } + }; + + private static void Send(params Input[] inputs) + { + if (SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()) != inputs.Length) + throw new InvalidOperationException($"SendInput failed, error={Marshal.GetLastWin32Error()}"); + } + + /// Key-down of F24 using the given API variant. MergedPair sends down and up in one SendInput call. + public static void KeyDown(InjectVariant variant) + { + switch (variant) + { + case InjectVariant.Vk: Send(Key(VkF24, 0, 0)); break; + case InjectVariant.Scan: Send(Key(0, ScanF24, ScanCodeFlag)); break; + case InjectVariant.VkScan: Send(Key(VkF24, ScanF24, 0)); break; + case InjectVariant.KeybdEvent: keybd_event((byte)VkF24, (byte)ScanF24, 0, new UIntPtr(Tag)); break; + case InjectVariant.MergedPair: Send(Key(VkF24, 0, 0), Key(VkF24, 0, KeyUpFlag)); break; + } + } + + /// Key-up of F24. A no-op for MergedPair because KeyDown already sent the up event. + public static void KeyUp(InjectVariant variant) + { + switch (variant) + { + case InjectVariant.Vk: Send(Key(VkF24, 0, KeyUpFlag)); break; + case InjectVariant.Scan: Send(Key(0, ScanF24, ScanCodeFlag | KeyUpFlag)); break; + case InjectVariant.VkScan: Send(Key(VkF24, ScanF24, KeyUpFlag)); break; + case InjectVariant.KeybdEvent: keybd_event((byte)VkF24, (byte)ScanF24, KeyUpFlag, new UIntPtr(Tag)); break; + case InjectVariant.MergedPair: break; + } + } +} +``` + +- [ ] **Step 4: 创建 `MessageWindow.cs`** + +```csharp +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// +/// A window with its own message-loop thread. The handler runs on that thread for every message and +/// returns true to swallow it (result 0); returning false falls through to DefWindowProc. +/// +internal sealed class MessageWindow : IDisposable +{ + private const uint WmDestroy = 0x2, WmClose = 0x10; + private static readonly IntPtr HwndMessage = new(-3); + + private delegate IntPtr WindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass + { + public uint Size, Style; + public IntPtr Proc; + public int ClassExtra, WindowExtra; + public IntPtr Instance, Icon, Cursor, Background; + public string? Menu, Name; + public IntPtr SmallIcon; + } + + private readonly Func handler; + private readonly Action? onCreated; + private readonly bool visible, messageOnly; + private readonly string title, className = "MacroHID.ApiProbe." + Guid.NewGuid().ToString("N"); + private readonly WindowProc proc; + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(); + private Exception? failure; + + public IntPtr Handle { get; private set; } + + /// Runs on the window thread right after creation (RegisterHotKey and raw-input registration must happen there). + /// For hidden windows: parent them to HWND_MESSAGE. Set false to get a hidden top-level window. + public MessageWindow(Func handler, bool visible, string title, + Action? onCreated = null, bool messageOnly = true) + { + this.handler = handler; this.visible = visible; this.title = title; + this.onCreated = onCreated; this.messageOnly = messageOnly; + proc = Proc; + thread = new Thread(Run) { IsBackground = true, Name = "ApiProbe-" + title, Priority = ThreadPriority.AboveNormal }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(); + if (failure is not null) throw new InvalidOperationException($"{title}: {failure.Message}", failure); + } + + private IntPtr Proc(IntPtr hwnd, uint message, IntPtr w, IntPtr l) + { + if (handler(message, w, l)) return IntPtr.Zero; + if (message == WmDestroy) PostQuitMessage(0); + return DefWindowProc(hwnd, message, w, l); + } + + private void Run() + { + IntPtr instance = GetModuleHandle(null); + var cls = new WindowClass + { + Size = (uint)Marshal.SizeOf(), Proc = Marshal.GetFunctionPointerForDelegate(proc), Instance = instance, + Cursor = LoadCursor(IntPtr.Zero, new IntPtr(32512)), Background = new IntPtr(6), Name = className + }; + try + { + if (RegisterClassEx(ref cls) == 0) throw new InvalidOperationException($"RegisterClassEx failed, error={Marshal.GetLastWin32Error()}"); + // 0x10CF0000 = WS_VISIBLE | WS_OVERLAPPEDWINDOW, ex style 8 = WS_EX_TOPMOST (same as the existing receiver). + Handle = visible + ? CreateWindowEx(8, className, title, 0x10CF0000, 240, 240, 600, 200, IntPtr.Zero, IntPtr.Zero, instance, IntPtr.Zero) + : CreateWindowEx(0, className, title, 0, 0, 0, 0, 0, messageOnly ? HwndMessage : IntPtr.Zero, IntPtr.Zero, instance, IntPtr.Zero); + if (Handle == IntPtr.Zero) throw new InvalidOperationException($"CreateWindowEx failed, error={Marshal.GetLastWin32Error()}"); + onCreated?.Invoke(Handle); + } + catch (Exception ex) + { + failure = ex; + if (Handle != IntPtr.Zero) DestroyWindow(Handle); + UnregisterClass(className, instance); + ready.Set(); + return; + } + ready.Set(); + while (Native.GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) + { Native.TranslateMessage(ref message); Native.DispatchMessage(ref message); } + UnregisterClass(className, instance); + } + + public void Dispose() + { + if (Handle != IntPtr.Zero) Native.PostMessage(Handle, WmClose, UIntPtr.Zero, IntPtr.Zero); + thread.Join(2000); + GC.KeepAlive(proc); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string? name); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern ushort RegisterClassEx(ref WindowClass cls); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool UnregisterClass(string name, IntPtr instance); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateWindowEx(uint ex, string cls, string title, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr DefWindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); + [DllImport("user32.dll")] private static extern IntPtr LoadCursor(IntPtr instance, IntPtr name); + [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern void PostQuitMessage(int code); +} +``` + +- [ ] **Step 5: 创建最小 `Program.cs`** + +```csharp +namespace InputApiProbe; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + Console.Error.WriteLine("Usage: InputApiProbe capture|capture-paired|inject|--receiver [options]"); + return 2; + } + catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } + } +} +``` + +- [ ] **Step 6: 构建** + +Run: `dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release` +Expected: `0 Error(s)`(依赖兼容性警告可有,与 `EndToEndProbe` 相同类别)。若 `GlobalKeyboardHook.cs` 报找不到类型,说明链接源码需要的类型不在 `MacroHid.Runtime`;对照 `src/tools/EndToEndProbe/EndToEndProbe.csproj` 补齐同样的引用。 + +- [ ] **Step 7: 提交** + +```bash +git add docs/superpowers/specs/2026-10-15-input-api-comparison-design.md src/tools/InputApiProbe +git commit -m "Scaffold InputApiProbe and refine experiment design" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: 汇总脚本(TDD) + +**Files:** +- Create: `tests/test_input_api_summary.py` +- Create: `scripts/summarize_input_api.py` + +- [ ] **Step 1: 先写失败的测试** + +`tests/test_input_api_summary.py`: + +```python +import csv +import importlib.util +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("summary", Path(__file__).parents[1] / "scripts/summarize_input_api.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +def group(round_p50, p50, p99=500.0, mx=900.0, lost=0, duplicates=0): + """Hand-built group: per-round P50s plus pooled stats (round P99/max follow the pooled values).""" + return { + "pooled": {"n": 1000, "p50": p50, "p99": p99, "max": mx}, + "rounds": [{"round": i + 1, "p50": v, "p99": p99, "max": mx} for i, v in enumerate(round_p50)], + "lost": lost, "duplicates": duplicates, + } + + +def write_csv(path, header, rows): + with path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.writer(stream) + writer.writerow(header) + writer.writerows(rows) + + +class PercentileTests(unittest.TestCase): + def test_nearest_rank(self): + self.assertEqual(module.stats([4, 1, 3, 2])["p50"], 2) + self.assertEqual(module.stats(list(range(1, 1001)))["p99"], 990) + self.assertEqual(module.stats(list(range(1, 1001)))["max"], 1000) + + def test_empty_is_none(self): + self.assertIsNone(module.stats([])) + + +class VerdictTests(unittest.TestCase): + base = group([1000] * 5, 1000) + + def test_clear_win_is_candidate(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800)), "candidate") + + def test_small_gain_is_no_net_benefit(self): + self.assertEqual(module.verdict(self.base, group([950] * 5, 950)), "no-net-benefit") + + def test_tail_regression_blocks_candidate(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, p99=600.0)), "no-net-benefit") + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, mx=1200.0)), "no-net-benefit") + + def test_needs_four_improved_rounds(self): + self.assertEqual(module.verdict(self.base, group([800, 800, 800, 1100, 1100], 800)), "no-net-benefit") + self.assertEqual(module.verdict(self.base, group([800, 800, 800, 800, 1100], 800)), "candidate") + + def test_lost_or_duplicate_is_invalid(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, lost=1)), "invalid") + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, duplicates=1)), "invalid") + + def test_too_few_rounds_is_inconclusive(self): + self.assertEqual(module.verdict(group([1000] * 4, 1000), group([800] * 4, 800)), "inconclusive") + + def test_noisy_baseline_is_inconclusive(self): + noisy = group([900, 1000, 1050, 1000, 1000], 1000) + self.assertEqual(module.verdict(noisy, group([700] * 5, 700)), "inconclusive") + + +class CaptureSummaryTests(unittest.TestCase): + def test_lost_duplicate_and_latency(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "capture-samples.csv" + header = ["round", "strategy", "sample", "send_qpc", "capture_qpc", "captures", "frequency"] + # frequency 1_000_000 -> 1 tick = 1 microsecond + write_csv(path, header, [ + [1, "ll-hook", 0, 1000, 1100, 1, 1000000], + [1, "ll-hook", 1, 2000, 2300, 1, 1000000], + [1, "ll-hook", 2, 3000, 0, 0, 1000000], + [2, "ll-hook", 0, 4000, 4200, 2, 1000000], + [1, "raw-input", 0, 1000, 1050, 1, 1000000], + ]) + result = module.summarize_capture(path) + ll = result["groups"]["ll-hook"] + self.assertEqual(ll["lost"], 1) + self.assertEqual(ll["duplicates"], 1) + self.assertEqual(ll["pooled"]["n"], 3) + self.assertEqual(ll["pooled"]["p50"], 200) + self.assertEqual([r["round"] for r in ll["rounds"]], [1, 2]) + self.assertEqual(result["groups"]["raw-input"]["pooled"]["p50"], 50) + + +class InjectSummaryTests(unittest.TestCase): + def test_wrong_key_counts_as_lost_and_up_latency_is_reported(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "inject-samples.csv" + header = ["round", "variant", "hook", "sample", "send_down_qpc", "send_up_qpc", + "recv_down_qpc", "recv_up_qpc", "down_count", "up_count", "vk", "frequency"] + write_csv(path, header, [ + [1, "vk", "none", 0, 1000, 5000, 1400, 5300, 1, 1, 135, 1000000], + [1, "vk", "none", 1, 6000, 9000, 6500, 9200, 1, 1, 135, 1000000], + [1, "scan", "none", 0, 1000, 5000, 1200, 5100, 1, 1, 17, 1000000], # wrong key + ]) + result = module.summarize_inject(path) + vk = result["groups"]["vk|none"] + self.assertEqual(vk["pooled"]["p50"], 400) + self.assertEqual(result["up_groups"]["vk|none"]["pooled"]["p50"], 200) + self.assertEqual(result["groups"]["scan|none"]["lost"], 1) + + +class PairedTests(unittest.TestCase): + def test_delta_against_baseline_by_nth_capture(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "paired.csv" + write_csv(path, ["sample", "strategy", "capture_qpc", "frequency"], [ + [0, "ll-hook", 1000, 1000000], [1, "ll-hook", 2000, 1000000], + [0, "raw-input", 1100, 1000000], [1, "raw-input", 1900, 1000000], + [0, "poll-1ms", 1500, 1000000], + ]) + result = module.summarize_paired(path, "ll-hook") + self.assertEqual(result["raw-input"]["n"], 2) + self.assertEqual(result["raw-input"]["p50"], -100) + self.assertEqual(result["raw-input"]["max"], 100) + self.assertEqual(result["poll-1ms"]["count_mismatch"], True) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `python -m unittest discover -s tests -p "test_input_api_summary.py" -v` +Expected: FAIL,原因是 `scripts/summarize_input_api.py` 不存在(`FileNotFoundError`)。 + +- [ ] **Step 3: 实现汇总脚本** + +`scripts/summarize_input_api.py`: + +```python +"""Summarize InputApiProbe CSVs and judge each candidate against its baseline (stdlib only).""" +import argparse +import csv +import hashlib +import json +import math +from pathlib import Path + +THRESHOLD_US = 100.0 +MIN_ROUNDS = 5 +MIN_IMPROVED_ROUNDS = 4 +F24 = 0x87 + + +def stats(values): + """Nearest-rank percentiles; None when there are no samples.""" + values = sorted(values) + if not values: + return None + + def rank(p): + return values[math.ceil(p * len(values)) - 1] + + return {"n": len(values), "p50": rank(.5), "p95": rank(.95), "p99": rank(.99), + "p999": rank(.999), "max": values[-1]} + + +def build_group(records): + """records: (round, latency_us, count). count 0 = lost, >1 = duplicate; latency counts when count >= 1.""" + lost = duplicates = 0 + pooled, by_round = [], {} + for round_number, latency, count in records: + if count == 0: + lost += 1 + continue + if count > 1: + duplicates += 1 + pooled.append(latency) + by_round.setdefault(round_number, []).append(latency) + return {"pooled": stats(pooled), + "rounds": [{"round": r, **stats(v)} for r, v in sorted(by_round.items())], + "lost": lost, "duplicates": duplicates} + + +def verdict(base, cand, threshold_us=THRESHOLD_US): + """candidate | no-net-benefit | inconclusive | invalid, per the design's decision gate.""" + if cand["lost"] or cand["duplicates"] or cand["pooled"] is None: + return "invalid" + if base["pooled"] is None or len(base["rounds"]) < MIN_ROUNDS or len(cand["rounds"]) < MIN_ROUNDS: + return "inconclusive" + base_p50 = [r["p50"] for r in base["rounds"]] + if max(base_p50) - min(base_p50) >= threshold_us: + return "inconclusive" + cand_by_round = {r["round"]: r for r in cand["rounds"]} + improved = sum(1 for r in base["rounds"] + if r["round"] in cand_by_round and cand_by_round[r["round"]]["p50"] < r["p50"]) + better = (base["pooled"]["p50"] - cand["pooled"]["p50"] >= threshold_us + and cand["pooled"]["p99"] <= base["pooled"]["p99"] + and cand["pooled"]["max"] <= base["pooled"]["max"] + and improved >= MIN_IMPROVED_ROUNDS) + return "candidate" if better else "no-net-benefit" + + +def read_rows(path): + with path.open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + if not rows: + raise ValueError(f"Empty CSV: {path}") + return rows + + +def fingerprint(path): + return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + + +def summarize_capture(path): + records = {} + for row in read_rows(path): + count = int(row["captures"]) + capture = int(row["capture_qpc"]) + if count and capture <= 0: + count = 0 + latency = (capture - int(row["send_qpc"])) * 1e6 / int(row["frequency"]) if count else 0.0 + records.setdefault(row["strategy"], []).append((int(row["round"]), latency, count)) + return {**fingerprint(path), "groups": {k: build_group(v) for k, v in records.items()}} + + +def summarize_inject(path): + down, up = {}, {} + for row in read_rows(path): + key = f"{row['variant']}|{row['hook']}" + freq = int(row["frequency"]) + round_number = int(row["round"]) + count = int(row["down_count"]) if int(row["vk"]) == F24 else 0 + down.setdefault(key, []).append( + (round_number, (int(row["recv_down_qpc"]) - int(row["send_down_qpc"])) * 1e6 / freq if count else 0.0, count)) + up_count = int(row["up_count"]) if count else 0 + up.setdefault(key, []).append( + (round_number, (int(row["recv_up_qpc"]) - int(row["send_up_qpc"])) * 1e6 / freq if up_count else 0.0, up_count)) + return {**fingerprint(path), + "groups": {k: build_group(v) for k, v in down.items()}, + "up_groups": {k: build_group(v) for k, v in up.items()}} + + +def summarize_paired(path, baseline): + """Per-strategy delta (strategy - baseline, microseconds) for the nth capture of each strategy.""" + captures = {} + freq = 1 + for row in read_rows(path): + freq = int(row["frequency"]) + captures.setdefault(row["strategy"], {})[int(row["sample"])] = int(row["capture_qpc"]) + base = captures[baseline] + result = {} + for strategy, values in captures.items(): + if strategy == baseline: + continue + deltas = [(values[i] - base[i]) * 1e6 / freq for i in sorted(values) if i in base] + result[strategy] = {**(stats(deltas) or {"n": 0}), "count_mismatch": len(values) != len(base)} + return result + + +def judge(groups, baseline): + return {key: verdict(groups[baseline], value) for key, value in groups.items() if key != baseline} + + +def print_table(title, groups, verdicts, baseline): + print(title) + print(f"{'candidate':28} {'n':>6} {'lost':>5} {'dup':>4} {'p50':>9} {'p95':>9} {'p99':>9} {'p99.9':>9} {'max':>9} verdict") + for key, value in groups.items(): + s = value["pooled"] or {} + label = "baseline" if key == baseline else verdicts.get(key, "") + print(f"{key:28} {s.get('n', 0):>6} {value['lost']:>5} {value['duplicates']:>4} " + f"{s.get('p50', float('nan')):>9.1f} {s.get('p95', float('nan')):>9.1f} {s.get('p99', float('nan')):>9.1f} " + f"{s.get('p999', float('nan')):>9.1f} {s.get('max', float('nan')):>9.1f} {label}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + for name in ("capture", "inject", "paired"): + p = sub.add_parser(name) + p.add_argument("csv", type=Path) + p.add_argument("--baseline", default={"capture": "ll-hook", "inject": "vk|none", "paired": "ll-hook"}[name]) + p.add_argument("--output", type=Path) + args = parser.parse_args() + if args.command == "paired": + result = {**fingerprint(args.csv), "baseline": args.baseline, "deltas_us": summarize_paired(args.csv, args.baseline)} + for key, value in result["deltas_us"].items(): + print(key, value) + else: + result = summarize_capture(args.csv) if args.command == "capture" else summarize_inject(args.csv) + result["baseline"] = args.baseline + result["verdicts"] = judge(result["groups"], args.baseline) + print_table(f"{args.command}: time from send to {'capture' if args.command == 'capture' else 'receiver WM_KEYDOWN'} (us)", + result["groups"], result["verdicts"], args.baseline) + if args.command == "inject": + result["up_verdicts"] = judge(result["up_groups"], args.baseline) + print_table("inject: key-up send to receiver WM_KEYUP (us)", result["up_groups"], result["up_verdicts"], args.baseline) + if args.output: + args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `python -m unittest discover -s tests -p "test_input_api_summary.py" -v` +Expected: 全部用例 `ok`,末尾 `OK`。若 `test_needs_four_improved_rounds` 的第一个断言失败,检查 `verdict` 里 `better` 是否同时要求 `improved >= MIN_IMPROVED_ROUNDS`(该用例候选逐轮 P50 只有三轮低于基线)。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/summarize_input_api.py tests/test_input_api_summary.py +git commit -m "Add input API summary script with verdict gate and tests" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: 四种捕获方式 + +**Files:** +- Create: `src/tools/InputApiProbe/Capture/ICaptureStrategy.cs` +- Create: `src/tools/InputApiProbe/Capture/LowLevelHookCapture.cs` +- Create: `src/tools/InputApiProbe/Capture/RawInputCapture.cs` +- Create: `src/tools/InputApiProbe/Capture/RegisterHotKeyCapture.cs` +- Create: `src/tools/InputApiProbe/Capture/PollingCapture.cs` + +- [ ] **Step 1: 接口** + +`Capture/ICaptureStrategy.cs`: + +```csharp +namespace InputApiProbe; + +internal interface ICaptureStrategy : IDisposable +{ + string Id { get; } + + /// + /// Raised on the strategy's own thread with the QPC taken at the first instruction of its callback + /// (for polling: right after the call that observed the key down) when the key goes down. + /// + event Action? Captured; + + void Start(); + void Stop(); +} +``` + +- [ ] **Step 2: A0 低层钩子(生产类)** + +`Capture/LowLevelHookCapture.cs`: + +```csharp +using MacroHid.Core; +using MacroStudio; + +namespace InputApiProbe; + +/// A0 baseline: the production GlobalKeyboardHook (low-level keyboard + mouse hook on an AboveNormal dispatcher thread). +internal sealed class LowLevelHookCapture(ushort vk) : ICaptureStrategy +{ + private GlobalKeyboardHook? hook; + + public string Id => "ll-hook"; + public event Action? Captured; + + public void Start() + { + hook = new GlobalKeyboardHook { ProbeInjectedInputTag = new IntPtr((long)Native.Tag) }; + hook.ProbeInputObserved += (key, down, tick) => { if (key == vk && down) Captured?.Invoke(tick); }; + hook.Start(new HotkeyGesture(HidModifier.None, HidKey.F8)); + } + + public void Stop() { hook?.Dispose(); hook = null; } + public void Dispose() => Stop(); +} +``` + +- [ ] **Step 3: A1 Raw Input** + +`Capture/RawInputCapture.cs`: + +```csharp +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A1: Raw Input keyboard with RIDEV_INPUTSINK on a hidden window (cannot swallow keys). +internal sealed class RawInputCapture(ushort vk) : ICaptureStrategy +{ + private const uint WmInput = 0x00FF, RidInput = 0x10000003, RidevRemove = 0x1, RidevInputSink = 0x100; + private const int HeaderSize = 24; // RAWINPUTHEADER on x64; RAWKEYBOARD follows + + [StructLayout(LayoutKind.Sequential)] + private struct RawInputDevice { public ushort UsagePage, Usage; public uint Flags; public IntPtr Target; } + + private readonly byte[] buffer = new byte[64]; + private MessageWindow? window; + + public string Id => "raw-input"; + public event Action? Captured; + + public void Start() + { + window = new MessageWindow(OnMessage, visible: false, "raw-input", onCreated: hwnd => + { + var device = new RawInputDevice { UsagePage = 1, Usage = 6, Flags = RidevInputSink, Target = hwnd }; + if (!RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf())) + throw new InvalidOperationException($"RegisterRawInputDevices failed, error={Marshal.GetLastWin32Error()}"); + }); + } + + private bool OnMessage(uint message, IntPtr w, IntPtr l) + { + if (message != WmInput) return false; + long tick = Stopwatch.GetTimestamp(); + uint size = (uint)buffer.Length; + if (GetRawInputData(l, RidInput, buffer, ref size, (uint)HeaderSize) > 0) + { + ushort flags = BitConverter.ToUInt16(buffer, HeaderSize + 2); // RAWKEYBOARD.Flags, bit 0 = RI_KEY_BREAK + ushort key = BitConverter.ToUInt16(buffer, HeaderSize + 6); // RAWKEYBOARD.VKey + if (key == vk && (flags & 1) == 0) Captured?.Invoke(tick); + } + return false; // let DefWindowProc do the required WM_INPUT cleanup + } + + public void Stop() + { + var device = new RawInputDevice { UsagePage = 1, Usage = 6, Flags = RidevRemove, Target = IntPtr.Zero }; + if (window is not null) RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf()); + window?.Dispose(); + window = null; + } + + public void Dispose() => Stop(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices(RawInputDevice[] devices, uint count, uint size); + [DllImport("user32.dll")] + private static extern uint GetRawInputData(IntPtr rawInput, uint command, byte[] data, ref uint size, uint headerSize); +} +``` + +- [ ] **Step 4: A2 RegisterHotKey** + +`Capture/RegisterHotKeyCapture.cs`: + +```csharp +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A2: RegisterHotKey on a hidden window. Down events only; the key is consumed by the system. +internal sealed class RegisterHotKeyCapture(ushort vk) : ICaptureStrategy +{ + private const uint WmHotKey = 0x0312, ModNoRepeat = 0x4000; + private MessageWindow? window; + + public string Id => "register-hotkey"; + public event Action? Captured; + + public void Start() + { + window = new MessageWindow(OnMessage, visible: false, "register-hotkey", onCreated: hwnd => + { + if (!RegisterHotKey(hwnd, 1, ModNoRepeat, vk)) + throw new InvalidOperationException($"RegisterHotKey failed, error={Marshal.GetLastWin32Error()}"); + }); + } + + private bool OnMessage(uint message, IntPtr w, IntPtr l) + { + if (message != WmHotKey) return false; + Captured?.Invoke(Stopwatch.GetTimestamp()); + return true; + } + + public void Stop() { window?.Dispose(); window = null; } + public void Dispose() => Stop(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hwnd, int id, uint modifiers, uint vk); +} +``` + +- [ ] **Step 5: A3 轮询** + +`Capture/PollingCapture.cs`: + +```csharp +using System.Diagnostics; + +namespace InputApiProbe; + +/// A3: dedicated thread polling GetAsyncKeyState. periodMs = 0 spins continuously; otherwise sleeps periodMs between polls. +internal sealed class PollingCapture(ushort vk, int periodMs) : ICaptureStrategy +{ + private Thread? thread; + private volatile bool stop; + + public string Id => periodMs == 0 ? "poll-spin" : $"poll-{periodMs}ms"; + public event Action? Captured; + + public void Start() + { + stop = false; + thread = new Thread(Loop) { IsBackground = true, Name = "ApiProbe-" + Id, Priority = ThreadPriority.AboveNormal }; + thread.Start(); + } + + private void Loop() + { + bool wasDown = (Native.GetAsyncKeyState(vk) & 0x8000) != 0; + while (!stop) + { + bool down = (Native.GetAsyncKeyState(vk) & 0x8000) != 0; + long tick = Stopwatch.GetTimestamp(); + if (down && !wasDown) Captured?.Invoke(tick); + wasDown = down; + if (periodMs > 0) Thread.Sleep(periodMs); + } + } + + public void Stop() { stop = true; thread?.Join(1000); thread = null; } + public void Dispose() => Stop(); +} +``` + +- [ ] **Step 6: 构建并提交** + +Run: `dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release` +Expected: `0 Error(s)`。 + +```bash +git add src/tools/InputApiProbe +git commit -m "Add four capture strategies to InputApiProbe" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: 实验 A 单独组与 provenance + +**Files:** +- Create: `src/tools/InputApiProbe/Provenance.cs` +- Create: `src/tools/InputApiProbe/Capture/CaptureExperiment.cs` +- Modify: `src/tools/InputApiProbe/Program.cs` + +- [ ] **Step 1: `Provenance.cs`** + +```csharp +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; + +namespace InputApiProbe; + +internal static class Provenance +{ + public static void Write(string path, string[] args) + { + string code = typeof(Provenance).Assembly.Location; + var data = new Dictionary + { + ["date_utc"] = DateTime.UtcNow.ToString("o"), + ["args"] = args, + ["os"] = RuntimeInformation.OSDescription, + ["cpu"] = Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER"), + ["logical_processors"] = Environment.ProcessorCount, + ["runtime"] = RuntimeInformation.FrameworkDescription, + ["timer_resolution_request_ms"] = 1, + ["power_scheme"] = Run("powercfg", "/getactivescheme"), + ["assembly"] = Path.GetFileName(code), + ["assembly_sha256"] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(code))), + ["note"] = "Latency of synthetic input only; see docs/superpowers/specs/2026-10-15-input-api-comparison-design.md for limits." + }; + File.WriteAllText(path, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } + + private static string Run(string file, string arguments) + { + try + { + using var process = Process.Start(new ProcessStartInfo(file, arguments) { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true })!; + string text = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + return text; + } + catch (Exception ex) { return "unavailable: " + ex.Message; } + } +} +``` + +- [ ] **Step 2: `CaptureExperiment.cs`** + +```csharp +using System.Diagnostics; +using System.Text; + +namespace InputApiProbe; + +internal static class CaptureExperiment +{ + private const int Warmup = 20; + + private static readonly (string Id, Func Create)[] Candidates = + { + ("ll-hook", () => new LowLevelHookCapture(Native.VkF24)), + ("raw-input", () => new RawInputCapture(Native.VkF24)), + ("register-hotkey", () => new RegisterHotKeyCapture(Native.VkF24)), + ("poll-spin", () => new PollingCapture(Native.VkF24, 0)), + ("poll-1ms", () => new PollingCapture(Native.VkF24, 1)), + }; + + /// Injects F24 down, waits for the strategy to report it, then F24 up. + private sealed class TriggerProbe + { + private readonly ManualResetEventSlim signal = new(false); + private long capturedTick; + private int count; + + public void OnCaptured(long tick) + { + if (Interlocked.Increment(ref count) == 1) { Volatile.Write(ref capturedTick, tick); signal.Set(); } + } + + public (long Send, long Capture, int Count) Fire() + { + signal.Reset(); Volatile.Write(ref capturedTick, 0); Interlocked.Exchange(ref count, 0); + long send = Stopwatch.GetTimestamp(); + Native.KeyDown(InjectVariant.Vk); + bool seen = signal.Wait(100); + long tick = Volatile.Read(ref capturedTick); + Native.KeyUp(InjectVariant.Vk); + Thread.Sleep(10); // let late duplicates arrive before reading the count + return (send, seen ? tick : 0, Volatile.Read(ref count)); + } + } + + public static int Run(int rounds, int perRound, string output, string[] args) + { + Directory.CreateDirectory(output); + using var samples = new StreamWriter(Path.Combine(output, "capture-samples.csv"), false, new UTF8Encoding(false)); + using var blocks = new StreamWriter(Path.Combine(output, "capture-blocks.csv"), false, new UTF8Encoding(false)); + samples.WriteLine("round,strategy,sample,send_qpc,capture_qpc,captures,frequency"); + blocks.WriteLine("round,strategy,samples,lost,wall_ms,cpu_ms,error"); + Provenance.Write(Path.Combine(output, "capture-provenance.json"), args); + for (int round = 1; round <= rounds; round++) + for (int k = 0; k < Candidates.Length; k++) + { + var (id, create) = Candidates[(k + round - 1) % Candidates.Length]; // rotate the order every round + RunBlock(round, id, create, perRound, samples, blocks); + } + return 0; + } + + private static void RunBlock(int round, string id, Func create, int perRound, + StreamWriter samples, StreamWriter blocks) + { + var cpuBefore = Process.GetCurrentProcess().TotalProcessorTime; + var wall = Stopwatch.StartNew(); + int lost = 0, done = 0; + string error = ""; + try + { + using var strategy = create(); + var probe = new TriggerProbe(); + strategy.Captured += probe.OnCaptured; + strategy.Start(); + Thread.Sleep(200); // let the strategy's thread and registrations settle + int seen = 0; + for (int i = 0; i < Warmup; i++) if (probe.Fire().Count > 0) seen++; + if (seen == 0) throw new InvalidOperationException("strategy observed none of the warm-up triggers"); + for (int i = 0; i < perRound; i++) + { + var (send, capture, count) = probe.Fire(); + if (count == 0) lost++; + samples.WriteLine($"{round},{id},{i},{send},{capture},{count},{Stopwatch.Frequency}"); + done++; + } + } + catch (Exception ex) { error = ex.Message.Replace(',', ';').Replace('\r', ' ').Replace('\n', ' '); } + double cpuMs = (Process.GetCurrentProcess().TotalProcessorTime - cpuBefore).TotalMilliseconds; + blocks.WriteLine($"{round},{id},{done},{lost},{wall.ElapsedMilliseconds},{cpuMs:F0},{error}"); + samples.Flush(); blocks.Flush(); + Console.WriteLine($"round {round} {id}: n={done} lost={lost} {error}"); + } +} +``` + +- [ ] **Step 3: 在 `Program.cs` 接入 `capture`** + +把 `Program.cs` 整体替换为: + +```csharp +namespace InputApiProbe; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + string Option(string key, string fallback) + { int at = Array.IndexOf(args, key); return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; } + + Native.timeBeginPeriod(1); // stable 10ms pacing; recorded in provenance + try + { + switch (args.FirstOrDefault()) + { + case "capture": + return CaptureExperiment.Run(int.Parse(Option("--rounds", "5")), int.Parse(Option("--per-round", "2000")), + Option("--output", "tmp/input-api"), args); + default: + Console.Error.WriteLine("Usage: InputApiProbe capture|capture-paired|inject|--receiver [options]"); + return 2; + } + } + finally { Native.timeEndPeriod(1); } + } + catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } + } +} +``` + +- [ ] **Step 4: 小规模烟测(同时回答「Raw Input 是否收到合成输入」)** + +Run: +```bash +dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture --rounds 1 --per-round 50 --output tmp/input-api-smoke +``` +Expected:每个候选一行 `round 1 : n=50 lost=0`。运行期间不要碰键盘。 +- 如果 `raw-input` 一行带 `strategy observed none of the warm-up triggers`:这是真实结论(该方式看不到合成输入),记入结果文档,自动组排除它,不是工程缺陷。先排除是消息窗口类型问题:在 `RawInputCapture.Start()` 里把 `new MessageWindow(...)` 加上参数 `messageOnly: false` 重试一次,再看结果;结论以重试后的为准,两次都写进结果文档。 +- `ll-hook` 若丢失,检查是否被其他软件的钩子拦截。 + +- [ ] **Step 5: 汇总烟测数据** + +Run: `python scripts/summarize_input_api.py capture tmp/input-api-smoke/capture-samples.csv` +Expected:打印一张表,`ll-hook` 行为 baseline,其余行 verdict 为 `inconclusive`(只有一轮)。检查 `tmp/input-api-smoke/capture-blocks.csv` 的 `cpu_ms`,`poll-spin` 应明显高于其他。 + +- [ ] **Step 6: 提交** + +```bash +git add src/tools/InputApiProbe +git commit -m "Add capture experiment runner with provenance" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: 实验 A 并列组(物理按键) + +**Files:** +- Create: `src/tools/InputApiProbe/Capture/CapturePairedExperiment.cs` +- Modify: `src/tools/InputApiProbe/Program.cs` + +- [ ] **Step 1: 实现并列组** + +`Capture/CapturePairedExperiment.cs`: + +```csharp +using System.Diagnostics; +using System.Text; + +namespace InputApiProbe; + +/// All capture strategies armed at once; one physical key press reaches each of them. Records raw capture ticks only. +internal static class CapturePairedExperiment +{ + public static ushort ParseKey(string name) + { + if (name.Length >= 2 && name[0] is 'F' or 'f' && int.TryParse(name.AsSpan(1), out int n) && n is >= 1 and <= 24) + return (ushort)(0x70 + n - 1); + throw new ArgumentException($"Unsupported key '{name}', use F1..F24."); + } + + public static int Run(int samples, ushort vk, string output, string[] args) + { + Directory.CreateDirectory(output); + var candidates = new ICaptureStrategy[] + { + new LowLevelHookCapture(vk), new RawInputCapture(vk), new RegisterHotKeyCapture(vk), + new PollingCapture(vk, 0), new PollingCapture(vk, 1) + }; + var ticks = new Dictionary>(); + var armed = new List(); + foreach (var strategy in candidates) + { + try + { + var list = new List(); + strategy.Captured += tick => { lock (list) list.Add(tick); }; + strategy.Start(); + ticks[strategy.Id] = list; + armed.Add(strategy); + } + catch (Exception ex) { Console.Error.WriteLine($"{strategy.Id}: not armed ({ex.Message})"); strategy.Dispose(); } + } + try + { + Thread.Sleep(300); + Console.WriteLine($"PHYSICAL_READY: press and release the key {samples} times, about 0.5s apart. Do not touch anything else."); + var wait = Stopwatch.StartNew(); + while (wait.Elapsed.TotalSeconds < 300) + { + int first; lock (ticks["ll-hook"]) first = ticks["ll-hook"].Count; + if (first >= samples) break; + Thread.Sleep(50); + } + Thread.Sleep(500); + } + finally { foreach (var strategy in armed) strategy.Dispose(); } + using var file = new StreamWriter(Path.Combine(output, "capture-paired.csv"), false, new UTF8Encoding(false)); + file.WriteLine("sample,strategy,capture_qpc,frequency"); + foreach (var (id, list) in ticks) + lock (list) for (int i = 0; i < list.Count; i++) file.WriteLine($"{i},{id},{list[i]},{Stopwatch.Frequency}"); + Provenance.Write(Path.Combine(output, "capture-paired-provenance.json"), args); + foreach (var (id, list) in ticks) Console.WriteLine($"{id}: {list.Count} captures"); + return 0; + } +} +``` + +注意:`name[0] is 'F' or 'f' && ...` 在 C# 中 `is 'F' or 'f'` 是模式组合,优先级高于 `&&`,写法正确。 + +- [ ] **Step 2: 在 `Program.cs` 的 switch 中加入分支** + +在 `case "capture":` 之前插入: + +```csharp + case "capture-paired": + return CapturePairedExperiment.Run(int.Parse(Option("--physical", "30")), + CapturePairedExperiment.ParseKey(Option("--key", "F8")), Option("--output", "tmp/input-api"), args); +``` + +- [ ] **Step 3: 构建,并用一次短物理测试验证** + +Run: +```bash +dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture-paired --physical 5 --output tmp/input-api-smoke +python scripts/summarize_input_api.py paired tmp/input-api-smoke/capture-paired.csv +``` +Expected:提示 `PHYSICAL_READY` 后手动按 F8 五次;末尾每个策略输出 `N captures`(轮询和 `ll-hook` 应为 5,`register-hotkey` 为 5,`raw-input` 为 5 或者 0 取决于设备),汇总打印各策略相对 `ll-hook` 的差值(µs)。 + +- [ ] **Step 4: 提交** + +```bash +git add src/tools/InputApiProbe +git commit -m "Add paired physical-key capture comparison" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 6: 实验 B 接收进程与空钩子 + +**Files:** +- Create: `src/tools/InputApiProbe/Inject/ReceiverProcess.cs` +- Create: `src/tools/InputApiProbe/EmptyHook.cs` +- Modify: `src/tools/InputApiProbe/Program.cs` + +- [ ] **Step 1: 接收进程** + +共享内存布局(发送端与接收端必须一致):偏移 0 = 接收端就绪标志(int32);8 = 接收窗口句柄(int64);16 = 当前样本行号(int32,-1 表示忽略);20 = 结束标志(int32);64 起每行 32 字节:+0 按下 QPC(int64)、+8 抬起 QPC(int64)、+16 按下计数(int32)、+20 抬起计数(int32)、+24 收到的 VK(int32)。 + +`Inject/ReceiverProcess.cs`: + +```csharp +using System.Diagnostics; +using System.IO.MemoryMappedFiles; + +namespace InputApiProbe; + +/// Separate-process Win32 window. Records the QPC of the first WM_KEYDOWN / WM_KEYUP for the row the sender selected. +internal static class ReceiverProcess +{ + public const int Header = 64, Stride = 32; + public const int Ready = 0, WindowHandle = 8, CurrentRow = 16, Done = 20; + public const int DownQpc = 0, UpQpc = 8, DownCount = 16, UpCount = 20, DownVk = 24; + + public static long RowOffset(int row) => Header + (long)row * Stride; + + public static void Run(string name) + { + using var memory = MemoryMappedFile.OpenExisting(name); + using var view = memory.CreateViewAccessor(); + using var window = new MessageWindow((message, w, l) => + { + if (message is not (0x100 or 0x101)) return false; // WM_KEYDOWN / WM_KEYUP + long tick = Stopwatch.GetTimestamp(); + int row = view.ReadInt32(CurrentRow); + if (row < 0) return false; + bool down = message == 0x100; + long at = RowOffset(row); + long countAt = at + (down ? DownCount : UpCount); + int previous = view.ReadInt32(countAt); + view.Write(countAt, previous + 1); + if (previous == 0) + { + view.Write(at + (down ? DownQpc : UpQpc), tick); + if (down) view.Write(at + DownVk, (int)w.ToInt64()); + } + return false; + }, visible: true, "MacroHID input API probe receiver", onCreated: hwnd => + { + view.Write(WindowHandle, hwnd.ToInt64()); + view.Write(Ready, 1); + Native.SetForegroundWindow(hwnd); + }); + while (view.ReadInt32(Done) == 0) Thread.Sleep(100); + } +} +``` + +- [ ] **Step 2: 空钩子** + +`EmptyHook.cs`: + +```csharp +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A do-nothing WH_KEYBOARD_LL hook on its own message thread: the lower bound of what an active hook adds to the input path. +internal sealed class EmptyHook : IDisposable +{ + private delegate IntPtr HookProc(int code, IntPtr w, IntPtr l); + + private readonly HookProc proc = (code, w, l) => CallNextHookEx(IntPtr.Zero, code, w, l); + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(); + private uint threadId; + private bool installed; + + public EmptyHook() + { + thread = new Thread(Run) { IsBackground = true, Name = "ApiProbe-empty-hook", Priority = ThreadPriority.AboveNormal }; + thread.Start(); + ready.Wait(); + if (!installed) throw new InvalidOperationException("Could not install the empty low-level hook"); + } + + private void Run() + { + threadId = Native.GetCurrentThreadId(); + IntPtr hook = SetWindowsHookEx(13, proc, IntPtr.Zero, 0); + installed = hook != IntPtr.Zero; + ready.Set(); + if (!installed) return; + while (Native.GetMessage(out _, IntPtr.Zero, 0, 0) > 0) { } + UnhookWindowsHookEx(hook); + } + + public void Dispose() + { + Native.PostThreadMessage(threadId, 0x12, UIntPtr.Zero, IntPtr.Zero); // WM_QUIT + thread.Join(2000); + GC.KeepAlive(proc); + } + + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int id, HookProc proc, IntPtr module, uint thread); + [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr w, IntPtr l); +} +``` + +- [ ] **Step 3: 在 `Program.cs` 中接入 `--receiver`** + +在 `switch` 的 `default:` 之前插入: + +```csharp + case "--receiver": + ReceiverProcess.Run(args[1]); + return 0; +``` + +- [ ] **Step 4: 构建并提交** + +Run: `dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release` +Expected: `0 Error(s)`。 + +```bash +git add src/tools/InputApiProbe +git commit -m "Add Win32 receiver process and empty hook for injection experiment" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 7: 实验 B 发送端 + +**Files:** +- Create: `src/tools/InputApiProbe/Inject/InjectExperiment.cs` +- Modify: `src/tools/InputApiProbe/Program.cs` + +- [ ] **Step 1: 发送端** + +`Inject/InjectExperiment.cs`: + +```csharp +using System.Diagnostics; +using System.IO.MemoryMappedFiles; +using System.Text; + +namespace InputApiProbe; + +internal static class InjectExperiment +{ + private const int Warmup = 20; + + private static readonly (string Id, InjectVariant Variant)[] Variants = + { + ("vk", InjectVariant.Vk), ("scan", InjectVariant.Scan), ("vk-scan", InjectVariant.VkScan), + ("keybd-event", InjectVariant.KeybdEvent), ("merged-pair", InjectVariant.MergedPair), + }; + + public static int Run(int rounds, int perBlock, string output, string[] args) + { + Directory.CreateDirectory(output); + // Native.ScanF24 == 0 means this layout has no scan code for F24; the scan variants cannot be tested. + var variants = Variants.Where(v => Native.ScanF24 != 0 || v.Variant is InjectVariant.Vk or InjectVariant.MergedPair).ToArray(); + var conditions = variants.SelectMany(v => new[] { (v.Id, v.Variant, Hook: false), (v.Id, v.Variant, Hook: true) }).ToArray(); + long totalRows = (long)rounds * conditions.Length * perBlock; + string name = "Local\\MacroHID-ApiProbe-" + Guid.NewGuid().ToString("N"); + using var memory = MemoryMappedFile.CreateNew(name, ReceiverProcess.Header + totalRows * ReceiverProcess.Stride); + using var view = memory.CreateViewAccessor(); + view.Write(ReceiverProcess.CurrentRow, -1); + + var info = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false }; + info.ArgumentList.Add("--receiver"); info.ArgumentList.Add(name); + using var receiver = Process.Start(info) ?? throw new InvalidOperationException("Cannot start receiver"); + using var csv = new StreamWriter(Path.Combine(output, "inject-samples.csv"), false, new UTF8Encoding(false)); + csv.WriteLine("round,variant,hook,sample,send_down_qpc,send_up_qpc,recv_down_qpc,recv_up_qpc,down_count,up_count,vk,frequency"); + Provenance.Write(Path.Combine(output, "inject-provenance.json"), args); + try + { + var start = Stopwatch.StartNew(); + while (view.ReadInt32(ReceiverProcess.Ready) == 0 && start.Elapsed.TotalSeconds < 30) Thread.Sleep(20); + IntPtr target = new(view.ReadInt64(ReceiverProcess.WindowHandle)); + if (target == IntPtr.Zero) throw new InvalidOperationException("Receiver did not start"); + Console.WriteLine("WINDOW_READY: click the receiver window to bring it to the foreground, then do not touch anything."); + while (Native.GetForegroundWindow() != target && start.Elapsed.TotalSeconds < 300) + { if (receiver.HasExited) throw new InvalidOperationException("Receiver closed"); Thread.Sleep(50); } + if (Native.GetForegroundWindow() != target) throw new TimeoutException("Receiver never became the foreground window"); + + int row = 0; + for (int round = 1; round <= rounds; round++) + for (int k = 0; k < conditions.Length; k++) + { + var (id, variant, hook) = conditions[(k + round - 1) % conditions.Length]; + using var hookScope = hook ? new EmptyHook() : null; + Thread.Sleep(100); + for (int i = 0; i < Warmup; i++) Sample(view, target, variant, -1); + for (int i = 0; i < perBlock; i++) + { + var s = Sample(view, target, variant, row); + long at = ReceiverProcess.RowOffset(row); + csv.WriteLine($"{round},{id},{(hook ? "ll-empty" : "none")},{i},{s.SendDown},{s.SendUp}," + + $"{view.ReadInt64(at + ReceiverProcess.DownQpc)},{view.ReadInt64(at + ReceiverProcess.UpQpc)}," + + $"{view.ReadInt32(at + ReceiverProcess.DownCount)},{view.ReadInt32(at + ReceiverProcess.UpCount)}," + + $"{view.ReadInt32(at + ReceiverProcess.DownVk)},{Stopwatch.Frequency}"); + row++; + } + csv.Flush(); + Console.WriteLine($"round {round} {id} hook={(hook ? "ll-empty" : "none")}: done"); + } + return 0; + } + finally + { + view.Write(ReceiverProcess.Done, 1); + if (!receiver.WaitForExit(5000)) receiver.Kill(); + } + } + + /// Sends F24 down, waits for the receiver, sends F24 up, waits for the receiver. row -1 = warm-up (receiver ignores it). + private static (long SendDown, long SendUp) Sample(MemoryMappedViewAccessor view, IntPtr target, InjectVariant variant, int row) + { + if (Native.GetForegroundWindow() != target) throw new InvalidOperationException("Receiver lost foreground focus; stopped before further injection."); + long at = row >= 0 ? ReceiverProcess.RowOffset(row) : 0; + view.Write(ReceiverProcess.CurrentRow, row); + long sendDown = Stopwatch.GetTimestamp(); + Native.KeyDown(variant); + if (row >= 0) WaitFor(view, at + ReceiverProcess.DownCount); else Thread.Sleep(5); + long sendUp = variant == InjectVariant.MergedPair ? sendDown : Stopwatch.GetTimestamp(); + Native.KeyUp(variant); + if (row >= 0) WaitFor(view, at + ReceiverProcess.UpCount); else Thread.Sleep(5); + Thread.Sleep(10); + view.Write(ReceiverProcess.CurrentRow, -1); + return (sendDown, sendUp); + } + + private static void WaitFor(MemoryMappedViewAccessor view, long countAt) + { + var wait = Stopwatch.StartNew(); + while (view.ReadInt32(countAt) == 0 && wait.ElapsedMilliseconds < 100) Thread.Yield(); + } +} +``` + +- [ ] **Step 2: 在 `Program.cs` 接入 `inject`** + +在 `case "--receiver":` 之前插入: + +```csharp + case "inject": + return InjectExperiment.Run(int.Parse(Option("--rounds", "5")), int.Parse(Option("--per-block", "2000")), + Option("--output", "tmp/input-api"), args); +``` + +- [ ] **Step 3: 构建并烟测** + +Run: +```bash +dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe inject --rounds 1 --per-block 50 --output tmp/input-api-smoke +python scripts/summarize_input_api.py inject tmp/input-api-smoke/inject-samples.csv +``` +Expected:出现 `WINDOW_READY` 后点击接收窗口,然后不要碰键鼠;十个条件各输出一行 `done`;汇总表里每个条件 `lost=0 dup=0`,延迟为几百 µs 到几 ms 量级,`verdict` 为 `inconclusive`(只有一轮)。若 `scan` 条件 `lost` 等于样本数,说明扫描码没有被翻译成 VK 0x87,属于有效结论,写入结果文档,不是工程缺陷。 + +- [ ] **Step 4: 提交** + +```bash +git add src/tools/InputApiProbe +git commit -m "Add injection experiment sender" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 8: 正式测量、结果文档与收尾 + +**Files:** +- Create: `docs/input-api-comparison-2026-10-15.md` +- Modify: `docs/current-precision-status.md`(顶部指针一行) + +本任务需要用户在场:接收窗口需要点击置前,测量期间不能碰键鼠或做其他 CPU 工作。执行前先向用户确认时间窗(总时长约 20–40 分钟)。 + +- [ ] **Step 1: 记录环境并运行实验 A 单独组** + +Run: +```bash +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture --rounds 5 --per-round 2000 --output tmp/input-api-20261015 +python scripts/summarize_input_api.py capture tmp/input-api-20261015/capture-samples.csv --output tmp/input-api-20261015/capture-summary.json +``` +Expected:每个候选五轮 `n=2000`;汇总表给出每个候选 verdict。`capture-blocks.csv` 保存 CPU 时间。 + +- [ ] **Step 2: 运行实验 B** + +Run: +```bash +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe inject --rounds 5 --per-block 2000 --output tmp/input-api-20261015 +python scripts/summarize_input_api.py inject tmp/input-api-20261015/inject-samples.csv --output tmp/input-api-20261015/inject-summary.json +``` +Expected:10 个条件 × 5 轮,每块 2,000 样本。以 `vk|none` 为基线,其余条件(含 `vk|ll-empty`,即钩子税)得到 verdict。 + +- [ ] **Step 3: 并列组物理按键** + +Run: +```bash +src/tools/InputApiProbe/bin/Release/net8.0-windows/InputApiProbe.exe capture-paired --physical 30 --output tmp/input-api-20261015 +python scripts/summarize_input_api.py paired tmp/input-api-20261015/capture-paired.csv --output tmp/input-api-20261015/paired-summary.json +``` +Expected:用户按 30 次 F8;各策略相对 `ll-hook` 的先后差值与 `count_mismatch` 标志。 + +- [ ] **Step 4: 写结果文档** + +`docs/input-api-comparison-2026-10-15.md` 必须包含,且数字全部取自 `tmp/input-api-20261015/*.json` 与 `*-blocks.csv`,不得凭印象填写: + +1. 环境与限制:系统、CPU、电源方案(来自 `*-provenance.json`),合成输入与物理键盘路径不同,结果仅用于候选间比较。 +2. 实验 A 单独组表:每个候选的 N、丢失、重复、P50、P95、P99、P99.9、最大、五轮 P50 极差、CPU 毫秒/墙钟毫秒、verdict。 +3. 实验 B 表:十个条件同样的列,加上抬起事件延迟;单独一行给出「钩子税」= `vk|ll-empty` 相对 `vk|none` 的 P50/P99 差。 +4. 并列组表:各策略相对 `ll-hook` 的先后差分位数。 +5. 语义兼容表:吞键、区分注入与物理、释放事件、鼠标键、修饰键组合、是否需要消息泵,逐候选填写(依据 Win32 文档与本次观察,观察不到的写「未验证」)。 +6. 结论:列出 verdict 为 `candidate` 的方案与它们的语义缺口;`no-net-benefit`、`inconclusive`、`invalid` 的一并保留并说明;明确声明未测游戏内延迟,不承诺任何改善。 +7. 后续:若有 `candidate`,下一步是单独设计其接入;驱动级注入另建新仓库的建议保留在这里(见设计文档范围外说明)。 + +- [ ] **Step 5: 更新状态文档指针** + +在 `docs/current-precision-status.md` 第 7 行(以「三档策略、准备缓存」开头的段落)之前新增一段: + +```markdown +2026-10-15 输入 API 对照(低层钩子 / Raw Input / RegisterHotKey / 轮询,以及 SendInput 各写法)的结果见 [输入 API 对照](input-api-comparison-2026-10-15.md)。仅比较合成输入的候选方案,不是游戏内延迟结论。 + +``` + +- [ ] **Step 6: 全量检查并提交** + +Run: +```bash +python -m unittest discover -s tests -p "test_input_api_summary.py" +dotnet build src/tools/InputApiProbe/InputApiProbe.csproj -c Release +git status --short +``` +Expected:unittest `OK`;构建 `0 Error(s)`;`git status` 只显示 `docs/` 两个文件与(若有)已忽略的 `tmp/` 之外无多余改动(`tmp/` 已被 `.gitignore` 忽略,原始 CSV 保留在本地,不提交)。 + +```bash +git add docs/input-api-comparison-2026-10-15.md docs/current-precision-status.md +git commit -m "Record input API comparison results" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +- [ ] **Step 7: 更新记忆并向用户汇报** + +更新 `latency-api-comparison-plan` 记忆:写入实验结论与「驱动级注入另建仓库」的时机(是否进入、仓库名待确认)。向用户汇报 verdict 为 `candidate` 的方案和被排除方案的原因。 diff --git a/docs/superpowers/specs/2026-10-15-input-api-comparison-design.md b/docs/superpowers/specs/2026-10-15-input-api-comparison-design.md new file mode 100644 index 0000000..5b08196 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-input-api-comparison-design.md @@ -0,0 +1,83 @@ +# 输入 API 对照实验设计 + +日期:2026-10-15。承接 `docs/single-macro-tail-and-end-to-end-2026-10-14.md` 的「下一步」与 `docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md` 的遗留项。 + +## 目的与边界 + +调度器本身已到微秒级,端到端的毫秒级耗时集中在调度器之外:源按键到钩子入口约 0.46ms,钩子入口到 `CallNextHookEx` 返回约 0.87ms,钩子返回到目标 `WM_KEYDOWN` 约 2.9ms(Win32 自动组 P50)。本实验用同一把尺子比较**不同的用户态输入 API**,判断哪些值得在产品里单独设计接入。 + +范围内:触发捕获侧与注入侧的用户态 API 对照。 +范围外,本轮不做: + +- 产品代码的任何默认行为改动。实验只新增独立探针工具与文档。 +- 驱动级注入(VHF、过滤驱动)。本轮出结果后,另建独立仓库研究,不放进本仓库。 +- 绕过公开接口的直接系统调用;`PostMessage` 类后台输入(语义不同,不是同类对照)。 + +## 工具 + +新增 `src/tools/InputApiProbe`(.NET 8 Windows 控制台,含一个 Win32 接收子进程模式)。不改 `EndToEndProbe`,该工具与宏引擎和 WPF 接收端耦合较深,本实验不需要宏引擎。所有计时用 `Stopwatch.GetTimestamp()`(QPC),同机同时基,保留全部样本与异常值,分位数用 nearest-rank。 + +## 实验 A:触发捕获 + +测量量:`t_capture - t_send`,`t_send` 是发送端调用 `SendInput` 之前取的 QPC,`t_capture` 是各捕获方式回调或消息处理入口的第一个 QPC。携带专属 `dwExtraInfo` 标记的注入 F8 作为触发源。 + +候选: + +| 编号 | 方式 | 备注 | +|---|---|---| +| A0 | `WH_KEYBOARD_LL`(基线,复用生产 `GlobalKeyboardHook` 源码) | 对照组,能吞键 | +| A1 | Raw Input:`RegisterRawInputDevices` + `RIDEV_INPUTSINK`,独立高优先级消息线程 | 不能吞键;先确认能否收到合成输入 | +| A2 | `RegisterHotKey` | 只支持修饰键+主键的标准组合;没有释放事件 | +| A3 | 专用线程轮询 `GetAsyncKeyState`,两个变体:持续自旋(poll-spin,占满一个核)与 1ms 睡眠轮询(poll-1ms,配合 `timeBeginPeriod(1)`) | 记录 CPU 占用;按下时间短于轮询周期会漏掉 | + +先决检查:A1 与 A3 是否能看到 `SendInput` 产生的合成按键。看不到的候选,自动测试组排除并在结果中说明,只留物理按键组。 + +方法: + +- 触发键使用 F24(VK 0x87)而非 F8,避免对前台程序产生副作用;物理并列组按 `--key` 指定的键(默认 F8)。触发为「按下 → 等待捕获 → 抬起」,不合并,因为轮询方式会漏掉瞬间合并的按下抬起。 +- **单独组(主结果)**:每次只启用一个候选。五轮,每轮对每个候选各 2,000 次触发,候选顺序每轮轮换。触发间隔 10ms,以免相互影响。 +- **并列组(辅助)**:所有候选同时启用,同一次按键,比较各自到达的先后差。启用多个候选会互相拖慢,所以只用于物理按键 30 次的相对先后,不与单独组的绝对值混用。 +- 每个候选独立报告 N、P50、P95、P99、P99.9、max、丢失与重复次数。 + +## 实验 B:注入到接收 + +测量量:`t_receive - t_send_begin`,接收端为独立进程的原生 Win32 `GetMessage` 窗口,记录前台时 `WM_KEYDOWN` 的 QPC。发送端自己不安装钩子,除非该组变量要求。 + +候选: + +| 编号 | 方式 | +|---|---| +| B0 | `SendInput`,虚拟键码(基线,对应现有生产路径) | +| B1 | `SendInput`,`KEYEVENTF_SCANCODE` | +| B2 | `SendInput`,虚拟键码加扫描码 | +| B3 | `keybd_event` | +| B4 | `SendInput` 按下+抬起合并为一次调用,与分两次调用对比(按下和抬起之间的间隔也一并记录) | + +附加因子:发送进程内是否同时安装 `WH_KEYBOARD_LL` 空钩子(只调用 `CallNextHookEx`)。用来量化「钩子税」:钩子入口到返回约 0.87ms 是否由系统路由中的钩子本身引入,而不是目标慢。 + +方法:每个候选五轮,每轮 2,000 次,顺序轮换,因子在每个候选内交替。接收端校验次数,不把 `SendInput` 返回值当作接收。失焦或缺失接收则中止并保留记录。 + +## 输出与判定 + +- 原始 CSV:`tmp/input-api-20261015/`,含每次的 QPC 字段、候选编号、轮次、因子。 +- 汇总脚本沿用 `scripts/summarize_end_to_end.py` 的统计口径,新增 `scripts/summarize_input_api.py`。 +- 结果文档:`docs/input-api-comparison-2026-10-15.md`,同步更新 `docs/current-precision-status.md` 的指针。文档保留未通过和更差的候选,说明机器、系统版本和负载状态。 +- 语义兼容表(写入结果文档):是否能吞键、是否能区分注入与物理、释放事件、鼠标键、修饰键组合、是否需要消息泵。 + +判定门槛,满足才进入「值得接入」名单,之后单独设计: + +1. 相比基线,P50 改善不少于 100µs,且 P99 与 max 没有变差; +2. 五轮中至少四轮方向一致; +3. 语义兼容表中没有生产必须的功能缺失(尤其是吞键),或者可以与现有路径并用来补足。 +4. 基线自身各轮 P50 的极差不小于 100µs 时,该组判为「无结论」(底噪大于要检测的改善量);候选出现任何丢失或重复事件,判为「无效」,不进入名单。 + +注入实验中,每个变体与同一钩子条件下的 `vk` 比较(`scan|ll-empty` 对 `vk|ll-empty`),钩子本身的代价(hook tax)单独报告;汇总样本上的「max 不变差」只是单个样本的比较,会照常报告,但噪声较大。 + +否则记为「无净收益」,不接入。机器底噪太大无法分辨的,写明「无结论」,不硬下结论。 + +## 风险与注意 + +- 合成输入的处理路径与物理键盘不完全相同,实验 A 的绝对值只适合候选之间比较,不代表物理键盘到回调的延迟。 +- 轮询方案会占 CPU,需在结果里同时给出 CPU 时间。 +- 测量期间不得同时构建或跑其他基准;同一环境、电源方案与 Turbo 设置,记录在 provenance 文件里。 +- 实验结果不构成对游戏内输入延迟的承诺。游戏帧时间与真实游戏消费仍需另测。 diff --git a/scripts/summarize_input_api.py b/scripts/summarize_input_api.py new file mode 100644 index 0000000..ba69b4e --- /dev/null +++ b/scripts/summarize_input_api.py @@ -0,0 +1,307 @@ +"""Summarize InputApiProbe CSVs and judge each candidate against its baseline (stdlib only).""" +import argparse +import csv +import hashlib +import json +import math +from pathlib import Path + +THRESHOLD_US = 100.0 +MIN_ROUNDS = 5 +MIN_IMPROVED_ROUNDS = 4 +F24 = 0x87 +PAIR_WINDOW_MS = 50.0 +KNOWN_STRATEGIES = ("ll-hook", "raw-input", "register-hotkey", "poll-spin", "poll-1ms") + + +def stats(values): + """Nearest-rank percentiles; None when there are no samples.""" + values = sorted(values) + if not values: + return None + + def rank(p): + return values[math.ceil(p * len(values)) - 1] + + return {"n": len(values), "p50": rank(.5), "p95": rank(.95), "p99": rank(.99), + "p999": rank(.999), "max": values[-1]} + + +def build_group(records): + """records: (round, latency_us, count). count 0 = lost, >1 = duplicate; latency counts when count >= 1.""" + lost = duplicates = 0 + pooled, by_round = [], {} + for round_number, latency, count in records: + if count == 0: + lost += 1 + continue + if count > 1: + duplicates += 1 + pooled.append(latency) + by_round.setdefault(round_number, []).append(latency) + return {"pooled": stats(pooled), + "rounds": [{"round": r, **stats(v)} for r, v in sorted(by_round.items())], + "lost": lost, "duplicates": duplicates} + + +def verdict(base, cand, threshold_us=THRESHOLD_US): + """candidate | no-net-benefit | inconclusive | invalid, per the design's decision gate.""" + if cand["lost"] or cand["duplicates"] or cand["pooled"] is None: + return "invalid" + if base["pooled"] is None or len(base["rounds"]) < MIN_ROUNDS or len(cand["rounds"]) < MIN_ROUNDS: + return "inconclusive" + base_p50 = [r["p50"] for r in base["rounds"]] + if max(base_p50) - min(base_p50) >= threshold_us: + return "inconclusive" + cand_by_round = {r["round"]: r for r in cand["rounds"]} + improved = sum(1 for r in base["rounds"] + if r["round"] in cand_by_round and cand_by_round[r["round"]]["p50"] < r["p50"]) + better = (base["pooled"]["p50"] - cand["pooled"]["p50"] >= threshold_us + and cand["pooled"]["p99"] <= base["pooled"]["p99"] + and cand["pooled"]["max"] <= base["pooled"]["max"] + and improved >= MIN_IMPROVED_ROUNDS) + return "candidate" if better else "no-net-benefit" + + +def read_rows(path): + with path.open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + if not rows: + raise ValueError(f"Empty CSV: {path}") + return rows + + +def fingerprint(path): + return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + + +def read_blocks(path): + """capture-blocks.csv -> per-strategy CPU/wall totals and error strings; {} when the file is absent.""" + if path is None or not path.exists(): + return {} + blocks = {} + for row in read_rows(path): + entry = blocks.setdefault(row["strategy"], {"samples": 0, "cpu_ms": 0.0, "wall_ms": 0.0, "errors": []}) + entry["samples"] += int(row["samples"]) + entry["cpu_ms"] += float(row["cpu_ms"]) + entry["wall_ms"] += float(row["wall_ms"]) + if row.get("error", "").strip(): + entry["errors"].append(row["error"].strip()) + for entry in blocks.values(): + entry["cpu_ms_per_1000"] = entry["cpu_ms"] * 1000 / entry["samples"] if entry["samples"] else None + return blocks + + +def summarize_capture(path, blocks_path=None): + records = {} + for row in read_rows(path): + count = int(row["captures"]) + capture = int(row["capture_qpc"]) + if count and capture <= 0: + count = 0 + latency = (capture - int(row["send_qpc"])) * 1e6 / int(row["frequency"]) if count else 0.0 + records.setdefault(row["strategy"], []).append((int(row["round"]), latency, count)) + if blocks_path is None: + blocks_path = path.with_name("capture-blocks.csv") + return {**fingerprint(path), "groups": {k: build_group(v) for k, v in records.items()}, + "blocks": read_blocks(blocks_path)} + + +def judge_capture(result, baseline): + verdicts = judge(result["groups"], baseline) + for strategy, entry in result["blocks"].items(): + if entry["errors"] and strategy != baseline: + verdicts[strategy] = "invalid" + return verdicts + + +def summarize_inject(path): + down, up, gap = {}, {}, {} + for row in read_rows(path): + key = f"{row['variant']}|{row['hook']}" + freq = int(row["frequency"]) + round_number = int(row["round"]) + count = int(row["down_count"]) if int(row["vk"]) == F24 else 0 + down.setdefault(key, []).append( + (round_number, (int(row["recv_down_qpc"]) - int(row["send_down_qpc"])) * 1e6 / freq if count else 0.0, count)) + up_count = int(row["up_count"]) if count else 0 + up.setdefault(key, []).append( + (round_number, (int(row["recv_up_qpc"]) - int(row["send_up_qpc"])) * 1e6 / freq if up_count else 0.0, up_count)) + gap.setdefault(key, []).append( + (round_number, (int(row["recv_up_qpc"]) - int(row["recv_down_qpc"])) * 1e6 / freq if up_count else 0.0, up_count)) + groups = {k: build_group(v) for k, v in down.items()} + return {**fingerprint(path), "groups": groups, + "up_groups": {k: build_group(v) for k, v in up.items()}, + "gap_groups": {k: build_group(v) for k, v in gap.items()}, + "hook_tax": hook_tax(groups)} + + +def hook_tax(groups): + """Per variant: pooled p50/p99 of `X|ll-empty` minus `X|none` (microseconds).""" + tax = {} + for key, value in groups.items(): + variant, _, hook = key.partition("|") + other = groups.get(f"{variant}|ll-empty") + if hook != "none" or other is None or not value["pooled"] or not other["pooled"]: + continue + tax[variant] = {"p50": other["pooled"]["p50"] - value["pooled"]["p50"], + "p99": other["pooled"]["p99"] - value["pooled"]["p99"]} + return tax + + +def judge_per_hook(groups, reference="vk", exclude=()): + """Judge each variant against `vk` under the same hook condition.""" + verdicts = {} + for key in groups: + variant, _, hook = key.partition("|") + if variant == reference or variant in exclude: + continue + base = groups.get(f"{reference}|{hook}") + verdicts[key] = verdict(base, groups[key]) if base else "inconclusive" + return verdicts + + +def judge_inject(result): + """(down verdicts, up verdicts); merged-pair up latency is measured from send_down so it is not judged.""" + return (judge_per_hook(result["groups"]), + judge_per_hook(result["up_groups"], exclude=("merged-pair",))) + + +def match_ticks(base, ticks, window): + """Greedy nearest-tick matching: each strategy tick is used at most once. Returns (deltas, unmatched_base, unmatched_strategy).""" + free = sorted(ticks) + deltas, unmatched_base = [], 0 + for b in sorted(base): + best = min(free, key=lambda t: abs(t - b), default=None) + if best is not None and abs(best - b) <= window: + deltas.append(best - b) + free.remove(best) + else: + unmatched_base += 1 + return deltas, unmatched_base, len(free) + + +def summarize_paired(path, baseline): + """Per-strategy delta (strategy - baseline, microseconds) matched by nearest tick within +-50 ms.""" + captures = {} + freq = 1 + for row in read_rows(path): + freq = int(row["frequency"]) + captures.setdefault(row["strategy"], []).append(int(row["capture_qpc"])) + if not captures.get(baseline): + raise ValueError(f"baseline {baseline} has no captures") + window = PAIR_WINDOW_MS * freq / 1000 + result = {} + for strategy in sorted(set(captures) | set(KNOWN_STRATEGIES)): + if strategy == baseline: + continue + deltas, unmatched_base, unmatched_strategy = match_ticks(captures[baseline], captures.get(strategy, []), window) + result[strategy] = {**(stats([d * 1e6 / freq for d in deltas]) or {"n": 0}), + "unmatched_baseline": unmatched_base, "unmatched_strategy": unmatched_strategy} + return result + + +def judge(groups, baseline): + return {key: verdict(groups[baseline], value) for key, value in groups.items() if key != baseline} + + +RIM_TYPEMOUSE, RIM_TYPEKEYBOARD = 0, 1 +RI_KEY_BREAK = 1 + + +def summarize_raw_diag(path, target_vk): + """raw-diag.csv -> whether WM_INPUT reached the window and, if so, what it carried. + + diagnosis is one of: received-target | received-other-keys-only | no-readable-wm-input | no-wm-input. + """ + rows = read_rows(path) + keyboard = [r for r in rows if int(r["read_ok"]) and int(r["type"]) == RIM_TYPEKEYBOARD] + by_vkey = {} + for r in keyboard: + by_vkey[int(r["vkey"])] = by_vkey.get(int(r["vkey"]), 0) + 1 + target = [r for r in keyboard if int(r["vkey"]) == target_vk] + target_up = sum(1 for r in target if int(r["flags"]) & RI_KEY_BREAK) + if target: + diagnosis = "received-target" + elif keyboard: + diagnosis = "received-other-keys-only" + elif rows: + diagnosis = "no-readable-wm-input" + else: + diagnosis = "no-wm-input" + return {**fingerprint(path), "target_vk": target_vk, "total": len(rows), + "read_failures": sum(1 for r in rows if not int(r["read_ok"])), + "non_keyboard": sum(1 for r in rows if int(r["read_ok"]) and int(r["type"]) != RIM_TYPEKEYBOARD), + "target_down": len(target) - target_up, "target_up": target_up, + "devices": len({r["device"] for r in keyboard}), "by_vkey": by_vkey, + "diagnosis": diagnosis} + + +def print_table(title, groups, verdicts, baseline): + baselines = {baseline} if isinstance(baseline, str) or baseline is None else set(baseline) + print(title) + print(f"{'candidate':28} {'n':>6} {'lost':>5} {'dup':>4} {'p50':>9} {'p95':>9} {'p99':>9} {'p99.9':>9} {'max':>9} verdict") + for key, value in groups.items(): + s = value["pooled"] or {} + label = "baseline" if key in baselines else verdicts.get(key, "") + print(f"{key:28} {s.get('n', 0):>6} {value['lost']:>5} {value['duplicates']:>4} " + f"{s.get('p50', float('nan')):>9.1f} {s.get('p95', float('nan')):>9.1f} {s.get('p99', float('nan')):>9.1f} " + f"{s.get('p999', float('nan')):>9.1f} {s.get('max', float('nan')):>9.1f} {label}") + + +def print_extras(result): + print("\ninject: hook tax (pooled, ll-empty minus none, us)") + print(f"{'variant':16} {'p50':>9} {'p99':>9}") + for variant, t in result["hook_tax"].items(): + print(f"{variant:16} {t['p50']:>9.1f} {t['p99']:>9.1f}") + print_table("\ninject: down-to-up gap (recv_up - recv_down, us)", result["gap_groups"], {}, None) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + for name in ("capture", "inject", "paired", "raw-diag"): + p = sub.add_parser(name) + p.add_argument("csv", type=Path) + if name == "raw-diag": + p.add_argument("--vk", type=lambda v: int(v, 0), default=0x77) + elif name != "inject": + p.add_argument("--baseline", default="ll-hook") + if name == "capture": + p.add_argument("--blocks", type=Path) + p.add_argument("--output", type=Path) + args = parser.parse_args() + if args.command == "raw-diag": + result = summarize_raw_diag(args.csv, args.vk) + for key, value in result.items(): + print(f"{key}: {value}") + elif args.command == "paired": + result = {**fingerprint(args.csv), "baseline": args.baseline, "deltas_us": summarize_paired(args.csv, args.baseline)} + for key, value in result["deltas_us"].items(): + print(key, value) + elif args.command == "capture": + result = summarize_capture(args.csv, args.blocks) + result["baseline"] = args.baseline + result["verdicts"] = judge_capture(result, args.baseline) + print_table("capture: time from send to capture (us)", result["groups"], result["verdicts"], args.baseline) + if not result["blocks"]: + print("note: no capture-blocks.csv found; CPU and errors not reported") + for key, b in result["blocks"].items(): + per = b["cpu_ms_per_1000"] + print(f"{key:16} cpu_ms={b['cpu_ms']:.0f} wall_ms={b['wall_ms']:.0f} " + f"cpu_ms/1000={'n/a' if per is None else format(per, '.1f')}") + for error in b["errors"]: + print(f" ERROR {key}: {error}") + else: + result = summarize_inject(args.csv) + result["verdicts"], result["up_verdicts"] = judge_inject(result) + print_table("inject: send to receiver WM_KEYDOWN (us), judged vs vk under the same hook", result["groups"], result["verdicts"], ("vk|none", "vk|ll-empty")) + up_groups = {k: v for k, v in result["up_groups"].items() if not k.startswith("merged-pair|")} + print_table("inject: key-up send to receiver WM_KEYUP (us), merged-pair excluded", up_groups, result["up_verdicts"], ("vk|none", "vk|ll-empty")) + print_extras(result) + if args.output: + args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/src/tools/InputApiProbe/Capture/CaptureExperiment.cs b/src/tools/InputApiProbe/Capture/CaptureExperiment.cs new file mode 100644 index 0000000..1cccf71 --- /dev/null +++ b/src/tools/InputApiProbe/Capture/CaptureExperiment.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Diagnostics; +using System.Text; + +namespace InputApiProbe; + +internal static class CaptureExperiment +{ + private const int Warmup = 20; + + private static readonly (string Id, Func Create)[] Candidates = + { + ("ll-hook", () => new LowLevelHookCapture(Native.VkF24)), + ("raw-input", () => new RawInputCapture(Native.VkF24)), + ("register-hotkey", () => new RegisterHotKeyCapture(Native.VkF24)), + ("poll-spin", () => new PollingCapture(Native.VkF24, 0)), + ("poll-1ms", () => new PollingCapture(Native.VkF24, 1)), + }; + + /// Injects F24 down, waits for the strategy to report it, then F24 up. + private sealed class TriggerProbe + { + private readonly ManualResetEventSlim signal = new(false); + private long capturedTick; + private int count; + + public void OnCaptured(long tick) + { + if (Interlocked.Increment(ref count) == 1) { Volatile.Write(ref capturedTick, tick); signal.Set(); } + } + + public (long Send, long Capture, int Count) Fire() + { + signal.Reset(); Volatile.Write(ref capturedTick, 0); Interlocked.Exchange(ref count, 0); + long send = Stopwatch.GetTimestamp(); + Native.KeyDown(InjectVariant.Vk); + bool seen = signal.Wait(100); + long tick = Volatile.Read(ref capturedTick); + Native.KeyUp(InjectVariant.Vk); + Thread.Sleep(10); // let late duplicates arrive before reading the count + return (send, seen ? tick : 0, Volatile.Read(ref count)); + } + } + + public static int Run(int rounds, int perRound, string output, string[] args) + { + Native.Warmup(requireScan: false); // keep lazy static init out of timed regions + Directory.CreateDirectory(output); + using var samples = new StreamWriter(Path.Combine(output, "capture-samples.csv"), false, new UTF8Encoding(false)); + using var blocks = new StreamWriter(Path.Combine(output, "capture-blocks.csv"), false, new UTF8Encoding(false)); + samples.WriteLine("round,strategy,sample,send_qpc,capture_qpc,captures,frequency"); + blocks.WriteLine("round,strategy,samples,lost,wall_ms,cpu_ms,error"); + Provenance.Write(Path.Combine(output, "capture-provenance.json"), args); + for (int round = 1; round <= rounds; round++) + for (int k = 0; k < Candidates.Length; k++) + { + var (id, create) = Candidates[(k + round - 1) % Candidates.Length]; // rotate the order every round + RunBlock(round, id, create, perRound, samples, blocks); + } + return 0; + } + + private static void RunBlock(int round, string id, Func create, int perRound, + StreamWriter samples, StreamWriter blocks) + { + var cpuBefore = Process.GetCurrentProcess().TotalProcessorTime; + var wall = Stopwatch.StartNew(); + int lost = 0, done = 0; + string error = ""; + try + { + using var strategy = create(); + var probe = new TriggerProbe(); + strategy.Captured += probe.OnCaptured; + strategy.Start(); + Thread.Sleep(200); // let the strategy's thread and registrations settle + int seen = 0; + for (int i = 0; i < Warmup; i++) if (probe.Fire().Count > 0) seen++; + if (seen == 0) throw new InvalidOperationException("strategy observed none of the warm-up triggers"); + for (int i = 0; i < perRound; i++) + { + var (send, capture, count) = probe.Fire(); + if (count == 0 || capture == 0) lost++; + samples.WriteLine($"{round},{id},{i},{send},{capture},{count},{Stopwatch.Frequency}"); + done++; + } + } + catch (Exception ex) { error = ex.Message.Replace(',', ';').Replace('\r', ' ').Replace('\n', ' '); } + double cpuMs = (Process.GetCurrentProcess().TotalProcessorTime - cpuBefore).TotalMilliseconds; + blocks.WriteLine($"{round},{id},{done},{lost},{wall.ElapsedMilliseconds},{cpuMs:F0},{error}"); + samples.Flush(); blocks.Flush(); + Console.WriteLine($"round {round} {id}: n={done} lost={lost} {error}"); + } +} diff --git a/src/tools/InputApiProbe/Capture/CapturePairedExperiment.cs b/src/tools/InputApiProbe/Capture/CapturePairedExperiment.cs new file mode 100644 index 0000000..88ee06f --- /dev/null +++ b/src/tools/InputApiProbe/Capture/CapturePairedExperiment.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace InputApiProbe; + +/// All capture strategies armed at once; one physical key press reaches each of them. Records raw capture ticks only. +internal static class CapturePairedExperiment +{ + public static ushort ParseKey(string name) + { + if (name.Length >= 2 && name[0] is 'F' or 'f' && int.TryParse(name.AsSpan(1), out int n) && n is >= 1 and <= 24) + return (ushort)(0x70 + n - 1); + throw new ArgumentException($"Unsupported key '{name}', use F1..F24."); + } + + public static int Run(int samples, ushort vk, string group, string output, string[] args) + { + Directory.CreateDirectory(output); + // RegisterHotKey consumes the key down, so Raw Input never sees it (measured with raw-diag --also); + // the two cannot share a group. ll-hook is the baseline in both. + var candidates = group switch + { + "main" => new ICaptureStrategy[] + { + new LowLevelHookCapture(vk), new RawInputCapture(vk), new PollingCapture(vk, 0), new PollingCapture(vk, 1) + }, + "hotkey" => new ICaptureStrategy[] { new LowLevelHookCapture(vk), new RegisterHotKeyCapture(vk) }, + _ => throw new ArgumentException("--group must be main or hotkey") + }; + var ticks = new Dictionary>(); + var armed = new List(); + foreach (var strategy in candidates) + { + try + { + var list = new List(); + strategy.Captured += tick => { lock (list) list.Add(tick); }; + strategy.Start(); + ticks[strategy.Id] = list; + armed.Add(strategy); + } + catch (Exception ex) { Console.Error.WriteLine($"{strategy.Id}: not armed ({ex.Message})"); strategy.Dispose(); } + } + if (!ticks.ContainsKey("ll-hook")) + { + foreach (var strategy in armed) strategy.Dispose(); + Console.Error.WriteLine("baseline ll-hook not armed"); + return 1; + } + try + { + Thread.Sleep(300); + Console.WriteLine($"PHYSICAL_READY: press and release the key {samples} times, about 0.5s apart. Do not touch anything else."); + var wait = Stopwatch.StartNew(); + while (wait.Elapsed.TotalSeconds < 300) + { + int most = 0; + foreach (var list in ticks.Values) lock (list) most = Math.Max(most, list.Count); + if (most >= samples) break; + Thread.Sleep(50); + } + Thread.Sleep(500); + } + finally { foreach (var strategy in armed) strategy.Dispose(); } + using var file = new StreamWriter(Path.Combine(output, $"capture-paired-{group}.csv"), false, new UTF8Encoding(false)); + file.WriteLine("sample,strategy,capture_qpc,frequency"); + foreach (var (id, list) in ticks) + lock (list) for (int i = 0; i < list.Count; i++) file.WriteLine($"{i},{id},{list[i]},{Stopwatch.Frequency}"); + Provenance.Write(Path.Combine(output, $"capture-paired-{group}-provenance.json"), args); + foreach (var (id, list) in ticks) Console.WriteLine($"{id}: {list.Count} captures"); + return 0; + } +} diff --git a/src/tools/InputApiProbe/Capture/ICaptureStrategy.cs b/src/tools/InputApiProbe/Capture/ICaptureStrategy.cs new file mode 100644 index 0000000..b78ed0b --- /dev/null +++ b/src/tools/InputApiProbe/Capture/ICaptureStrategy.cs @@ -0,0 +1,15 @@ +namespace InputApiProbe; + +internal interface ICaptureStrategy : IDisposable +{ + string Id { get; } + + /// + /// Raised on the strategy's own thread with the QPC taken at the first instruction of its callback + /// (for polling: right after the call that observed the key down) when the key goes down. + /// + event Action? Captured; + + void Start(); + void Stop(); +} diff --git a/src/tools/InputApiProbe/Capture/LowLevelHookCapture.cs b/src/tools/InputApiProbe/Capture/LowLevelHookCapture.cs new file mode 100644 index 0000000..1c0b9c6 --- /dev/null +++ b/src/tools/InputApiProbe/Capture/LowLevelHookCapture.cs @@ -0,0 +1,33 @@ +using MacroHid.Core; +using MacroStudio; + +namespace InputApiProbe; + +/// A0 baseline: the production GlobalKeyboardHook (low-level keyboard + mouse hook on an AboveNormal dispatcher thread). +internal sealed class LowLevelHookCapture(ushort vk) : ICaptureStrategy +{ + private GlobalKeyboardHook? hook; + private volatile bool held; + + public string Id => "ll-hook"; + public event Action? Captured; + + public void Start() + { + held = false; + hook = new GlobalKeyboardHook { ProbeInjectedInputTag = new IntPtr((long)Native.Tag) }; + // Only the first down after an up counts: holding a key makes Windows repeat the down every ~31ms. + hook.ProbeInputObserved += (key, down, tick) => + { + if (key != vk) return; + if (!down) { held = false; return; } + if (held) return; + held = true; + Captured?.Invoke(tick); + }; + hook.Start(new HotkeyGesture(HidModifier.None, HidKey.F8)); + } + + public void Stop() { hook?.Dispose(); hook = null; } + public void Dispose() => Stop(); +} diff --git a/src/tools/InputApiProbe/Capture/PollingCapture.cs b/src/tools/InputApiProbe/Capture/PollingCapture.cs new file mode 100644 index 0000000..2ba8cfa --- /dev/null +++ b/src/tools/InputApiProbe/Capture/PollingCapture.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; + +namespace InputApiProbe; + +/// A3: dedicated thread polling GetAsyncKeyState. periodMs = 0 spins continuously; otherwise sleeps periodMs between polls. +internal sealed class PollingCapture(ushort vk, int periodMs) : ICaptureStrategy +{ + private Thread? thread; + private volatile bool stop; + + public string Id => periodMs == 0 ? "poll-spin" : $"poll-{periodMs}ms"; + public event Action? Captured; + + public void Start() + { + stop = false; + thread = new Thread(Loop) { IsBackground = true, Name = "ApiProbe-" + Id, Priority = ThreadPriority.AboveNormal }; + thread.Start(); + } + + private void Loop() + { + bool wasDown = (Native.GetAsyncKeyState(vk) & 0x8000) != 0; + while (!stop) + { + bool down = (Native.GetAsyncKeyState(vk) & 0x8000) != 0; + long tick = Stopwatch.GetTimestamp(); + if (down && !wasDown) Captured?.Invoke(tick); + wasDown = down; + if (periodMs > 0) Thread.Sleep(periodMs); + } + } + + public void Stop() { stop = true; thread?.Join(1000); thread = null; } + public void Dispose() => Stop(); +} diff --git a/src/tools/InputApiProbe/Capture/RawInputCapture.cs b/src/tools/InputApiProbe/Capture/RawInputCapture.cs new file mode 100644 index 0000000..69057e5 --- /dev/null +++ b/src/tools/InputApiProbe/Capture/RawInputCapture.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A1: Raw Input keyboard with RIDEV_INPUTSINK on a hidden window (cannot swallow keys). +internal sealed class RawInputCapture(ushort vk) : ICaptureStrategy +{ + private const uint WmInput = 0x00FF, RidInput = 0x10000003, RidevRemove = 0x1, RidevInputSink = 0x100; + private const int HeaderSize = 24; // RAWINPUTHEADER on x64; RAWKEYBOARD follows + + [StructLayout(LayoutKind.Sequential)] + private struct RawInputDevice { public ushort UsagePage, Usage; public uint Flags; public IntPtr Target; } + + private readonly byte[] buffer = new byte[64]; + private MessageWindow? window; + private bool held; + + public string Id => "raw-input"; + public event Action? Captured; + + public void Start() + { + window = new MessageWindow(OnMessage, visible: false, "raw-input", onCreated: hwnd => + { + var device = new RawInputDevice { UsagePage = 1, Usage = 6, Flags = RidevInputSink, Target = hwnd }; + if (!RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf())) + throw new InvalidOperationException($"RegisterRawInputDevices failed, error={Marshal.GetLastWin32Error()}"); + }); + } + + private bool OnMessage(uint message, IntPtr w, IntPtr l) + { + if (message != WmInput) return false; + long tick = Stopwatch.GetTimestamp(); + uint size = (uint)buffer.Length; + uint r = GetRawInputData(l, RidInput, buffer, ref size, (uint)HeaderSize); + if (r != uint.MaxValue && r >= HeaderSize + 8) + { + ushort flags = BitConverter.ToUInt16(buffer, HeaderSize + 2); // RAWKEYBOARD.Flags, bit 0 = RI_KEY_BREAK + ushort key = BitConverter.ToUInt16(buffer, HeaderSize + 6); // RAWKEYBOARD.VKey + if (key != vk) return false; + if ((flags & 1) != 0) { held = false; return false; } + if (held) return false; // auto-repeat of a key that is still down + held = true; + Captured?.Invoke(tick); + } + return false; // let DefWindowProc do the required WM_INPUT cleanup + } + + public void Stop() + { + var device = new RawInputDevice { UsagePage = 1, Usage = 6, Flags = RidevRemove, Target = IntPtr.Zero }; + if (window is not null) RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf()); + window?.Dispose(); + window = null; + } + + public void Dispose() => Stop(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices(RawInputDevice[] devices, uint count, uint size); + [DllImport("user32.dll")] + private static extern uint GetRawInputData(IntPtr rawInput, uint command, byte[] data, ref uint size, uint headerSize); +} diff --git a/src/tools/InputApiProbe/Capture/RawInputDiagExperiment.cs b/src/tools/InputApiProbe/Capture/RawInputDiagExperiment.cs new file mode 100644 index 0000000..32244ad --- /dev/null +++ b/src/tools/InputApiProbe/Capture/RawInputDiagExperiment.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace InputApiProbe; + +/// +/// Raw Input diagnostics for physical keys: registers the same keyboard device as +/// but records every WM_INPUT without filtering, so "never delivered" and "delivered but filtered out" can be told apart. +/// Variables are single-switch: --window message|toplevel, --flags sink|none, --also <strategies armed alongside>. +/// +internal static class RawInputDiagExperiment +{ + private const uint WmInput = 0x00FF, RidInput = 0x10000003, RidevInputSink = 0x100; + private const int HeaderSize = 24; // RAWINPUTHEADER on x64: dwType, dwSize, hDevice, wParam + + [StructLayout(LayoutKind.Sequential)] + private struct RawInputDevice { public ushort UsagePage, Usage; public uint Flags; public IntPtr Target; } + + private sealed record Row(int Seq, long Qpc, bool ReadOk, long Device, uint Type, ushort MakeCode, ushort VKey, ushort Flags, uint Message, uint Extra); + + public static int Run(int seconds, string windowKind, string flagsKind, string also, ushort vk, string output, string[] args) + { + Directory.CreateDirectory(output); + if (windowKind is not ("message" or "toplevel")) throw new ArgumentException("--window must be message or toplevel"); + if (flagsKind is not ("sink" or "none")) throw new ArgumentException("--flags must be sink or none"); + + var companions = new List(); + var companionCounts = new Dictionary(); + try + { + foreach (var id in also.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + ICaptureStrategy strategy = id switch + { + "ll-hook" => new LowLevelHookCapture(vk), + "register-hotkey" => new RegisterHotKeyCapture(vk), + "poll-spin" => new PollingCapture(vk, 0), + "poll-1ms" => new PollingCapture(vk, 1), + _ => throw new ArgumentException($"--also: unknown strategy '{id}'") + }; + var count = new int[1]; + companionCounts[strategy.Id] = count; + strategy.Captured += _ => Interlocked.Increment(ref count[0]); + companions.Add(strategy); + strategy.Start(); + } + return RunCore(seconds, windowKind, flagsKind, also, companionCounts, output, args); + } + finally { foreach (var strategy in companions) strategy.Dispose(); } + } + + private static int RunCore(int seconds, string windowKind, string flagsKind, string also, Dictionary companionCounts, string output, string[] args) + { + var rows = new List(); + var buffer = new byte[256]; + int seq = 0; + + bool OnMessage(uint message, IntPtr w, IntPtr l) + { + if (message != WmInput) return false; + long tick = Stopwatch.GetTimestamp(); + uint size = (uint)buffer.Length; + uint read = GetRawInputData(l, RidInput, buffer, ref size, (uint)HeaderSize); + if (read == uint.MaxValue || read < HeaderSize) + { + lock (rows) rows.Add(new Row(seq++, tick, false, 0, 0, 0, 0, 0, 0, 0)); + return false; + } + uint type = BitConverter.ToUInt32(buffer, 0); + long device = BitConverter.ToInt64(buffer, 8); + ushort make = 0, vkey = 0, flags = 0; uint msg = 0, extra = 0; + if (type == 1 && read >= HeaderSize + 16) // RAWKEYBOARD: MakeCode, Flags, Reserved, VKey, Message, ExtraInformation + { + make = BitConverter.ToUInt16(buffer, HeaderSize); + flags = BitConverter.ToUInt16(buffer, HeaderSize + 2); + vkey = BitConverter.ToUInt16(buffer, HeaderSize + 6); + msg = BitConverter.ToUInt32(buffer, HeaderSize + 8); + extra = BitConverter.ToUInt32(buffer, HeaderSize + 12); + } + lock (rows) rows.Add(new Row(seq++, tick, true, device, type, make, vkey, flags, msg, extra)); + return false; + } + + using (var window = new MessageWindow(OnMessage, visible: false, "raw-diag", messageOnly: windowKind != "toplevel", onCreated: hwnd => + { + var device = new RawInputDevice { UsagePage = 1, Usage = 6, Flags = flagsKind == "sink" ? RidevInputSink : 0, Target = hwnd }; + if (!RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf())) + throw new InvalidOperationException($"RegisterRawInputDevices failed, error={Marshal.GetLastWin32Error()}"); + })) + { + Console.WriteLine($"RAW_DIAG_READY window={windowKind} flags={flagsKind} also=[{also}]: press keys for {seconds}s. Do not touch anything else."); + Thread.Sleep(seconds * 1000); + if (window.HandlerFailure is not null) throw new InvalidOperationException("handler failed", window.HandlerFailure); + } + + using var file = new StreamWriter(Path.Combine(output, "raw-diag.csv"), false, new UTF8Encoding(false)); + file.WriteLine("seq,qpc,frequency,read_ok,device,type,make_code,vkey,flags,message,extra"); + lock (rows) + foreach (var r in rows) + file.WriteLine($"{r.Seq},{r.Qpc},{Stopwatch.Frequency},{(r.ReadOk ? 1 : 0)},{r.Device},{r.Type},{r.MakeCode},{r.VKey},{r.Flags},{r.Message},{r.Extra}"); + Provenance.Write(Path.Combine(output, "raw-diag-provenance.json"), args); + Console.WriteLine($"raw-diag: {rows.Count} WM_INPUT records ({windowKind}, {flagsKind}, also=[{also}])"); + foreach (var (id, count) in companionCounts) Console.WriteLine($"companion {id}: {count[0]} captures"); + return 0; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices(RawInputDevice[] devices, uint count, uint size); + [DllImport("user32.dll")] + private static extern uint GetRawInputData(IntPtr rawInput, uint command, byte[] data, ref uint size, uint headerSize); +} diff --git a/src/tools/InputApiProbe/Capture/RegisterHotKeyCapture.cs b/src/tools/InputApiProbe/Capture/RegisterHotKeyCapture.cs new file mode 100644 index 0000000..70b2803 --- /dev/null +++ b/src/tools/InputApiProbe/Capture/RegisterHotKeyCapture.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A2: RegisterHotKey on a hidden window. Down events only; the key is consumed by the system. +internal sealed class RegisterHotKeyCapture(ushort vk) : ICaptureStrategy +{ + private const uint WmHotKey = 0x0312, ModNoRepeat = 0x4000; + private MessageWindow? window; + + public string Id => "register-hotkey"; + public event Action? Captured; + + public void Start() + { + window = new MessageWindow(OnMessage, visible: false, "register-hotkey", onCreated: hwnd => + { + if (!RegisterHotKey(hwnd, 1, ModNoRepeat, vk)) + throw new InvalidOperationException($"RegisterHotKey failed, error={Marshal.GetLastWin32Error()}"); + }); + } + + private bool OnMessage(uint message, IntPtr w, IntPtr l) + { + if (message != WmHotKey) return false; + Captured?.Invoke(Stopwatch.GetTimestamp()); + return true; + } + + public void Stop() { window?.Dispose(); window = null; } + public void Dispose() => Stop(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hwnd, int id, uint modifiers, uint vk); +} diff --git a/src/tools/InputApiProbe/EmptyHook.cs b/src/tools/InputApiProbe/EmptyHook.cs new file mode 100644 index 0000000..2d1af29 --- /dev/null +++ b/src/tools/InputApiProbe/EmptyHook.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// A do-nothing WH_KEYBOARD_LL hook on its own message thread: the lower bound of what an active hook adds to the input path. +internal sealed class EmptyHook : IDisposable +{ + private delegate IntPtr HookProc(int code, IntPtr w, IntPtr l); + + private readonly HookProc proc = (code, w, l) => CallNextHookEx(IntPtr.Zero, code, w, l); + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(); + private uint threadId; + private bool installed; + + public EmptyHook() + { + thread = new Thread(Run) { IsBackground = true, Name = "ApiProbe-empty-hook", Priority = ThreadPriority.AboveNormal }; + thread.Start(); + ready.Wait(); + if (!installed) throw new InvalidOperationException("Could not install the empty low-level hook"); + } + + private void Run() + { + threadId = Native.GetCurrentThreadId(); + IntPtr hook = SetWindowsHookEx(13, proc, IntPtr.Zero, 0); + installed = hook != IntPtr.Zero; + ready.Set(); + if (!installed) return; + while (Native.GetMessage(out _, IntPtr.Zero, 0, 0) > 0) { } + UnhookWindowsHookEx(hook); + } + + public void Dispose() + { + Native.PostThreadMessage(threadId, 0x12, UIntPtr.Zero, IntPtr.Zero); // WM_QUIT + if (!thread.Join(2000)) throw new InvalidOperationException("Empty hook thread did not stop"); + GC.KeepAlive(proc); + } + + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int id, HookProc proc, IntPtr module, uint thread); + [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr w, IntPtr l); +} diff --git a/src/tools/InputApiProbe/Inject/InjectExperiment.cs b/src/tools/InputApiProbe/Inject/InjectExperiment.cs new file mode 100644 index 0000000..d79e147 --- /dev/null +++ b/src/tools/InputApiProbe/Inject/InjectExperiment.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Text; + +namespace InputApiProbe; + +internal static class InjectExperiment +{ + private const int Warmup = 20; + + private static readonly (string Id, InjectVariant Variant)[] Variants = + { + ("vk", InjectVariant.Vk), ("scan", InjectVariant.Scan), ("vk-scan", InjectVariant.VkScan), + ("keybd-event", InjectVariant.KeybdEvent), ("merged-pair", InjectVariant.MergedPair), + }; + + public static int Run(int rounds, int perBlock, string output, string[] args) + { + Native.Warmup(requireScan: false); + Directory.CreateDirectory(output); + // Native.ScanF24 == 0 means this layout has no scan code for F24; the scan variants cannot be tested. + var variants = Variants.Where(v => Native.ScanF24 != 0 || v.Variant is InjectVariant.Vk or InjectVariant.MergedPair).ToArray(); + var conditions = variants.SelectMany(v => new[] { (v.Id, v.Variant, Hook: false), (v.Id, v.Variant, Hook: true) }).ToArray(); + long totalRows = (long)rounds * conditions.Length * perBlock; + string name = "Local\\MacroHID-ApiProbe-" + Guid.NewGuid().ToString("N"); + using var memory = MemoryMappedFile.CreateNew(name, ReceiverProcess.Header + totalRows * ReceiverProcess.Stride); + using var view = memory.CreateViewAccessor(); + view.Write(ReceiverProcess.CurrentRow, -1); + + var info = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false }; + info.ArgumentList.Add("--receiver"); info.ArgumentList.Add(name); + info.ArgumentList.Add(Environment.ProcessId.ToString()); + using var receiver = Process.Start(info) ?? throw new InvalidOperationException("Cannot start receiver"); + using var csv = new StreamWriter(Path.Combine(output, "inject-samples.csv"), false, new UTF8Encoding(false)); + csv.WriteLine("round,variant,hook,sample,send_down_qpc,send_up_qpc,recv_down_qpc,recv_up_qpc,down_count,up_count,vk,frequency"); + Provenance.Write(Path.Combine(output, "inject-provenance.json"), args); + try + { + var start = Stopwatch.StartNew(); + while (view.ReadInt32(ReceiverProcess.Ready) == 0 && start.Elapsed.TotalSeconds < 30) Thread.Sleep(20); + IntPtr target = new(view.ReadInt64(ReceiverProcess.WindowHandle)); + if (target == IntPtr.Zero) throw new InvalidOperationException("Receiver did not start"); + Console.WriteLine("WINDOW_READY: click the receiver window to bring it to the foreground, then do not touch anything."); + while (Native.GetForegroundWindow() != target && start.Elapsed.TotalSeconds < 300) + { if (receiver.HasExited) throw new InvalidOperationException("Receiver closed"); Thread.Sleep(50); } + if (Native.GetForegroundWindow() != target) throw new TimeoutException("Receiver never became the foreground window"); + + int row = 0; + for (int round = 1; round <= rounds; round++) + for (int k = 0; k < conditions.Length; k++) + { + var (id, variant, hook) = conditions[(k + round - 1) % conditions.Length]; + using var hookScope = hook ? new EmptyHook() : null; + Thread.Sleep(100); + for (int i = 0; i < Warmup; i++) Sample(view, target, variant, hook ? "ll-empty" : "none", -1); + for (int i = 0; i < perBlock; i++) + { + var s = Sample(view, target, variant, hook ? "ll-empty" : "none", row); + long at = ReceiverProcess.RowOffset(row); + csv.WriteLine($"{round},{id},{(hook ? "ll-empty" : "none")},{i},{s.SendDown},{s.SendUp}," + + $"{view.ReadInt64(at + ReceiverProcess.DownQpc)},{view.ReadInt64(at + ReceiverProcess.UpQpc)}," + + $"{view.ReadInt32(at + ReceiverProcess.DownCount)},{view.ReadInt32(at + ReceiverProcess.UpCount)}," + + $"{view.ReadInt32(at + ReceiverProcess.DownVk)},{Stopwatch.Frequency}"); + row++; + } + csv.Flush(); + Console.WriteLine($"round {round} {id} hook={(hook ? "ll-empty" : "none")}: done"); + } + return 0; + } + finally + { + view.Write(ReceiverProcess.Done, 1); + if (!receiver.WaitForExit(5000)) receiver.Kill(); + } + } + + /// Sends F24 down, waits for the receiver, sends F24 up, waits for the receiver. row -1 = warm-up (receiver ignores it). + private static (long SendDown, long SendUp) Sample(MemoryMappedViewAccessor view, IntPtr target, InjectVariant variant, string hookLabel, int row) + { + if (Native.GetForegroundWindow() != target) throw new InvalidOperationException("Receiver lost foreground focus; stopped before further injection."); + long at = row >= 0 ? ReceiverProcess.RowOffset(row) : 0; + view.Write(ReceiverProcess.CurrentRow, row); + long sendDown = Stopwatch.GetTimestamp(); + Native.KeyDown(variant); + bool downSeen = true, upSeen = true; + if (row >= 0) downSeen = WaitFor(view, at + ReceiverProcess.DownCount); else Thread.Sleep(5); + long sendUp = variant == InjectVariant.MergedPair ? sendDown : Stopwatch.GetTimestamp(); + Native.KeyUp(variant); + if (row >= 0) upSeen = WaitFor(view, at + ReceiverProcess.UpCount); else Thread.Sleep(5); + Thread.Sleep(10); + view.Write(ReceiverProcess.CurrentRow, -1); + if (!downSeen || !upSeen) + throw new InvalidOperationException($"missing receipt: variant={variant} hook={hookLabel} row={row} ({(downSeen ? "up" : "down")} not received within 100 ms)"); + return (sendDown, sendUp); + } + + private static bool WaitFor(MemoryMappedViewAccessor view, long countAt) + { + var wait = Stopwatch.StartNew(); + while (view.ReadInt32(countAt) == 0 && wait.ElapsedMilliseconds < 100) Thread.Yield(); + return view.ReadInt32(countAt) != 0; + } +} diff --git a/src/tools/InputApiProbe/Inject/ReceiverProcess.cs b/src/tools/InputApiProbe/Inject/ReceiverProcess.cs new file mode 100644 index 0000000..fad7dbc --- /dev/null +++ b/src/tools/InputApiProbe/Inject/ReceiverProcess.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace InputApiProbe; + +/// Separate-process Win32 window. Records the QPC of the first WM_KEYDOWN / WM_KEYUP for the row the sender selected. +internal static class ReceiverProcess +{ + public const int Header = 64, Stride = 32; + public const int Ready = 0, WindowHandle = 8, CurrentRow = 16, Done = 20; + public const int DownQpc = 0, UpQpc = 8, DownCount = 16, UpCount = 20, DownVk = 24; + + public static long RowOffset(int row) => Header + (long)row * Stride; + + private static bool ParentExited(int pid) + { + try { using var parent = Process.GetProcessById(pid); return parent.HasExited; } + catch (ArgumentException) { return true; } + } + + public static void Run(string name, int parentPid) + { + using var memory = MemoryMappedFile.OpenExisting(name); + using var view = memory.CreateViewAccessor(); + // Normal priority: a real target app does not run above normal, so the receiver must not be artificially fast. + using var window = new MessageWindow((message, w, l) => + { + if (message is not (0x100 or 0x101)) return false; // WM_KEYDOWN / WM_KEYUP + long tick = Stopwatch.GetTimestamp(); + int row = view.ReadInt32(CurrentRow); + if (row < 0) return false; + bool down = message == 0x100; + long at = RowOffset(row); + long countAt = at + (down ? DownCount : UpCount); + int previous = view.ReadInt32(countAt); + view.Write(countAt, previous + 1); + if (previous == 0) + { + view.Write(at + (down ? DownQpc : UpQpc), tick); + if (down) view.Write(at + DownVk, (int)w.ToInt64()); + } + return false; + }, visible: true, title: "MacroHID input API probe receiver", onCreated: hwnd => + { + view.Write(WindowHandle, hwnd.ToInt64()); + view.Write(Ready, 1); + Native.SetForegroundWindow(hwnd); + }, priority: ThreadPriority.Normal); + while (view.ReadInt32(Done) == 0 && !ParentExited(parentPid)) Thread.Sleep(100); + } +} diff --git a/src/tools/InputApiProbe/InputApiProbe.csproj b/src/tools/InputApiProbe/InputApiProbe.csproj new file mode 100644 index 0000000..fed5c0a --- /dev/null +++ b/src/tools/InputApiProbe/InputApiProbe.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0-windows + true + enable + enable + x64 + $(DefineConstants);MACROHID_HOTKEY_PROBE + + + + + + diff --git a/src/tools/InputApiProbe/MessageWindow.cs b/src/tools/InputApiProbe/MessageWindow.cs new file mode 100644 index 0000000..e9f143d --- /dev/null +++ b/src/tools/InputApiProbe/MessageWindow.cs @@ -0,0 +1,121 @@ +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +/// +/// A window with its own message-loop thread. The handler runs on that thread for every message and +/// returns true to swallow it (result 0); returning false falls through to DefWindowProc. +/// +internal sealed class MessageWindow : IDisposable +{ + private const uint WmDestroy = 0x2, WmClose = 0x10; + private static readonly IntPtr HwndMessage = new(-3); + + private delegate IntPtr WindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass + { + public uint Size, Style; + public IntPtr Proc; + public int ClassExtra, WindowExtra; + public IntPtr Instance, Icon, Cursor, Background; + public string? Menu, Name; + public IntPtr SmallIcon; + } + + private readonly Func handler; + private readonly Action? onCreated; + private readonly bool visible, messageOnly; + private readonly string title, className = "MacroHID.ApiProbe." + Guid.NewGuid().ToString("N"); + private readonly WindowProc proc; + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(); + private Exception? failure; + + public IntPtr Handle { get; private set; } + + /// First exception thrown by the handler on the window thread, if any. + public Exception? HandlerFailure { get; private set; } + + /// Runs on the window thread right after creation (RegisterHotKey and raw-input registration must happen there). + /// For hidden windows: parent them to HWND_MESSAGE. Set false to get a hidden top-level window. + public MessageWindow(Func handler, bool visible, string title, + Action? onCreated = null, bool messageOnly = true, ThreadPriority priority = ThreadPriority.AboveNormal) + { + this.handler = handler; this.visible = visible; this.title = title; + this.onCreated = onCreated; this.messageOnly = messageOnly; + proc = Proc; + thread = new Thread(Run) { IsBackground = true, Name = "ApiProbe-" + title, Priority = priority }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(); + if (failure is not null) throw new InvalidOperationException($"{title}: {failure.Message}", failure); + } + + private IntPtr Proc(IntPtr hwnd, uint message, IntPtr w, IntPtr l) + { + // Shutdown messages are handled first so a handler that swallows everything cannot block exit. + if (message == WmClose) { DestroyWindow(hwnd); return IntPtr.Zero; } + if (message == WmDestroy) { PostQuitMessage(0); return IntPtr.Zero; } + try + { + if (handler(message, w, l)) return IntPtr.Zero; + } + catch (Exception ex) + { + HandlerFailure ??= ex; + } + return DefWindowProc(hwnd, message, w, l); + } + + private void Run() + { + IntPtr instance = IntPtr.Zero; + try + { + instance = GetModuleHandle(null); + var cls = new WindowClass + { + Size = (uint)Marshal.SizeOf(), Proc = Marshal.GetFunctionPointerForDelegate(proc), Instance = instance, + Cursor = LoadCursor(IntPtr.Zero, new IntPtr(32512)), Background = new IntPtr(6), Name = className + }; + if (RegisterClassEx(ref cls) == 0) throw new InvalidOperationException($"RegisterClassEx failed, error={Marshal.GetLastWin32Error()}"); + // 0x10CF0000 = WS_VISIBLE | WS_OVERLAPPEDWINDOW, ex style 8 = WS_EX_TOPMOST (same as the existing receiver). + Handle = visible + ? CreateWindowEx(8, className, title, 0x10CF0000, 240, 240, 600, 200, IntPtr.Zero, IntPtr.Zero, instance, IntPtr.Zero) + : CreateWindowEx(0, className, title, 0, 0, 0, 0, 0, messageOnly ? HwndMessage : IntPtr.Zero, IntPtr.Zero, instance, IntPtr.Zero); + if (Handle == IntPtr.Zero) throw new InvalidOperationException($"CreateWindowEx failed, error={Marshal.GetLastWin32Error()}"); + onCreated?.Invoke(Handle); + } + catch (Exception ex) + { + failure = ex; + if (Handle != IntPtr.Zero) { DestroyWindow(Handle); Handle = IntPtr.Zero; } + UnregisterClass(className, instance); + ready.Set(); + return; + } + ready.Set(); + while (Native.GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) + { Native.TranslateMessage(ref message); Native.DispatchMessage(ref message); } + UnregisterClass(className, instance); + } + + public void Dispose() + { + if (Handle != IntPtr.Zero) Native.PostMessage(Handle, WmClose, UIntPtr.Zero, IntPtr.Zero); + bool exited = thread.Join(2000); + GC.KeepAlive(proc); + if (!exited) throw new InvalidOperationException($"{title}: window thread did not exit within 2s"); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string? name); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern ushort RegisterClassEx(ref WindowClass cls); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool UnregisterClass(string name, IntPtr instance); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateWindowEx(uint ex, string cls, string title, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr DefWindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); + [DllImport("user32.dll")] private static extern IntPtr LoadCursor(IntPtr instance, IntPtr name); + [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern void PostQuitMessage(int code); +} diff --git a/src/tools/InputApiProbe/Native.cs b/src/tools/InputApiProbe/Native.cs new file mode 100644 index 0000000..4ab3fc1 --- /dev/null +++ b/src/tools/InputApiProbe/Native.cs @@ -0,0 +1,105 @@ +using System.Runtime.InteropServices; + +namespace InputApiProbe; + +internal enum InjectVariant { Vk, Scan, VkScan, KeybdEvent, MergedPair } + +/// All P/Invoke used by the probe, plus the keyboard injection variants under test. +internal static class Native +{ + public const ushort VkF24 = 0x87; + public const ulong Tag = 0x4D484150; // "MHAP", dwExtraInfo on injected input + + private const uint InputKeyboard = 1, KeyUpFlag = 0x2, ScanCodeFlag = 0x8; + + /// Scan code of F24, resolved once so MapVirtualKey is never inside a timed region. 0 if unmapped. + public static readonly ushort ScanF24 = (ushort)MapVirtualKey(VkF24, 0); + + [StructLayout(LayoutKind.Sequential)] + private struct KeyboardInput { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Explicit, Size = 32)] + private struct InputUnion { [FieldOffset(0)] public KeyboardInput Keyboard; } + [StructLayout(LayoutKind.Sequential)] + private struct Input { public uint Type; public InputUnion Union; } + + [StructLayout(LayoutKind.Sequential)] + public struct Msg + { + public IntPtr Hwnd; + public uint Id; + public UIntPtr W; + public IntPtr L; + public uint Time; + public int X, Y; + public uint Private; + } + + [DllImport("user32.dll")] private static extern uint SendInput(uint count, Input[] inputs, int size); + [DllImport("user32.dll")] private static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); + [DllImport("user32.dll")] private static extern uint MapVirtualKey(uint code, uint mapType); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vk); + [DllImport("user32.dll")] public static extern int GetMessage(out Msg message, IntPtr hwnd, uint minimum, uint maximum); + [DllImport("user32.dll")] public static extern bool TranslateMessage(ref Msg message); + [DllImport("user32.dll")] public static extern IntPtr DispatchMessage(ref Msg message); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint message, UIntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool PostThreadMessage(uint thread, uint message, UIntPtr w, IntPtr l); + [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); + [DllImport("winmm.dll")] public static extern uint timeBeginPeriod(uint milliseconds); + [DllImport("winmm.dll")] public static extern uint timeEndPeriod(uint milliseconds); + + private static Input Key(ushort vk, ushort scan, uint flags) => new() + { + Type = InputKeyboard, + Union = new() { Keyboard = new() { Vk = vk, Scan = scan, Flags = flags, Extra = new UIntPtr(Tag) } } + }; + + // Pre-built after ScanF24 (static initialisers run in textual order) so injection never allocates. + private static readonly Input[] VkDown = { Key(VkF24, 0, 0) }; + private static readonly Input[] VkUp = { Key(VkF24, 0, KeyUpFlag) }; + private static readonly Input[] ScanDown = { Key(0, ScanF24, ScanCodeFlag) }; + private static readonly Input[] ScanUp = { Key(0, ScanF24, ScanCodeFlag | KeyUpFlag) }; + private static readonly Input[] VkScanDown = { Key(VkF24, ScanF24, 0) }; + private static readonly Input[] VkScanUp = { Key(VkF24, ScanF24, KeyUpFlag) }; + private static readonly Input[] MergedPairInputs = { Key(VkF24, 0, 0), Key(VkF24, 0, KeyUpFlag) }; + private static readonly int InputSize = Marshal.SizeOf(); + + /// Forces static initialisation. With requireScan, throws if F24 has no scan code mapping. + public static void Warmup(bool requireScan = true) + { + if (requireScan && ScanF24 == 0) throw new InvalidOperationException("MapVirtualKey(VK_F24) returned 0; no scan code available"); + } + + private static void Send(Input[] inputs) + { + if (SendInput((uint)inputs.Length, inputs, InputSize) != inputs.Length) + throw new InvalidOperationException($"SendInput failed, error={Marshal.GetLastSystemError()}"); + } + + /// Key-down of F24 using the given API variant. MergedPair sends down and up in one SendInput call. + public static void KeyDown(InjectVariant variant) + { + switch (variant) + { + case InjectVariant.Vk: Send(VkDown); break; + case InjectVariant.Scan: Send(ScanDown); break; + case InjectVariant.VkScan: Send(VkScanDown); break; + case InjectVariant.KeybdEvent: keybd_event((byte)VkF24, (byte)ScanF24, 0, new UIntPtr(Tag)); break; + case InjectVariant.MergedPair: Send(MergedPairInputs); break; + } + } + + /// Key-up of F24. A no-op for MergedPair because KeyDown already sent the up event. + public static void KeyUp(InjectVariant variant) + { + switch (variant) + { + case InjectVariant.Vk: Send(VkUp); break; + case InjectVariant.Scan: Send(ScanUp); break; + case InjectVariant.VkScan: Send(VkScanUp); break; + case InjectVariant.KeybdEvent: keybd_event((byte)VkF24, (byte)ScanF24, KeyUpFlag, new UIntPtr(Tag)); break; + case InjectVariant.MergedPair: break; + } + } +} diff --git a/src/tools/InputApiProbe/Program.cs b/src/tools/InputApiProbe/Program.cs new file mode 100644 index 0000000..65fd6eb --- /dev/null +++ b/src/tools/InputApiProbe/Program.cs @@ -0,0 +1,43 @@ +namespace InputApiProbe; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + string Option(string key, string fallback) + { int at = Array.IndexOf(args, key); return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; } + + Native.timeBeginPeriod(1); // stable 10ms pacing; recorded in provenance + try + { + switch (args.FirstOrDefault()) + { + case "raw-diag": + return RawInputDiagExperiment.Run(int.Parse(Option("--seconds", "20")), Option("--window", "message"), + Option("--flags", "sink"), Option("--also", ""), + CapturePairedExperiment.ParseKey(Option("--key", "F8")), Option("--output", "tmp/input-api"), args); + case "capture-paired": + return CapturePairedExperiment.Run(int.Parse(Option("--physical", "30")), + CapturePairedExperiment.ParseKey(Option("--key", "F8")), Option("--group", "main"), + Option("--output", "tmp/input-api"), args); + case "capture": + return CaptureExperiment.Run(int.Parse(Option("--rounds", "5")), int.Parse(Option("--per-round", "2000")), + Option("--output", "tmp/input-api"), args); + case "inject": + return InjectExperiment.Run(int.Parse(Option("--rounds", "5")), int.Parse(Option("--per-block", "2000")), + Option("--output", "tmp/input-api"), args); + case "--receiver": + ReceiverProcess.Run(args[1], int.Parse(args[2])); + return 0; + default: + Console.Error.WriteLine("Usage: InputApiProbe capture|capture-paired|raw-diag|inject|--receiver [options]"); + return 2; + } + } + finally { Native.timeEndPeriod(1); } + } + catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } + } +} diff --git a/src/tools/InputApiProbe/Provenance.cs b/src/tools/InputApiProbe/Provenance.cs new file mode 100644 index 0000000..f0cb44a --- /dev/null +++ b/src/tools/InputApiProbe/Provenance.cs @@ -0,0 +1,42 @@ +using System.IO; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; + +namespace InputApiProbe; + +internal static class Provenance +{ + public static void Write(string path, string[] args) + { + string code = typeof(Provenance).Assembly.Location; + var data = new Dictionary + { + ["date_utc"] = DateTime.UtcNow.ToString("o"), + ["args"] = args, + ["os"] = RuntimeInformation.OSDescription, + ["cpu"] = Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER"), + ["logical_processors"] = Environment.ProcessorCount, + ["runtime"] = RuntimeInformation.FrameworkDescription, + ["timer_resolution_request_ms"] = 1, + ["power_scheme"] = Run("powercfg", "/getactivescheme"), + ["assembly"] = Path.GetFileName(code), + ["assembly_sha256"] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(code))), + ["note"] = "Latency of synthetic input only; see docs/superpowers/specs/2026-10-15-input-api-comparison-design.md for limits." + }; + File.WriteAllText(path, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } + + private static string Run(string file, string arguments) + { + try + { + using var process = Process.Start(new ProcessStartInfo(file, arguments) { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true })!; + string text = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + return text; + } + catch (Exception ex) { return "unavailable: " + ex.Message; } + } +} diff --git a/tests/test_input_api_summary.py b/tests/test_input_api_summary.py new file mode 100644 index 0000000..d59493a --- /dev/null +++ b/tests/test_input_api_summary.py @@ -0,0 +1,260 @@ +import csv +import importlib.util +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("summary", Path(__file__).parents[1] / "scripts/summarize_input_api.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +def group(round_p50, p50, p99=500.0, mx=900.0, lost=0, duplicates=0): + """Hand-built group: per-round P50s plus pooled stats (round P99/max follow the pooled values).""" + return { + "pooled": {"n": 1000, "p50": p50, "p99": p99, "max": mx}, + "rounds": [{"round": i + 1, "p50": v, "p99": p99, "max": mx} for i, v in enumerate(round_p50)], + "lost": lost, "duplicates": duplicates, + } + + +def write_csv(path, header, rows): + with path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.writer(stream) + writer.writerow(header) + writer.writerows(rows) + + +class PercentileTests(unittest.TestCase): + def test_nearest_rank(self): + self.assertEqual(module.stats([4, 1, 3, 2])["p50"], 2) + self.assertEqual(module.stats(list(range(1, 1001)))["p99"], 990) + self.assertEqual(module.stats(list(range(1, 1001)))["max"], 1000) + + def test_empty_is_none(self): + self.assertIsNone(module.stats([])) + + +class VerdictTests(unittest.TestCase): + base = group([1000] * 5, 1000) + + def test_clear_win_is_candidate(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800)), "candidate") + + def test_small_gain_is_no_net_benefit(self): + self.assertEqual(module.verdict(self.base, group([950] * 5, 950)), "no-net-benefit") + + def test_tail_regression_blocks_candidate(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, p99=600.0)), "no-net-benefit") + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, mx=1200.0)), "no-net-benefit") + + def test_needs_four_improved_rounds(self): + self.assertEqual(module.verdict(self.base, group([800, 800, 800, 1100, 1100], 800)), "no-net-benefit") + self.assertEqual(module.verdict(self.base, group([800, 800, 800, 800, 1100], 800)), "candidate") + + def test_lost_or_duplicate_is_invalid(self): + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, lost=1)), "invalid") + self.assertEqual(module.verdict(self.base, group([800] * 5, 800, duplicates=1)), "invalid") + + def test_too_few_rounds_is_inconclusive(self): + self.assertEqual(module.verdict(group([1000] * 4, 1000), group([800] * 4, 800)), "inconclusive") + + def test_noisy_baseline_is_inconclusive(self): + noisy = group([900, 1000, 1050, 1000, 1000], 1000) + self.assertEqual(module.verdict(noisy, group([700] * 5, 700)), "inconclusive") + + +class CaptureSummaryTests(unittest.TestCase): + def test_lost_duplicate_and_latency(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "capture-samples.csv" + header = ["round", "strategy", "sample", "send_qpc", "capture_qpc", "captures", "frequency"] + # frequency 1_000_000 -> 1 tick = 1 microsecond + write_csv(path, header, [ + [1, "ll-hook", 0, 1000, 1100, 1, 1000000], + [1, "ll-hook", 1, 2000, 2300, 1, 1000000], + [1, "ll-hook", 2, 3000, 0, 0, 1000000], + [2, "ll-hook", 0, 4000, 4200, 2, 1000000], + [1, "raw-input", 0, 1000, 1050, 1, 1000000], + ]) + result = module.summarize_capture(path) + ll = result["groups"]["ll-hook"] + self.assertEqual(ll["lost"], 1) + self.assertEqual(ll["duplicates"], 1) + self.assertEqual(ll["pooled"]["n"], 3) + self.assertEqual(ll["pooled"]["p50"], 200) + self.assertEqual([r["round"] for r in ll["rounds"]], [1, 2]) + self.assertEqual(result["groups"]["raw-input"]["pooled"]["p50"], 50) + + +class InjectSummaryTests(unittest.TestCase): + def test_wrong_key_counts_as_lost_and_up_latency_is_reported(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "inject-samples.csv" + header = ["round", "variant", "hook", "sample", "send_down_qpc", "send_up_qpc", + "recv_down_qpc", "recv_up_qpc", "down_count", "up_count", "vk", "frequency"] + write_csv(path, header, [ + [1, "vk", "none", 0, 1000, 5000, 1400, 5300, 1, 1, 135, 1000000], + [1, "vk", "none", 1, 6000, 9000, 6500, 9200, 1, 1, 135, 1000000], + [1, "scan", "none", 0, 1000, 5000, 1200, 5100, 1, 1, 17, 1000000], # wrong key + ]) + result = module.summarize_inject(path) + vk = result["groups"]["vk|none"] + self.assertEqual(vk["pooled"]["p50"], 400) + self.assertEqual(result["up_groups"]["vk|none"]["pooled"]["p50"], 200) + self.assertEqual(result["groups"]["scan|none"]["lost"], 1) + + +class CaptureBlocksTests(unittest.TestCase): + def test_blocks_cpu_and_errors_invalidate(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "capture-samples.csv" + header = ["round", "strategy", "sample", "send_qpc", "capture_qpc", "captures", "frequency"] + write_csv(path, header, [ + [1, "ll-hook", 0, 1000, 1100, 1, 1000000], + [1, "ll-hook", 1, 2000, 2100, 1, 1000000], + [1, "poll-1ms", 0, 1000, 1500, 1, 1000000], + ]) + write_csv(Path(folder) / "capture-blocks.csv", + ["round", "strategy", "samples", "lost", "wall_ms", "cpu_ms", "error"], [ + [1, "ll-hook", 2, 0, 100, 10, ""], + [2, "ll-hook", 2, 0, 100, 30, ""], + [1, "poll-1ms", 1, 0, 50, 20, "boom"], + ]) + result = module.summarize_capture(path) + ll = result["blocks"]["ll-hook"] + self.assertEqual((ll["cpu_ms"], ll["wall_ms"], ll["samples"]), (40, 200, 4)) + self.assertEqual(ll["cpu_ms_per_1000"], 10000.0) + self.assertEqual(ll["errors"], []) + self.assertEqual(result["blocks"]["poll-1ms"]["errors"], ["boom"]) + verdicts = module.judge_capture(result, "ll-hook") + self.assertEqual(verdicts["poll-1ms"], "invalid") + + def test_missing_blocks_file_is_skipped(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "capture-samples.csv" + write_csv(path, ["round", "strategy", "sample", "send_qpc", "capture_qpc", "captures", "frequency"], + [[1, "ll-hook", 0, 1000, 1100, 1, 1000000]]) + self.assertEqual(module.summarize_capture(path)["blocks"], {}) + + +class InjectJudgementTests(unittest.TestCase): + def make(self, folder): + path = Path(folder) / "inject-samples.csv" + header = ["round", "variant", "hook", "sample", "send_down_qpc", "send_up_qpc", + "recv_down_qpc", "recv_up_qpc", "down_count", "up_count", "vk", "frequency"] + rows = [] + # (variant, hook) -> down latency, up latency + spec = {("vk", "none"): (400, 200), ("vk", "ll-empty"): (500, 250), + ("scan", "none"): (300, 100), ("scan", "ll-empty"): (450, 150), + ("merged-pair", "none"): (300, 100)} + for (variant, hook), (d, u) in spec.items(): + for r in range(1, 6): + rows.append([r, variant, hook, 0, 1000, 5000, 1000 + d, 5000 + u, 1, 1, 135, 1000000]) + write_csv(path, header, rows) + return path + + def test_hook_tax_pairing_and_merged_pair(self): + with tempfile.TemporaryDirectory() as folder: + result = module.summarize_inject(self.make(folder)) + self.assertEqual(result["hook_tax"]["vk"]["p50"], 100) + self.assertEqual(result["hook_tax"]["scan"]["p50"], 150) + self.assertNotIn("merged-pair", result["hook_tax"]) + down, up = module.judge_inject(result) + self.assertEqual(down["scan|none"], "candidate") # 400 -> 300 vs vk|none + self.assertEqual(down["scan|ll-empty"], "no-net-benefit") # 500 -> 450 vs vk|ll-empty + self.assertNotIn("vk|none", down) + self.assertFalse([k for k in up if k.startswith("merged-pair")]) + self.assertIn("merged-pair|none", down) + self.assertEqual(result["gap_groups"]["vk|none"]["pooled"]["p50"], 4000 + 200 - 400) + + +class PairedTests(unittest.TestCase): + def write(self, folder, rows): + path = Path(folder) / "paired.csv" + write_csv(path, ["sample", "strategy", "capture_qpc", "frequency"], rows) + return path + + def test_delta_against_baseline_by_nearest_tick(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, [ + [0, "ll-hook", 1000, 1000000], [1, "ll-hook", 2000, 1000000], + [0, "raw-input", 1100, 1000000], [1, "raw-input", 1900, 1000000], + [0, "poll-1ms", 1500, 1000000], + ]) + result = module.summarize_paired(path, "ll-hook") + self.assertEqual(result["raw-input"]["n"], 2) + self.assertEqual(result["raw-input"]["p50"], -100) + self.assertEqual(result["raw-input"]["max"], 100) + poll = result["poll-1ms"] + self.assertEqual((poll["n"], poll["p50"]), (1, 500)) + self.assertEqual((poll["unmatched_baseline"], poll["unmatched_strategy"]), (1, 0)) + + def test_far_ticks_unmatched_and_empty_strategy_present(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, [ + [0, "ll-hook", 1000, 1000000], [0, "raw-input", 1000 + 60000, 1000000], + [0, "register-hotkey", 1000, 1000000], [1, "register-hotkey", 1020, 1000000], + ]) + result = module.summarize_paired(path, "ll-hook") + self.assertEqual(result["raw-input"]["n"], 0) + self.assertEqual(result["raw-input"]["unmatched_baseline"], 1) + self.assertEqual(result["raw-input"]["unmatched_strategy"], 1) + self.assertEqual(result["register-hotkey"]["n"], 1) # each strategy tick used once + self.assertEqual(result["register-hotkey"]["unmatched_strategy"], 1) + + def test_missing_baseline_raises(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, [[0, "raw-input", 1000, 1000000]]) + with self.assertRaisesRegex(ValueError, "baseline ll-hook has no captures"): + module.summarize_paired(path, "ll-hook") + + +class RawDiagTests(unittest.TestCase): + HEADER = ["seq", "qpc", "frequency", "read_ok", "device", "type", "make_code", "vkey", "flags", "message", "extra"] + F8 = 0x77 + + def write(self, folder, rows): + path = Path(folder) / "raw.csv" + write_csv(path, self.HEADER, rows) + return path + + @staticmethod + def row(seq, vkey, flags=0, device=100, read_ok=1, rtype=1, make=66): + return [seq, 1000 + seq, 1000000, read_ok, device, rtype, make, vkey, flags, 0x100, 0] + + def test_target_received(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, [ + self.row(0, self.F8), self.row(1, self.F8, flags=1), + self.row(2, 0x41), self.row(3, self.F8, device=200), + ]) + result = module.summarize_raw_diag(path, self.F8) + self.assertEqual(result["total"], 4) + self.assertEqual((result["target_down"], result["target_up"]), (2, 1)) + self.assertEqual(result["devices"], 2) + self.assertEqual(result["by_vkey"][self.F8], 3) + self.assertEqual(result["diagnosis"], "received-target") + + def test_other_keys_only_means_filtered_not_missing(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, [self.row(0, 0x41), self.row(1, 0x42)]) + result = module.summarize_raw_diag(path, self.F8) + self.assertEqual(result["target_down"], 0) + self.assertEqual(result["diagnosis"], "received-other-keys-only") + + def test_no_keyboard_wm_input_at_all(self): + with tempfile.TemporaryDirectory() as folder: + result = module.summarize_raw_diag(self.write(folder, [self.row(0, 0, read_ok=0)]), self.F8) + self.assertEqual(result["read_failures"], 1) + self.assertEqual(result["diagnosis"], "no-readable-wm-input") + + def test_non_keyboard_type_is_not_counted_as_key(self): + with tempfile.TemporaryDirectory() as folder: + result = module.summarize_raw_diag(self.write(folder, [self.row(0, self.F8, rtype=0)]), self.F8) + self.assertEqual(result["target_down"], 0) + self.assertEqual(result["non_keyboard"], 1) + + +if __name__ == "__main__": + unittest.main()