Files
StellarX-kaifa/开发记录/模块/Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md
T
2026-10-07 04:09:36 +08:00

629 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段 0B 原生键盘消息隔离消费实验规格
## 基本信息
- 模块 ID:Module-20261001-0004
- 模块名称:阶段 0B 原生键盘消息隔离消费实验规格
- 状态:0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按 `ime_condition_not_met` 运行时 fail-open 并形成 `MustForward` 结论;未建立生产消费白名单
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / InputProbe / EasyX Event Loop
- 版本 / 分支:`master`,0C 收口基线 `e514a7d`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX 25.9.10
- 负责人:Codex 协作设计
- 阶段定位:阶段 0 的消费行为隔离实验;执行顺序为 0A → 0C → 0B → 阶段 1。
## 背景与目标
### 背景
阶段 0A 已确认:
- EasyX HWND/WndProc 与宿主 `peekmessage` 循环分属两个线程。
- 固定键在 WndProc 与 EasyX `EX_KEY` 两个观察面均可出现。
- 当前 EasyX 窗口为 ANSI,活动代码页为 CP936。
- 0A 只观察并转发,没有验证 WndProc `return 0` 后 EasyX 事件面是否仍然产生相应 `EX_KEY`。
阶段 0C 已确认:
- 宽字符 GDI 绘制链可用。
- 当前 DPI-unaware 96 与 Per-Monitor-V2 144 坐标基线可用。
- 微软拼音与搜狗候选框定位链通过当前环境人工验收。
- 0B 可以引用可哈希的绘制/坐标基线判断环境是否可比。
阶段 1 的 InputCollector 必须决定每类消息采用 `capture-and-forward` 还是 `capture-and-consume`。如果跳过 0B,这个决定只能依赖推测;FocusManager 又依赖阶段 1 的队列和事件模型,因此 0B 是阶段 1 的硬前置,不与阶段 1 或 FocusManager 并行开发。
### 目标
0B 只回答一个问题:
> 对指定原生键盘消息,在现有 WndProc 层观察后不调用旧窗口过程、直接返回 0,是否安全;该动作是否阻断 EasyX 宿主侧对应的 `EX_KEY`,以及是否破坏 EasyX/IME 的既有行为。
具体目标:
1. 对照 `ObserveAndForward` 与 `ObserveAndConsume` 两种且仅两种 WndProc 动作。
2. 单次只改变消息类型、虚拟键、IME 状态和动作中的一个变量。
3. 单独测量消费 `WM_KEYDOWN` 后真实宿主 `peekmessage(EX_KEY)` 是否仍返回对应事件。
4. 分开记录 KeyDown/KeyUp,禁止把成对输入合并成一个模糊结论。
5. 在微软拼音与搜狗的 Idle/Composition 场景中复验候选、提交、取消和定位行为。
6. 输出阶段 1 可引用的消息级结论及证据,不直接实现生产 InputCollector 或下游路由抑制。
### 非目标
- 不实现 InputCollector、NativeInputQueue、FocusManager、KeyboardRouter 或 TextBox。
- 不决定 Button/Dialog 是否接收已转发消息。
- 不把“转发给旧窗口过程但不再路由给控件”作为第三种 WndProc 动作。
- 不在 0B 中新增 `TranslateMessage`、`DispatchMessage` 或额外 `peekmessage`。
- 不修改 EasyX、输入法、系统键盘布局或用户输入配置。
- 不把自动合成输入等同于真实键盘或真实 IME 验收。
## 分层边界
### WndProc 动作:严格两值
```cpp
enum class ProbeAction
{
ObserveAndForward,
ObserveAndConsume
};
```
- `ObserveAndForward`:完成观察后调用旧窗口过程,原样返回旧过程的 `LRESULT`。
- `ObserveAndConsume`:完成观察后不调用旧窗口过程,直接返回当前实验规定的消费值;首期仅允许异步键盘消息,消费值固定为 0。
- 不允许 `ForwardButSuppressRouting`、`ObserveOnly` 等第三种动作进入枚举。
“消息必须转发给 IME,但阶段 1 不应把它路由给 Button/Dialog”属于阶段 1 的队列和路由决策,不属于 0B 的 WndProc 动作。
### 实验结论:三值
```cpp
enum class ExperimentVerdict
{
SafeToConsume,
MustForward,
Inconclusive
};
```
- `SafeToConsume`:同环境对照有效,消费动作确实命中,自动与所需人工门槛均通过,且没有破坏 EasyX/IME 行为。
- `MustForward`:消费导致输入丢失、IME/候选异常、系统行为异常,或无法达到阶段 1 所需语义。
- `Inconclusive`:环境、规则、时序、证据或输入法状态存在任何不确定。
动作枚举和结论枚举不可混用。`Inconclusive` 不是第三种动作;运行时仍必须 fail-open 到 `ObserveAndForward`。
## 不变量
1. 生产默认路径永远没有消费规则。
2. 0B 必须同时满足编译期开关、0A 运行开关和独立 0B 运行开关才可装载规则。
3. 每个进程只允许一条规则;每轮实验只允许一个目标消息/虚拟键/IME 条件组合。
4. 规则未命中时调用旧窗口过程并原样返回。
5. 规则配置、环境、IME 状态或日志完整性存在不确定时调用旧窗口过程并原样返回。
6. 同步窗口管理消息禁止进入消费规则表。
7. WndProc 线程不写文件、不等待宿主线程、不调用新的消息泵 API。
8. 0B 复用 0A 的跨线程序列、缓冲、flush 和隐私边界,不另建不可比较的日志格式。
9. `sequence` 只描述探测器观察顺序,不伪装成 Windows 队列序号。
10. 任何 `records_lost` 都使相关消费结论降级为 `Inconclusive`。
## 规则模型
```cpp
struct ProbeConsumeRule
{
std::string experimentId;
std::string ruleId;
UINT message;
std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume
std::uint32_t maxMatches;
std::string baselineId;
InputSource inputSource; // SendInput / Manual
};
```
首期规则只接受:
```text
WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP
```
原因是首个核心问题是 WndProc 物理键消息与 EasyX `EX_KEY` 的关系。`WM_CHAR/WM_SYSCHAR/WM_IME_CHAR` 涉及 ANSI/DBCS、隐私和 IME 回声状态机,在首期键消息结论稳定后再按同一 schema 扩展,不与物理键首轮混测。
下列消息永远不得加入 0B 消费规则:
```text
WM_GETDLGCODE
WM_IME_SETCONTEXT
WM_UNICHAR + UNICODE_NOCHAR
WM_SETCURSOR
WM_MOUSEACTIVATE
WM_INPUTLANGCHANGEREQUEST
WM_INPUTLANGCHANGE
WM_CLOSE / WM_DESTROY / WM_NCDESTROY
```
### 规则配置
诊断构建通过以下环境字段装载唯一规则:
```text
STELLARX_INPUT_PROBE=1
STELLARX_INPUT_PROBE_0B=1
STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_RULE_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_ACTION=ObserveAndForward|ObserveAndConsume
STELLARX_INPUT_PROBE_0B_MESSAGE=WM_KEYDOWN|WM_KEYUP|WM_SYSKEYDOWN|WM_SYSKEYUP
STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=<十进制或 0x 十六进制>
STELLARX_INPUT_PROBE_0B_SCAN_CODE=<0..255;256 表示任意>
STELLARX_INPUT_PROBE_0B_IME_CONDITION=Any|IdleOnly|ComposingOnly
STELLARX_INPUT_PROBE_0B_MAX_MATCHES=<正整数,小上限>
STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=SendInput|Manual
STELLARX_INPUT_PROBE_0B_BASELINE_ID=<0C combinedBaselineId>
STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=0|1
STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=<枚举整数>
```
Composition 人工实验还使用固定、非文本的客户区锚点配置:
```text
STELLARX_INPUT_PROBE_0B_IME_CARET_X/Y
STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT/TOP/RIGHT/BOTTOM
```
当前夹具固定为 `720×420`,caret point 为 `(225,282)`,排除矩形为
`(200,255)-(680,284)`。探测器在焦点/IME context 建立以及组合生命周期中重复调用
`ImmSetCompositionWindow(CFS_POINT)` 与
`ImmSetCandidateWindow(CFS_EXCLUDE)`;两个 setter 至少一次同时成功,才允许把
Composition 消费实验视为位置基线可用。
`experimentId`、`ruleId` 只允许 ASCII 字母、数字、点、下划线和连字符;不得把用户输入或候选文字写入标识字段。
## 0C 基线引用契约
0B runner 必须读取 0C 的 `baseline-set-v1.json`,并在启动夹具前验证:
1. baseline sidecar SHA-256 与文件一致。
2. `drawingBaselineManifest` 和 `coordinateBaselineManifest` 存在。
3. 两份 manifest 的 SHA-256 与 baseline 内记录一致。
4. 用 `drawingSha256 + ':' + coordinateSha256` 重新计算的组合值等于 `combinedBaselineId`。
5. `automatedStatus=pass`。
6. DPI awareness、DPI、窗口 A/W 属性与本轮期望一致。
当前可用基线为:
```text
drawingBaselineSha256 BCD0E3DD5D90C5D6F892F9127D81FD124C1DC7D619260B236DBCB8E53CBEC9CA
coordinateBaselineSha256 35D15BF2DD54D9A57B1F8470C8415025F1BAE6CE98D4628F13B64AB1EC381000
combinedBaselineId sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5
dpiAwareness unaware
dpiForWindow 96
windowUnicode false
ansiCodePage 936(来自 0A 同环境证据)
```
0C 基线不自动证明实际 TSF 输入配置文件身份。Composition 人工实验还必须记录操作者确认的实际输入法名称和身份来源;脚本参数只能表示预期名称。
## fail-open 硬规则
出现以下任一情况时,规则不得消费,实际动作必须退回 `ObserveAndForward`:
1. 配置缺失、无法解析、超长或出现未知枚举值。
2. 规则消息不在首期允许列表。
3. 虚拟键越界、`maxMatches` 为 0 或超过上限。
4. 同一进程出现多条规则或同一事件存在模糊匹配。
5. baseline ID、DPI、DPI awareness、窗口 A/W 属性或代码页不匹配。
6. Composition 实验的实际输入法身份未经操作者确认。
7. IME 状态条件无法由当前证据可靠确认。
8. Composition 消费实验尚未成功设置固定组合窗和候选窗位置。
9. 已发生缓冲溢出、采集丢失或序列不完整。
10. 规则命中次数已经达到 `maxMatches`。
11. 目标 HWND、窗口实例或线程归属与会话 attach 记录不一致。
12. 任意同步窗口管理消息或未识别消息。
fail-open 必须在 WndProc 决策前完成;不能先消费、事后再把结果标记为 `Inconclusive`。
## 日志 schema
0B 是 0A schema 的可加性 v2,不新建平行日志:
- 被动 0A 会话仍可写 schema v1。
- 启用 0B 时会话写 schema v2。
- v2 保留 v1 全部公共字段和 record type,并新增可选实验字段。
新增 `experiment_config` 记录:
| 字段 | 说明 |
|---|---|
| `experimentId/ruleId` | 本轮实验与唯一规则标识 |
| `requestedAction` | 两值动作之一 |
| `targetMessage/targetVirtualKey/targetScanCode` | 唯一原生目标;scan code 可为 `null` 表示任意 |
| `imeCondition` | `any/idle_only/composing_only` |
| `maxMatches` | 最大命中次数 |
| `inputSource` | `SendInput/manual` |
| `baselineId` | 引用的 0C 组合基线 |
| `configurationValid` | 配置是否合法 |
| `baselineMatched` | 运行环境是否匹配 |
| `ruleEligible` | 是否允许进入匹配 |
| `failOpenReason` | 结构化原因;无问题为 `none` |
| `imePositionConfigured/imeCaretPoint/imeLineRect` | Composition 人工实验的固定 IMM32 客户区锚点 |
目标 `wndproc_enter` 增加:
```text
experimentId
ruleId
ruleMatched
matchOrdinal
requestedAction
actualAction
failOpenApplied
failOpenReason
```
对应 `wndproc_leave` 增加相同关联字段,并继续保留:
```text
wndProcCallId
forwarded
forwardResultHex
```
`easyx_key` 增加 `experimentId/ruleId` 会话标签,但不伪造与 WndProc 的直接 ID 关联。跨线程关联由受控实验协议、目标消息、虚拟键、方向、scan code、QPC 区间和序列窗口共同完成。
IMM32 定位结果使用独立 `ime_position` 记录:
| 字段 | 说明 |
|---|---|
| `parentSequence` | 触发定位请求且已经入队的 `wndproc_enter.sequence` |
| `wndProcCallId` | 触发定位的窗口过程调用 |
| `triggerMessage` | `WM_SETFOCUS/WM_IME_SETCONTEXT/WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` |
| `imePositionContextAvailable` | 是否取得当前 HWND 的 HIMC |
| `compositionPositionSucceeded/candidatePositionSucceeded` | 两个 setter 的独立结果 |
| `imeCaretPoint/imeLineRect` | 本次使用的不可变客户区坐标快照 |
`ime_position` 必须在 IMM32 调用完成后再分配序号,禁止预留低序号后跨锁补写;setter 同步重入产生的消息应自然排在父 enter 与结果记录之间。分析器必须校验 `parentSequence` 存在且小于结果序号。
## WndProc 执行语义
```text
WndProc enter
→ 0A 解析并记录原始消息
→ 0B 校验规则、基线、IME 条件、丢失状态和命中上限
→ 实际动作 ObserveAndForward
→ CallWindowProc(oldWndProc)
→ leave(forwarded=true, 原样 result)
→ 实际动作 ObserveAndConsume
→ 不调用 oldWndProc
→ leave(forwarded=false, result=0)
→ return 0
```
只有成功写入目标 enter 记录且决策证据完整时才允许消费。采集器使用两个固定记录缓冲:安全点在锁内只交换 active buffer、清空新 active 的索引,文件序列化在锁外完成;WndProc 使用极短的阻塞内存临界区,不再因 `try-lock` 失败主动丢弃观察。缓冲覆盖等任何实际丢失仍必须写 `records_lost`,并使实验失效。
固定 IME 定位严格执行三阶段协议:锁内提交父 enter 并复制请求,锁外调用全部定位相关 IMM32 API,再次加锁校验 `enabled + attachedHwnd + hwndInstanceId` 后提交结果。禁止在状态锁内调用 `ImmGetContext`、两个 setter 或 `ImmReleaseContext`。定位 API 调用期间使用 WndProc 线程局部标志阻止重入消息递归发起定位;重入消息本身仍按正常路径观察和转发。
## EX_KEY 核心测量
### 必答问题
> 在 WndProc 对目标 `WM_KEYDOWN` 执行 `ObserveAndConsume` 后,宿主线程现有的 `peekmessage(&msg, EX_KEY, true)` 是否仍返回同一虚拟键的 KeyDown?
该问题必须作为独立结果字段,而不是隐藏在总通过状态中:
```text
easyxVisibility = present_once | present_multiple | absent | inconclusive
```
解释:
- `absent`:WndProc 消费阻断了 EasyX 键盘面;阶段 1可以评估以采集队列作为唯一业务输入源。
- `present_once/present_multiple`:`return 0` 不足以消除 EasyX 事件;阶段 1 必须显式排空或忽略 EasyX 键盘事件,不能依赖消费避免双触发。
- `inconclusive`:对照缺失、额外输入污染、关联不唯一或日志丢失。
### 受控关联协议
每轮自动实验:
1. 启动独立进程和唯一 `experimentId`。
2. 等待 HWND、探测 attach 和空闲窗口稳定。
3. 在启用目标输入前排空历史事件;探针不得为实验新增第二个 `peekmessage`。
4. 只用一次 `SendInput` 产生固定虚拟键的 Down/Up。
5. 规则只匹配其中一个方向,另一个方向作为普通转发事件。
6. 等待宿主安全点 flush 后关闭窗口。
7. 使用目标 message、vk、可选 scan code、方向和受控时间窗关联 WndProc 与 `easyx_key`;指定 scan code 时,WndProc 和 EasyX 两个观察面都必须匹配该值。
8. 对照组和消费组分别使用新进程,禁止在同一进程热切换 action。
自动注入标记为 `inputSource=SendInput`。`PostMessage` 只可用于结构烟测,不能作为物理键/IME 行为结论。
## 实验矩阵
### 0B-1:核心自动 A/B
第一门槛固定为 Idle 状态的 F24:
| 组 | 消息 | VK | 动作 | 目标 |
|---|---|---:|---|---|
| Control | `WM_KEYDOWN` | `VK_F24` | Forward | 证明对照能同时看到 WndProc 和 `EX_KEY` |
| Experiment | `WM_KEYDOWN` | `VK_F24` | Consume | 判断消费后对应 `EX_KEY` 是否仍出现 |
| Control | `WM_KEYUP` | `VK_F24` | Forward | 建立 KeyUp 对照 |
| Experiment | `WM_KEYUP` | `VK_F24` | Consume | 判断 KeyUp 是否独立受影响 |
最初候选 F8 在当前中文 IME 环境中不适合作为自动门槛:`SendInput` 的 F8 KeyDown 没有到达目标 WndProc,仅观察到 KeyUp。当前证据没有确认它在哪一层被截断,也不据此推断 EasyX 消费关系;因此固定键改为当前环境中可稳定通过 WndProc 与 `EX_KEY` 两个观察面的 F24。
#### 0B-1 当前自动结论(2026-10-06)
| 目标 | Forward `EX_KEY` | Consume `EX_KEY` | 自动状态 | 当前结论 |
|---|---|---|---|---|
| `WM_KEYDOWN + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyDown |
| `WM_KEYUP + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyUp |
| 错误 baseline + 请求 Consume | `present_once`(实际 Forward) | 不适用 | `pass` | `baseline_id_mismatch` 正确 fail-open,规则零命中、零消费 |
三轮有效证据均满足:`sequenceStrict=true`、`recordsLost=0`、目标观察唯一、`closeObserved=true`、`sessionEndObserved=true`。证据如下(`TestResults` 为本地忽略目录,不进入源码提交):
```text
KeyDown A/B
TestResults/InputProbe/0B/20261006-103302-713-f22cbc3c-4d54-4da6-a3da-9b32cd0aeb43/comparison-v1.json
SHA-256 55B2160CF84594D653B5BF4B71A8DA8E9E2FF9795EB88BC16BA0F61E94DCD440
KeyUp A/B
TestResults/InputProbe/0B/20261006-103644-778-f4086f15-fbd7-4328-ac4c-fda378ef4594/comparison-v1.json
SHA-256 7A1381E8E6B13B3808754863DCD88B38B07D8E79630614A8ACB75649DE780194
baseline mismatch fail-open
TestResults/InputProbe/0B/20261006-103714-598-d0f4b1f1-dc03-47da-a039-ca2cb7523f7b/comparison-v1.json
SHA-256 A69A3646ECBA1FCACE921B15DB6A82FCD27F4E9B1747767DA500310250D7D520
```
上述证据只回答 F24 的 WndProc/`EX_KEY` 关系,尚不足以把任何生产消息判为 `SafeToConsume`;普通键、导航键、快捷键和真实 IME 人工矩阵完成前,最终 verdict 仍为 `Inconclusive`。
随后再扩展到:
- 普通字母键。
- Tab、方向键、Enter、Escape、Space。
- Ctrl 组合键。
- `WM_SYSKEYDOWN/UP`,但不得用会改变系统状态或关闭窗口的组合。
### 0B-2:自动稳定性
每轮同时验证:
- 进程、HWND 和宿主循环正常存活。
- 鼠标、窗口关闭和 resize 路径未受规则影响。
- enter/leave 配对符合实际动作。
- `sequence` 严格递增。
- 无原始文字泄露。
- 无未记账丢失。
- 对照与消费运行引用相同 0C baseline ID。
- runner 必须通过 `GetWindowThreadProcessId` 跟踪真实 UiFixture 进程;`cmd /c` 仅是环境变量启动包装,不能作为退出或超时判断对象。
- EasyX 旧窗口过程可能在 `WM_CLOSE` 内直接终止进程。探测构建须在转发 `WM_CLOSE` 前完成最后一次耐久 detach,写入 `window_detach/session_end`。
- 宿主 flush 与 WndProc 关闭收口必须由独立文件写锁串行,统一采用“文件写锁 → 状态锁”的锁序,禁止跨线程快照乱序落盘。
- 安全点不得在状态锁内复制或写出整批记录;双固定缓冲的交换必须为常数时间,避免 WndProc 因宿主 flush 占锁而丢失观察。
- 不得在状态锁内调用可能同步重入窗口过程的 IMM32 setter;`ime_position` 父序号关联失败时实验必须判为 `inconclusive_ime_position_link`。
### 0B-3:真实 IME 人工 A/B
微软拼音和搜狗分别测试,Idle 与 Composition 分开;每次只启用一个规则。固定非敏感测试串沿用 `ceshi`。
至少覆盖:
- 组合开始前的普通字母键。
- 候选态方向键和数字选择键。
- Enter 提交。
- Escape 取消。
- Space 选择候选。
- Ctrl 快捷键。
- 候选框位置是否仍符合 0C 三 anchor 基线。
- 提交后切换 anchor 是否出现 stale position。
Composition 条件只在日志确实观察到 composing 状态时匹配;如果某 IME 已显示候选 UI 但没有向目标 HWND 提供足够生命周期证据,规则必须 fail-open,不能按 Idle 误消费。单次证据不足仍为 `Inconclusive`;如果受控人工运行已经同时证明候选 UI 存在、目标键到达且 Composition 条件不可可靠确认,则该消息/IME 组合形成 `MustForward`:生产路径必须转发给旧窗口过程,并在阶段 1 抑制其进入 Button、Dialog 和焦点导航等业务路由。
#### 0B-3 首轮夹具发现(2026-10-06)
首轮微软拼音与搜狗测试不能作为正式 A/B 证据,原因不是两个输入法都定位失败,而是夹具本身存在三项诊断缺陷:
1. `input-consume-probe` 未调用两个 IMM32 定位 setter,候选框落在桌面左下角;这与已通过的 0C 坐标链不一致。
2. 操作者按 Escape 时,组合态 WndProc 实际看到的是
`WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`;`WM_KEYUP` 才恢复为
`VK_ESCAPE(27)`。因此原规则 `WM_KEYDOWN + VK_ESCAPE` 不可能命中。
3. 运行出现 3 条 `wndproc_lock_contention`,`recordsLost=3`,按本规格必须判为
`inconclusive_capture_loss`。
微软文档说明,IME 处理后会把虚拟键改为 `VK_PROCESSKEY`;应用只有在自己的消息循环中、调用 `TranslateMessage` 之前才能用 `ImmGetVirtualKey` 恢复原 VK。StellarX 当前钩子位于 EasyX 的 WndProc,时机已经晚于该边界,因此 0B 不伪造“逻辑 Escape”,而是让规则匹配实际可观察的原生三元组;操作者指令仍写“按 Escape”。参考:<https://learn.microsoft.com/windows/win32/api/immdev/nf-immdev-immgetvirtualkey>。
无效运行保留为夹具缺陷证据:
```text
TestResults/InputProbe/0B/20261006-140720-218-96b7392e-548a-44cf-9343-5d1449bf73fa
manual-evidence/composition-candidate-position-wrong.png
SHA-256 61E4A04CEE329B13BB22C919DC233A07ABF3EF6C298E64FB7B6E70F6D60DE0A3
```
该截图只证明候选框位于夹具窗口之外;运行混入多次组合且存在记录丢失,不用于区分微软拼音与搜狗,也不用于判断消费是否安全。
#### 0B-3 首次修复复验发现(2026-10-06)
首次微软拼音 Forward 复验出现窗口未响应和无法输入。根因是定位实现持有不可重入的 SRW 状态锁时调用两个 IMM32 setter;setter 同步重入 WndProc 后再次获取同一把锁,形成线程自锁。失败运行保留于:
```text
TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward
```
该日志耐久保存 268 条严格递增、零丢失记录,但没有关闭收口;卡死发生在下一次定位进入且早于新记录入队是结合调用链得出的推断,不把它表述为日志直接证明的栈。修复拆成锁内请求、锁外 IMM32、锁内结果,并增加递归定位保护;详见 `BUG-20261006-0003` / `Fix-BUG-20261006-0003`。
修复后的微软拼音 Forward 运行已经通过:
```text
TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward
comparison SHA-256 7CBEA9089E69BE921829BE49FC74B3F75E6BDAB8BB6DD252F4D4F1D6338AA456C
```
- `experimentValidity=valid`、`sequenceStrict=true`、`recordsLost=0`。
- Escape 原生规则唯一命中,`ObserveAndForward` leave 完整,对应 EasyX 键可见一次。
- `imePositionAttemptCount=18`、`imePositionSuccessCount=18`、`imePositionOrphanCount=0`。
- 组合开始/结束各 2 次,GCS 结果 1 次,关闭和 `session_end` 完整。
- 人工截图 `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png` 确认候选 UI 位于固定锚点附近;SHA-256 `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`。
该 Forward 运行关闭 `BUG-20261006-0003`;随后继续执行微软拼音 Consume 与搜狗运行,以形成消息消费 verdict。
微软拼音 Consume 第一次操作只完成了候选提交,没有按 Escape。日志中目标 scan code 1 计数为 0,runner 正确判为 `unexpected_target_count`;该运行只保留位置/响应证据,不计入 Consume 结论:
```text
TestResults/InputProbe/0B/20261006-235044-603-9c996ff5-f85b-4127-aac0-68d9f0b89908/consume
manual-evidence/microsoft-pinyin-consume-position-only-no-escape.png
SHA-256 B7B218BEDD343A8E2023A6FE18FC678E5488CB17B1C857D46A254EC06F9095FB
```
按“候选显示时只按一次 Escape”重跑后的微软拼音 Consume 有效:
```text
TestResults/InputProbe/0B/20261007-000152-550-4c83bcda-12e8-4f4e-b616-1d1fe1f56a27/consume
comparison SHA-256 BDB2E202EE7B050D74E6989208D7A63C2D74766292BC4410F5020213787CB698
```
- `experimentValidity=valid`、915 条记录严格递增、`recordsLost=0`。
- Escape 目标唯一命中,`consumedMatchCount=1`、`forwardedMatchCount=0`。
- 对应 EasyX 键为 `absent`,结构关系为 `easyx_suppressed_by_wndproc_consume`。
- `imePosition=7/7`、`imePositionOrphanCount=0`,关闭与 `session_end` 完整。
- `successfulResultCount=0`,与本轮执行取消而非提交一致;操作者完成候选消失检查后关闭窗口,未报告位置或响应异常。
微软拼音 A/B 当前对照为:Forward 时目标键进入 EasyX 一次,Consume 时目标键在 EasyX 面消失;两轮 IME 定位与窗口响应均正常。该结论仍限定于微软拼音和当前 ANSI/CP936、96 DPI 基线。
#### 0B-3 搜狗生命周期缺失与 MustForward 结论(2026-10-07)
搜狗 Forward 受控运行中,候选 UI 实际可见且位置正常,但目标 HWND 没有收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION/WM_IME_ENDCOMPOSITION`。操作者按 Escape 时仍观察到唯一的 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,探测状态为 `idle`,因此 `ComposingOnly` 规则没有命中:
```text
TestResults/InputProbe/0B/20261007-000604-592-4ddb4314-769e-4c4a-b3e8-696bd0489956/forward
JSONL SHA-256 539BE9BC378EB86825A1040D2A39BF7A1FD3C7F7C07966B67BBC753EB7F83608
```
- 1181 条记录严格递增、`recordsLost=0`、定位 6/6、无孤立定位结果、关闭收口完整。
- `experimentValidity=unexpected_match_count` 准确表示“请求的 ComposingOnly 条件不可证明”,不表示候选 UI 或 Forward 行为异常。
- 对应 EasyX 键为 `present_once`;该轮不得改写为 `IdleOnly` 后强行消费。
最终轮显式请求 `ObserveAndConsume`,并把预期运行时退让原因固定为 `ime_condition_not_met`:
```text
TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume
comparison SHA-256 0FD8E46493A6D84FD8C0BAD55D22CDB55B48F13BE94EB2831D39E14B7993FBEC
```
- `experimentValidity=runtime_fail_open`,目标观察唯一、规则命中 0 次、消费 0 次。
- enter/leave 的唯一原因均为 `ime_condition_not_met`,实际动作退回 `ObserveAndForward`,旧窗口过程被调用。
- `failOpenForwardedCount=1`,对应 EasyX 键为 `present_once`,结构关系为 `easyx_visible_after_fail_open_forward`。
- 582 条记录严格递增、`recordsLost=0`、定位 3/3、`imePositionOrphanCount=0`、关闭与 `session_end` 完整。
- 最终 verdict 为 `MustForward`。这里的“有效”是指 fail-open 契约得到验证,不是搜狗真实执行过 Consume。
阶段 1 因此采用保守且跨 IME 一致的基线:原生键默认采集并转发;`VK_PROCESSKEY` 和已确认组合态键不得路由到 Button、Dialog、快捷键或焦点导航。微软拼音的单点 Consume 成功只证明 WndProc 消费会阻断对应 EasyX 键面,不足以建立生产消费白名单。
### 0B-4:结论回写
每个消息/虚拟键/IME 状态组合必须输出:
```text
controlSessionId
consumeSessionId
baselineId
easyxVisibility
imeBehavior
windowBehavior
verdict
evidenceHashes
knownScope
```
只有完成所需自动和人工矩阵的组合才可判定 `SafeToConsume`;其余保持 `Inconclusive` 或因明确破坏判定 `MustForward`。
## 验收门槛
自动运行有效必须同时满足:
1. `experiment_config.ruleEligible=true`。
2. 正常 A/B 的目标规则恰好命中预期次数;预期运行时 fail-open 的目标观察次数必须符合 `maxMatches`、规则命中数必须为 0。
3. Forward 组对应 leave 为 `forwarded=true`。
4. 真正 Consume 组对应 leave 为 `forwarded=false` 且返回 0;预期 fail-open 组必须为 `actualAction=ObserveAndForward`、`forwarded=true`。
5. Forward 对照组至少出现一次对应 `easyx_key`。
6. Consume 或运行时 fail-open 组明确得到 `present_once/present_multiple/absent` 之一。
7. `sequence` 严格递增,enter/leave 可配对。
8. `records_lost=0`,无缓冲溢出或锁竞争丢失。
9. 原生关闭与 `session_end` 完整。
10. 两组 baseline ID 和关键环境字段一致。
11. 运行时 fail-open 的 enter/leave 原因必须唯一且一致,分析结果必须为 `runtime_fail_open`;原因不明或半边错配时仍为 `Inconclusive`。
人工 IME 运行有效还要求:
1. 操作者明确确认实际输入法身份。
2. 使用固定非敏感输入。
3. 记录候选、提交、取消是否正常。
4. 位置判断引用相同环境的 0C 基线。
5. 日志至少有一次 `ImmGetContext`、composition setter 与 candidate setter 同时成功,且 caret/line rect 等于固定夹具锚点。
6. 自动失败不能被人工通过覆盖。
## 隐私与安全
- 沿用 0A:文字只记来源、长度、结构和会话摘要,不记原文。
- 0B 配置不允许携带用户输入文本。
- 自动测试使用 F24 等非文字键作为第一门槛;当前环境不使用 KeyDown 未到达目标 WndProc 的 F8。
- 人工 IME 只使用约定的固定非敏感测试串。
- 截图只包含探测窗口和候选 UI,不包含其他应用。
- 无论探测错误、日志错误还是环境错误,默认行为都是转发。
## 实现与影响
- 已复用并实现:
- `src/StellarX/SxInputProbe.cpp`
- `src/StellarX/internal/SxInputProbeInternal.h`
- `src/StellarX/Window.cpp`
- `tests/UiFixture.cpp`
- `scripts/analyze-input-probe.ps1`
- 已新增:
- `scripts/run-input-consume-probe.ps1`:自动 A/B、单规则人工模式、0C baseline 校验、真实进程跟踪和 comparison 输出。
- `scripts/run-input-consume-probe-manual.ps1`:真实 IME 人工单规则入口和固定 `ceshi` checklist。
- 分析摘要中的实验有效性、`easyxVisibility`、关闭完整性和 fail-open 证据字段。
- 可选 scan code 精确规则、固定 IME caret/line rect、IMM32 定位调用及位置成功计数。
- 双固定记录缓冲;安全点常数时间交换、锁外文件写入,取消 WndProc `try-lock` 丢记录路径。
- IMM32 定位三阶段拆锁、线程内递归定位保护、独立 `ime_position` 结果记录与 `parentSequence` 校验。
- 不修改公共 API。
- 不新增生产焦点或键盘路由行为。
- 探测关闭时不产生文件、线程、定时器或消息返回差异。
## 测试与验证
- F24 `WM_KEYDOWN/UP` 的 Forward/Consume 自动 A/B 已完成,均直接报告消费后 `EX_KEY=absent`。
- 错误 baseline 的请求 Consume 负向实验已完成,实际动作正确 fail-open 为 Forward。
- 默认 Debug x64 构建与现有单元测试通过(15/15);探测开关构建已由三轮最终自动实验覆盖。
- 三份 PowerShell 脚本在 Windows PowerShell 5.1.22000.2538 与 PowerShell 7.6.5 下均为零解析错误;`git diff --check` 无空白错误,仅有仓库换行规范提示。
- 0B-3 重入死锁修复后的微软拼音 Forward/Consume 已通过;搜狗候选态未暴露可证明的 Composition 生命周期,最终 Consume 请求按 `ime_condition_not_met` 正确 fail-open 为 Forward,形成 `MustForward` 结论。
- 2026-10-07 最终回归:Debug x64 单元测试 15/15、UI fixture 1 轮通过;F24 KeyDown A/B 新运行通过(comparison SHA-256 `1C1E4EDA1251A460AC250AE57F4F8A5FC8AFE4A9C0EDA87C731BA3C6301FB6FF`),既有 KeyUp 与 baseline mismatch 证据使用当前分析器回放仍分别为 `valid` 与 `ineligible_fail_open`。
- 阶段 1 可以按“默认采集并转发、EasyX `EX_KEY` 只排空、`VK_PROCESSKEY` 与已确认组合态键不进入业务路由”的基线开始;任何生产消费白名单仍需新的逐键证据。
- 构建至少覆盖 Debug x64 探测开关开启、默认关闭构建和现有 15 个单元测试。
- PowerShell 脚本必须同时通过 Windows PowerShell 5.1 与可用的 PowerShell 7 解析检查。
## 已知限制
- 自动 `SendInput` 不等同于物理键盘。
- 当前中文 IME 环境下,自动 F8 KeyDown 未到达目标 WndProc;截断层级尚未确认,因此 F8 运行只作为“门槛键选择失败”记录,不作为消费行为证据。
- 相同中文 HKL 下的 TSF 输入配置文件不能只靠 HKL 自动区分。
- 当前 0C 基线没有覆盖 100%、125% 和跨屏;不同环境的 0B IME 位置结论必须为 `Inconclusive`。
- 首期不消费文字和 IME 消息,只先回答物理键与 EasyX `EX_KEY` 的核心关系。
- WndProc 层只能可靠记录 `VK_PROCESSKEY` 与 scan code,不能在 EasyX 已调用 `TranslateMessage` 后再依赖 `ImmGetVirtualKey` 恢复原始逻辑键。
- 搜狗候选 UI 可见时可能不向目标 HWND 发送标准 Composition 生命周期;不得把 `imeState=idle` 推断为“当前键不属于 IME”。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:`BUG-20261006-0002` / `Fix-BUG-20261006-0002`;`BUG-20261006-0003` / `Fix-BUG-20261006-0003`
- Commit:待用户确认后提交
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)