docs: 输入系统 v0.7 设计与 0A 探测规格

This commit is contained in:
Codex
2026-10-05 01:14:58 +08:00
parent c218c25310
commit 3d08bac1b9
5 changed files with 2445 additions and 0 deletions
@@ -0,0 +1,57 @@
# BUG-20261004-0001
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261004-0001
- 标题: EasyX 原生窗口销毁后宿主事件循环未退出
- 状态:已修复
- 严重性:S2
- 优先级:P1
- 模块: Window / Dialog / EasyX 事件循环 / 输入探测
- 版本 / 分支: `master`,生产修复基线 `c0eca1d`
- 环境: Windows / EasyX / Visual Studio 2022 / Debug x64
- 发现人: 用户与 Codex 协作探测
- 关联 Fix ID: Fix-BUG-20261004-0001
## 问题描述
- 现象: 真实微软拼音探测完成后关闭 EasyX 窗口,窗口从桌面消失,但 `StellarX.UiFixture.exe` 和调用它的人工探测脚本继续运行,脚本无法进入隐私安全摘要阶段。
- 影响范围: `Window::runEventLoop()` 及 `Dialog::Show()` 自建轮询循环;当 EasyX 内部窗口线程完成原生 HWND 销毁,而宿主 `peekmessage` 线程没有收到对应 `WM_CLOSE` 时均可能挂住。
- 期望结果: 原生窗口生命周期结束后,普通事件循环和模态事件循环都应在安全点自行退出,探测脚本继续分析已落盘日志。
- 实际结果: 事件循环仅以宿主侧 `WM_CLOSE` 为退出条件;窗口已经销毁时仍继续轮询。
## 复现信息
- 前置条件: 构建开启 `StellarXInputProbe` 的 `StellarX.UiFixture`,通过 `scripts/run-input-probe-manual.ps1 -ImeName MicrosoftPinyin` 启动。
- 复现步骤:
1. 按人工清单完成微软拼音提交、重复字符、取消、候选选择和中英文切换操作。
2. 关闭 EasyX 原生窗口。
3. 观察窗口消失后脚本仍未输出摘要,fixture 进程仍存在。
- 复现概率:修复前真实人工会话稳定复现
- 最小复现 Demo:`StellarX.UiFixture.exe input-probe <result-dir>`;普通循环和真实 `MessageBox::showModal()` 均可构造关闭回归。
- 证据:
- 会话目录:`TestResults/InputProbe/manual-20261004-194521-908-MicrosoftPinyin`。
- 日志观察到 `WM_CLOSE -> WM_DESTROY -> WM_NCDESTROY`,但宿主 EasyX 消息批次没有取得 `WM_CLOSE`。
- 关闭后原挂起进程 PID 32948 仍存在,结束该进程后脚本才能退出。
## 初步分析
- 疑似位置: `Window::runEventLoop()`、`Dialog::Show()` 与 `Window::WndProcThunk()` 之间的原生窗口生命周期同步。
- 触发条件: EasyX 的 HWND/WndProc 所在线程与调用 `peekmessage` 的宿主线程不同,且原生关闭消息没有映射为宿主侧 `EX_WINDOW/WM_CLOSE`。
- 相关线索:
- 独立 `EasyX.ThreadProbe` 已确认双线程是 EasyX 固有模型,不是 UiFixture 夹具造成。
- 旧事件循环只在 `peekmessage` 返回的消息为 `WM_CLOSE` 时停止。
- HWND 已失效后继续尺寸轮询或绘制还会扩大为失效句柄访问风险。
- 最近相关改动: 阶段 0A 探测器首次把 WndProc 与宿主批次放进同一时间线,暴露了此前被单线程假设遮蔽的生命周期缺口。
## 跟踪信息
- 首次发现时间: 2026-10-04
- 最后更新时间: 2026-10-05
- 修复版本: `c0eca1d`(`fix: 原生窗口销毁后宿主事件循环未退出`)
- 验证版本: Debug x64;单元测试 15/15;普通与模态原生关闭自动回归通过
- 备注: EasyX `EX_SHOWCONSOLE` fixture 由 `Start-Process` 托管时,原生关闭可能返回 `0xC000013A`;该状态与“窗口关闭后进程仍挂住”是不同问题,回归以有界时间内自行退出为核心断言。阶段 0A 诊断实现与线程归属复现见 `c218c25`。
@@ -0,0 +1,95 @@
# Fix-BUG-20261004-0001
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261004-0001
- 关联 BUG ID: BUG-20261004-0001
- 修复目标: 原生 EasyX HWND 销毁后,让普通和模态宿主事件循环安全退出
- 状态:已完成
- 负责人: Codex
- 分支 / 版本: `master`,生产修复基线 `c0eca1d`
## 根因分析
- 根因: EasyX 原生 WndProc 与 StellarX 宿主 `peekmessage` 循环分属不同线程;原生窗口线程收到并处理 `WM_CLOSE/WM_DESTROY/WM_NCDESTROY` 时,宿主队列不保证出现 `WM_CLOSE`。旧实现却只依赖宿主消息退出,因此 HWND 已销毁后循环仍然存活。
- 触发条件: 用户点击标题栏关闭、发送 `SC_CLOSE`,或在模态循环期间销毁宿主原生窗口。
- 为什么之前没发现: 旧设计隐含假定 WndProc 与宿主循环同线程且关闭消息会进入 EasyX `EX_WINDOW`;阶段 0A 的双后端时序日志和独立线程探针才将该假定证伪。
- 关键证据: 微软拼音真实日志中 WndProc 链完整观察到 `WM_CLOSE -> WM_DESTROY -> WM_NCDESTROY`,宿主线程没有取得 `WM_CLOSE`,窗口消失后进程仍挂起。
## 修复方案
- 修复思路: 由 WndProc 在线程安全边界发布原生窗口生命周期,宿主普通循环和模态循环在每轮开始及消息批次结束后读取该状态并退出。
- 关键改动:
- `Window` 增加 `std::atomic<bool> nativeWindowAlive`,WndProc 生产者使用 release 发布,宿主循环使用 acquire 读取。
- `draw()` 在取得有效 HWND 后初始化生命周期状态;子类化失败时退回 `IsWindow()` 兜底。
- `WndProcThunk()` 只在旧窗口过程与探测 leave 都结束后,且作为对 `self` 的最后一次访问,于 `WM_NCDESTROY` 发布 `false`。
- `Window::runEventLoop()` 在每轮开始和批次结束后检查状态,窗口死亡时在安全点 flush 探测记录并返回。
- `Dialog::Show()` 在进入模态、每轮开始和批次结束后检查状态,并检查两处 `GetClientRect` 返回值;窗口死亡后不再执行尺寸轮询、绘制或访问失效 HWND。
- 析构时只对仍存活的窗口恢复旧 WndProc,仍调用 `closegraph()` 完成 EasyX 清理。
- 人工探测脚本先分析日志再判断退出码;仅在日志序列严格递增、无文字原值泄露且观察到原生关闭时接受 `0xC000013A`,不泛化忽略异常退出。
- 涉及文件 / 类 / 函数:
- `include/StellarX/Window.h`
- `src/StellarX/Window.cpp`:`Window::draw()`、`Window::runEventLoop()`、`Window::WndProcThunk()`、析构函数
- `src/StellarX/Dialog.cpp`:`Dialog::Show()`
- `tests/UiFixture.cpp`:`native-close` 与 `modal-close` 场景
- `scripts/run-ui-tests.ps1`:普通与模态原生关闭回归
- `scripts/run-input-probe-manual.ps1`:退出码与日志证据联合判定
- 影响的 API / 行为: 无公共 API 变化;窗口关闭后的宿主进程现在会自行结束。
- 关键约束 / 不变量:
- 不得在转发 `WM_NCDESTROY` 前发布死亡状态,否则宿主可能提前析构仍在执行的 `WndProcThunk`,形成 use-after-free。
- 发布 `false` 后 WndProc 不得再访问 `self`。
- 模态循环发现窗口死亡后不得继续 `GetClientRect`、绘制或处理控件。
- 回滚点 / 开关: 回退 `nativeWindowAlive`、普通/模态循环检查及对应关闭回归;探测脚本调整可独立回退。
## 影响评估
- 影响范围: Window 原生生命周期、普通事件循环、Dialog 模态循环、输入探测收尾。
- 兼容性影响:无公共 API 或控件事件语义变化。
- 行为变化:有;原生窗口销毁后事件循环不再等待一个可能永远不会进入宿主队列的 `WM_CLOSE`。
- 性能影响:每轮增加少量 atomic acquire 读取,可忽略。
- 回归风险:
- 生命周期发布过早会使 `Window` 在 WndProc 尚未返回时被析构。
- 子类化失败路径若只依赖 atomic,可能无法观察真实 HWND 状态,因此保留 `IsWindow()` 兜底。
- 模态循环退出顺序错误可能继续绘制失效 HWND 或遗漏 `dialogOpen` 清理。
- `EX_SHOWCONSOLE` 托管运行的 `0xC000013A` 不能被无条件当作成功。
## 验证结果
- 验证步骤:
1. 默认探测关闭构建 `StellarX.Core`、`StellarX.UiFixture`。
2. 执行 Debug x64 单元测试。
3. 运行原有六个 UI 场景,并分别对 `native-close` 普通循环和 `modal-close` 真实模态循环发送 `WM_SYSCOMMAND/SC_CLOSE`;`input-probe` 仅保留给人工 IME 探测。
4. 断言两种关闭场景都在 5 秒内自行退出,并分别生成 `close-result.json`。
5. 以 `/p:StellarXInputProbe=true` 重建,运行探测开启的 UI 关闭 smoke,逐个解析 JSONL 并检查 sequence、丢失和文字原值泄露。
6. 对真实微软拼音日志运行 `analyze-input-probe.ps1`,检查关闭证据与隐私字段。
- 验证结果:
- Debug x64 默认构建通过;VSTest 15/15 通过。
- 2026-10-04 UI 回归通过 1 轮;普通和模态关闭均在 5 秒内自行退出,`close-result.json` 均为 `nativeCloseExited=true`。
- 探测开启的 Core/UiFixture 完整重建通过。
- 2026-10-05 探测开启的最终自动关闭 smoke 产生 8 份可解析 JSONL:全部 sequence 严格递增、`closeObserved=true`、无文字原始 `wParam` 泄露、无 `records_lost`;自动原生关闭均未伪造 `session_end`,人工正常关闭会话则已验证可写出 `window_detach/session_end`。
- 真实微软拼音日志可解析、sequence 严格递增、`closeObserved=true`、文字原值泄露为 0;存在 5 条已显式记录的 `wndproc_lock_contention` 丢失。
- 搜狗真实提交日志正常写出 `WM_CLOSE/WM_DESTROY/WM_NCDESTROY`、`window_detach` 和 `session_end`;第二次短输入布局会话同样正常收口,证明修复后的人工脚本能够在关闭后稳定进入摘要阶段。
- 输入布局 request 补观测后重新执行 1 轮完整 UI 回归并通过;其中普通与模态原生关闭断言仍在时限内退出,说明新增被动消息观察没有回归关闭行为。
- request 补观测后的专用 Win+Space 会话也正常写出 `WM_CLOSE/WM_DESTROY/WM_NCDESTROY`、`window_detach` 与 `session_end`。
- 回归检查: 正常事件循环、真实模态循环、探测关闭、探测开启、原生标题栏关闭。
- 验证证据:
- `TestResults/ui/20261004-203032-495-20980`
- `TestResults/InputProbe/close-smoke-20261004-203149-536`
- `TestResults/InputProbe/manual-20261004-194521-908-MicrosoftPinyin`
- `TestResults/InputProbe/manual-20261004-204556-353-Sogou`
- `TestResults/InputProbe/manual-20261004-223833-893-Sogou`
- `TestResults/InputProbe/request-change-ui-regression-final-20261004`
- `TestResults/InputProbe/manual-20261004-231414-758-Sogou`
- `TestResults/InputProbe/automated-precommit2`
- `TestResults/easyx-thread-probe/20261005-005952-040-7276.json`
## 落地信息
- Commit: `c0eca1d`(生产修复);`c218c25`(阶段 0A 诊断与复现)
- PR:无
- 发布版本:未发布
- 备注: 未删除任何探测结果;微软拼音和搜狗真实输入法人工测试均已完成。
@@ -0,0 +1,792 @@
# 新增功能模块 / 模块重构
> 适用场景:新增模块、重大模块重构、核心架构能力演进。
> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
## 基本信息
- 模块 ID:Module-20261001-0002
- 模块名称:阶段 0A 输入链路被动探测规格
- 状态:已完成(0A 核心与微软拼音/搜狗人工验证已完成;0B 为后续独立阶段)
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / Input Probe
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + IMM32
- 负责人:Codex 协作设计
## 背景与目标
- 背景:
- 通用输入、焦点和键盘路由设计需要建立在 EasyX 真实消息链路之上。
- 当前尚不确定 EasyX 是否以及何时调用 `TranslateMessage`,WndProc 与 EasyX `EX_KEY` 是否重复报告,普通循环和模态循环是否一致,以及 IME 结果消息是否形成回声。
- 在没有证据前直接实现消费策略、去重状态机或 TextBox 输入链,会把推断写成框架行为。
- 当前痛点:
- 调试器逐条查看消息无法稳定覆盖完整输入批次和模态重入。
- 直接记录文字原文存在隐私风险。
- 在 WndProc 内同步写盘会扰动需要观察的消息时序。
- “为了探测额外读取 EasyX 消息”本身会改变被测对象。
- 目标:
- 定义阶段 0A 的版本化日志 schema、观察点、字段、缓冲、flush 和隐私规则。
- 被动回答 WndProc、EasyX、IMM32 和宿主事件循环之间的顺序与重复关系。
- 为阶段 0B 的逐消息消费实验和阶段 1 InputCollector 提供共同输入。
- 保证探测开启时业务语义透明,并把不可避免的时序扰动降到最低。
- 非目标:
- 第一切片不实现 IMM 结果串摘要、在线回声关联和真实中文 IME 结论;这些仍需后续切片与人工输入验证。
- 0A 不消费任何消息,不修改任何 WndProc 参数或返回值。
- 0A 不新增焦点、键盘路由、TextBox 编辑或 IME 控制行为。
- 0A 不验证消费消息的影响;该问题属于阶段 0B。
- 日志不作为用户输入内容审计或遥测系统。
## 模块边界
- 职责:
- 定义探测会话、消息记录、来源后端和关联字段。
- 定义 Key lParam、IME flags、窗口属性和 EasyX 消息的解析结果。
- 定义文本负载的隐私化摘要和在线回声关联结果。
- 定义固定内存缓冲、丢失记录、逻辑 flush 与耐久 flush 边界。
- 定义 JSON Lines 输出格式、文件生命周期和 schema 版本。
- 不负责什么:
- 不决定生产输入队列的数据结构。
- 不决定最终消费白名单。
- 不在 WndProc 中执行磁盘 I/O、JSON 序列化或阻塞等待。
- 不为观察 EasyX 后端而额外调用 `peekmessage`。
- 不使用日志内容还原用户输入原文。
- 外部依赖:
- 现有 Window WndProc 子类化链。
- 现有 EasyX `peekmessage` / `ExMessage` 消费位置。
- IMM32 只读查询。
- Windows 高精度计时和安全随机数能力。
- 对外能力 / API:
- 本文只定义内部诊断格式,不新增框架公共 API。
- 探测器必须由测试/诊断构建开关显式启用,正式默认关闭。
- 关键数据 / 状态:
- `probeSessionId`、`schemaVersion`、全会话 `sequence`。
- `hwndInstanceId`、`wndProcCallId`、`parentSequence`、`callDepth`。
- 固定容量记录缓冲、flush 请求原因和丢失范围。
- 会话级随机摘要密钥,仅驻留内存,不写入日志。
## 设计说明
### 1. 语义透明原则
阶段 0A 的“被动”定义为业务语义透明,而不是宣称完全没有时间成本。
启用 0A 后必须保持:
1. 传给旧窗口过程的 `hwnd/message/wParam/lParam` 原样不变。
2. 每条原生消息调用旧窗口过程的次数不变。
3. WndProc 对调用方返回的值不变。
4. 原有 EasyX 消息读取次数、过滤条件和顺序不变。
5. 不额外调用 `TranslateMessage`、`DispatchMessage`、`PeekMessage` 或 EasyX `peekmessage`。
6. 不调用会改变 IME 状态的 API,包括 `ImmNotifyIME`、`ImmSetCompositionWindow` 和 `ImmSetCandidateWindow`。
7. 只允许执行读取型窗口/IME 查询和内存记录。
8. 日志失败不能改变窗口、输入或控件行为。
探测记录不可避免会产生少量 CPU、内存和时间开销,因此所有结论都要标注“探测构建观察结果”,必要时与关闭探测的基线行为对照。
### 2. 观察后端与观察点
`sourceBackend` 表示记录来自哪个观察后端,不等同于系统消息的真实起源。
| `sourceBackend` | 观察点 | 允许动作 | 禁止动作 |
|---|---|---|---|
| `Win32WndProc` | 现有 WndProc 入口、旧过程返回后 | 解析参数、配对进入/离开、记录返回值 | 修改参数、吞消息、改变返回值 |
| `EasyXExKey` | 现有主循环已经取出 `EX_KEY` 的位置 | 记录现有 `ExMessage` | 为探测新增 `peekmessage` |
| `Imm32Query` | 处理现有 IME 消息时的只读查询 | 查询长度、状态、组合/结果摘要 | 完成、取消、转换或移动 IME UI |
| `HostEventLoop` | 一次宿主消息/输入批次返回后的安全点 | flush、记录批次边界 | 在仍持有控件借用指针时写盘 |
如果同一输入同时被 WndProc 和 EasyX 报告,它们是两条独立观察记录。只有存在可靠证据时才通过关联字段声明二者匹配,不能仅因消息名称相同就认定是同一事件。
### 3. 会话与序列
#### 3.1 会话
每次启用探测并打开日志文件形成一个会话:
```text
probeSessionId = 随机 UUID
schemaVersion = 1
startedAtUtc = ISO-8601 UTC 时间
processId
uiThreadId
qpcFrequency
```
会话开始时写入一条 `session_start`,正常关闭时写入 `session_end`。
#### 3.2 `sequence`
- `sequence` 是会话内跨所有后端严格递增的 `uint64_t`。
- 0 保留为无记录/未关联值,第一条记录从 1 开始。
- 嵌套 WndProc 调用仍按实际观察顺序分配 sequence。
- sequence 只描述探测器观察顺序,不等同于 Windows 消息队列序号。
- 溢出视为会话不可继续,停止探测但不能改变业务行为。
#### 3.3 调用配对
每次 WndProc 进入分配 `wndProcCallId`:
```text
WndProc enter:
recordType = wndproc_enter
wndProcCallId = 新 ID
callDepth = 当前重入深度
旧窗口过程返回:
recordType = wndproc_leave
wndProcCallId = 对应 enter 的 ID
forwardResult = 原样结果
```
如果旧窗口过程同步触发嵌套消息,内层记录会自然出现在外层 enter/leave 之间。
### 4. 通用记录字段
以下字段构成 schema v1 的公共部分。
| 字段 | 类型 | 必需 | 说明 |
|---|---|---:|---|
| `schemaVersion` | integer | 是 | 初始为 1 |
| `recordType` | string | 是 | 记录类型 |
| `sessionId` | string | 是 | 探测会话 UUID |
| `sequence` | uint64 | 是 | 全会话严格递增序号 |
| `sourceBackend` | string | 是 | 观察后端 |
| `observationPoint` | string | 是 | 具体入口/离开/批次边界 |
| `qpcTicks` | uint64 | 是 | `QueryPerformanceCounter` 原始值 |
| `threadId` | uint32 | 是 | 当前线程 ID |
| `processId` | uint32 | 是 | 当前进程 ID |
| `hwnd` | string/null | 视情况 | 十六进制句柄,仅用于本次会话诊断 |
| `hwndInstanceId` | uint64/null | 视情况 | 探测器为一次 HWND 生命周期分配的 ID |
| `wndProcCallId` | uint64/null | 否 | WndProc enter/leave 配对 |
| `callDepth` | uint32 | 否 | WndProc 重入深度 |
| `parentSequence` | uint64/null | 否 | IMM 查询等子记录关联的父消息 |
| `dispatchDepth` | uint32/null | 否 | 当前框架分发深度,未知则 null |
| `modalDepth` | uint32/null | 否 | 当前模态深度,未知则 null |
| `imeState` | string | 否 | `unknown/idle/composing` |
| `compositionId` | uint64 | 是 | 0 表示无活动 IME 组合 |
`hwnd` 可能被系统重用,因此分析生命周期时必须使用 `hwndInstanceId`,不能只比较句柄值。
### 5. 原生消息字段
适用于 `Win32WndProc` 记录。
| 字段 | 类型 | 说明 |
|---|---|---|
| `messageId` | uint32 | 原始消息编号 |
| `messageName` | string | 已知消息符号名,未知时使用 `UNKNOWN_0xNNNN` |
| `messageCategory` | string | `key/text/ime/focus/window/mouse/other` |
| `wParamPolicy` | string | `raw/parsed/redacted/not_applicable` |
| `wParamHex` | string/null | 仅允许非文本、非敏感消息记录 |
| `lParamPolicy` | string | `raw_and_parsed/parsed_only/redacted/not_applicable` |
| `lParamHex` | string/null | 只允许无指针、无文本含义的消息记录 |
| `forwarded` | boolean | 是否按原链调用旧窗口过程 |
| `forwardResultHex` | string/null | leave 记录中的旧过程返回值 |
| `messageTime` | uint32/null | 只有来源明确时记录 |
| `messageTimeSource` | string | `msg/easyx/stale_or_unknown/not_available` |
约束:
- `WM_CHAR`、`WM_UNICHAR`、`WM_IME_CHAR` 等文本消息不得记录原始 `wParamHex`。
- lParam 为指针的消息不得记录指针值或盲目解引用,只记录允许的解析结果和是否为空。
- WndProc 本身没有 `MSG.time`;不得把 `GetMessageTime()` 的潜在陈旧值伪装成当前消息时间。
- 对 `SendMessage` 产生的同步消息,`messageTime` 通常应为 null/unknown。
### 6. Key lParam 解析
对 `WM_KEYDOWN/UP`、`WM_SYSKEYDOWN/UP` 记录:
| 字段 | 来源 |
|---|---|
| `virtualKey` | `wParam`,作为键值而非文字记录 |
| `action` | 消息类型决定 `down/up` |
| `repeatCount` | lParam bits 0–15 |
| `scanCode` | bits 16–23 |
| `extended` | bit 24 |
| `altContext` | bit 29 |
| `wasDown` | bit 30 |
| `transition` | bit 31 |
| `systemKey` | 是否为 `WM_SYSKEY*` |
| `imeComposingAtObservation` | 观察瞬间的探测状态 |
修饰键状态可额外记录:
```text
leftShift/rightShift
leftCtrl/rightCtrl
leftAlt/rightAlt
capsLock/numLock/scrollLock
```
必须注明这些值来自观察时的 `GetKeyState` 快照,不保证是消息生成瞬间的硬件状态。
### 7. IME 字段
#### 7.1 `WM_IME_COMPOSITION`
解析并记录布尔 flags:
```text
hasGcsCompStr
hasGcsCompAttr
hasGcsCompClause
hasGcsCursorPos
hasGcsResultStr
hasGcsResultClause
hasCsInsertChar
hasCsNoMoveCaret
hasAnyGcsFlag
```
允许记录:
```text
compositionUtf16Length
compositionScalarCount
compositionDigest
resultUtf16Length
resultScalarCount
resultDigest
cursorPosition
immQueryStatus
```
禁止记录组合串、读取串、结果串和候选文字原文。
#### 7.2 `WM_IME_STARTCOMPOSITION`
- 记录进入前后的探测 IME 状态。
- 记录新分配的探测 `compositionId`。
- 该 ID 只用于关联日志,不代表阶段 1 最终实现已经落地。
#### 7.3 缺失 START 的容错
Idle 状态直接观察到 `WM_IME_COMPOSITION` 时:
- 惰性分配新的非零 `compositionId`。
- 写入 `compositionIdAllocation = lazy_missing_start`。
- 禁止沿用已 Commit/Cancel/End 的旧 ID。
#### 7.4 `WM_IME_ENDCOMPOSITION`
- 记录本次组合是否观察到 result/cancel。
- 设置 `flushRequested=true`,原因为 `ime_end`。
- 不在 WndProc 内执行实际写盘。
#### 7.5 `WM_IME_SETCONTEXT`
- 记录 `activate` 和 `ISC_SHOWUI*` flags。
- 记录转发前后 flags 一致。
- 记录旧窗口过程返回值。
- 0A 不允许修改 flags。
### 8. 窗口和字符集字段
每个 `hwndInstanceId` 首次出现时记录一次窗口快照:
```text
isWindowUnicode
threadId
processId
windowClassDigest
dpiForWindow
dpiAwarenessContext
```
窗口类名默认也使用会话摘要,不记录业务窗口标题。
`WM_INPUTLANGCHANGEREQUEST` 记录:
```text
requestFlags
requestedInputLanguageHandle
requestedAnsiCodePage
inputLanguageSysCharset
inputLanguageForward
inputLanguageBackward
```
- 该消息表示输入布局切换请求,不等于切换已经完成。
- 0A 只观察并原样转发,不因请求更新活动代码页,也不清理当前回声状态。
- `requestFlags` 与 `requestedInputLanguageHandle` 分别是通用 `wParamHex`、`lParamHex` 的具名副本,便于分析器在不反推参数语义的情况下直接使用。
- `requestedAnsiCodePage` 只描述 `lParam` 中目标 HKL 的只读解析结果。
`WM_INPUTLANGCHANGE` 记录:
```text
inputLanguageHandle
charset
oldAnsiCodePage
newAnsiCodePage
pendingLeadPresentBefore
pendingLeadCleared
```
0A 尚未接管 ANSI 解码时,`pendingLead*` 可以是 `not_applicable`;字段保留给阶段 1 共用 schema。
### 9. EasyX 后端字段
只在现有业务循环已经成功取出 EasyX 消息时记录,不允许为了探测额外读取队列。
| 字段 | 说明 |
|---|---|
| `easyxMessageType` | EasyX 消息类别 |
| `easyxVkCode` | 键盘虚拟键,不记录字符原文 |
| `easyxDown` | 按下/释放状态 |
| `easyxCtrl/Shift/Alt` | EasyX 提供的修饰状态 |
| `easyxMessageTime` | EasyX 提供时记录,否则 null |
| `existingDispatchDecision` | 当前代码原本如何处理该消息 |
`existingDispatchDecision` 只描述观察到的旧行为,0A 不能把该消息改为“丢弃”或“进入新业务队列”。
### 10. 文本隐私与关联
#### 10.1 默认禁止记录的内容
- `WM_CHAR/WM_UNICHAR/WM_IME_CHAR` 原始字符值。
- `GCS_COMPSTR/GCS_RESULTSTR` 原文。
- 拼音串、候选词、剪贴板和 TextBox 内容。
- 窗口标题或可能包含用户数据的控件文本。
#### 10.2 允许的文本元数据
```text
textSource
utf16Length
scalarCount
isValidUtf16
sessionDigest
correlationStatus
correlationTargetSequence
matchedUnitCount
```
#### 10.3 会话摘要
- 会话开始时生成随机密钥。
- 使用带密钥摘要,例如 HMAC-SHA-256。
- 日志只保存截断后的 128-bit 十六进制摘要。
- 密钥只驻留内存,不写日志、不跨会话复用。
- 摘要只用于同一会话内比较相等性,不能跨会话追踪用户输入。
#### 10.4 在线回声关联
探测器可以在内存中短暂保留本次 `GCS_RESULTSTR`,在线比较后续 `WM_IME_CHAR/WM_CHAR` 序列。日志只输出:
```text
matched
partial
mismatched
expired
```
及匹配数量、目标 sequence 和摘要,不输出原文。
不默认记录“前 2–3 个码点”,因为短前缀仍可能泄露姓名、拼音或密码信息。
如果摘要和在线关联不足以定位问题,只允许在显式测试开关下启用明文模式,并满足:
1. UI 明确显示正在记录测试文本。
2. 只输入预先约定的非敏感测试串。
3. 明文模式不能进入正式构建默认配置。
4. 日志文件名明确包含 `PLAINTEXT-TEST`。
### 11. 来源关联
WndProc 和 EasyX 记录的匹配只能是显式结论:
```text
correlationStatus:
none
candidate
matched
ambiguous
rejected
correlationConfidence:
exact
high
low
```
匹配可参考:
- HWND 实例。
- 消息类别和虚拟键。
- Down/Up 状态。
- scan code。
- 消息时间(仅来源可靠时)。
- sequence 邻近关系。
如果同时存在多个候选,必须标为 `ambiguous`,不能强行一一配对。
### 12. 内存缓冲
#### 12.1 捕获结构
- WndProc 内只写紧凑的内部 `ProbeRecord`,不构造 JSON 字符串。
- 缓冲预分配,避免在消息热路径频繁扩容。
- 文本只计算长度、有效性、摘要和在线关联结果。
- JSON 序列化和文件编码在安全点执行。
#### 12.2 默认容量
schema v1 建议默认:
```text
softLimit = 3072 records
hardLimit = 4096 records
```
容量必须可由测试配置覆盖,但不能在正式路径无限增长。
达到 softLimit:
- 设置 `flushRequested=true`。
- 原因为 `soft_limit`。
- 继续记录到 hardLimit。
达到 hardLimit 且仍未到安全点:
- 保留最近记录,覆盖最旧未写记录。
- 维护 `lostFirstSequence/lostLastSequence/lostCount`。
- 下一次实际 flush 时先输出一条 `records_lost` 元记录。
- 禁止静默覆盖。
选择保留最近记录是为了在异常退出前尽量留下最接近故障的消息,同时通过 loss 元记录明确标注因果链已不完整。
### 13. Flush 模型
#### 13.1 两类 flush
区分:
1. `buffer_flush`:把内存记录序列化并写入文件,由操作系统缓存。
2. `durable_flush`:在 buffer_flush 后调用耐久化能力,降低进程崩溃后的日志丢失。
#### 13.2 WndProc 内只请求
以下观察点只设置请求,不直接 I/O:
| 触发点 | 请求原因 | 建议级别 |
|---|---|---|
| `WM_IME_ENDCOMPOSITION` | `ime_end` | durable |
| `WM_KILLFOCUS` / 窗口失活 | `window_deactivate` | durable |
| softLimit | `soft_limit` | buffer |
| 最大滞留时间到期 | `max_latency` | buffer |
| `WM_CLOSE/WM_DESTROY` 观察 | `window_shutdown` | durable |
#### 13.3 实际安全点
实际 flush 只能发生在:
```text
WndProc 已返回
&& 当前宿主消息/输入批次已经结束
&& 当前 mutation transaction 已提交
&& 当前没有跨 flush 保存的 Control* 借用视图
&& 当前循环是该 Window 的活动 drainer
```
现阶段尚未实现通用 drainer 时,0A 必须选择现有主循环已经完成一次消息/事件分发的位置,不能在控件回调、旧窗口过程调用或嵌套 WndProc 栈中写盘。
#### 13.4 flush 时机
- 每个宿主输入批次结束:如缓冲非空,执行 buffer_flush。
- 收到 durable 请求:在下一个安全点执行 buffer_flush + durable_flush。
- 最大滞留时间默认 500ms,到安全点后执行 buffer_flush。
- 正常关闭:完成最终 durable_flush,并写 `session_end`。
- 异常崩溃:不在异常处理器中尝试复杂 flush,允许最多丢失尚未到安全点的当前批次。
#### 13.5 写入失败
写入失败时:
- 保存错误码和失败计数到内存状态。
- 禁止重试死循环或阻塞 WndProc。
- 禁止退回记录明文的控制台输出。
- 后续安全点可以进行有上限的重试。
- 达到重试上限后禁用本次会话文件输出,但业务输入继续原样运行。
### 14. 日志文件
建议路径:
```text
TestResults/InputProbe/
```
建议文件名:
```text
input-probe-YYYYMMDD-HHMMSS-p<PID>-<sessionId>.jsonl
```
格式:
- UTF-8,无 BOM。
- JSON Lines,一行一条完整 JSON 对象。
- 换行使用 `\n`。
- schemaVersion 必须出现在每条记录中,便于截断文件恢复分析。
- 未知字段读取方忽略;语义变化必须提升 schemaVersion。
### 15. JSONL 示例
以下示例只展示格式,不代表真实运行结果。
```json
{"schemaVersion":1,"recordType":"session_start","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":1,"sourceBackend":"HostEventLoop","observationPoint":"probe_start","qpcTicks":518820000,"threadId":10420,"processId":18192,"hwnd":null,"hwndInstanceId":null,"compositionId":0,"startedAtUtc":"2026-10-01T08:00:00Z","qpcFrequency":10000000}
{"schemaVersion":1,"recordType":"wndproc_enter","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":42,"sourceBackend":"Win32WndProc","observationPoint":"enter","qpcTicks":518821240,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"wndProcCallId":19,"callDepth":1,"compositionId":3,"messageId":271,"messageName":"WM_IME_COMPOSITION","messageCategory":"ime","wParamPolicy":"redacted","wParamHex":null,"lParamPolicy":"raw_and_parsed","lParamHex":"0x00000800","hasGcsResultStr":true,"hasAnyGcsFlag":true}
{"schemaVersion":1,"recordType":"imm_query","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":43,"sourceBackend":"Imm32Query","observationPoint":"gcs_result","qpcTicks":518821260,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"parentSequence":42,"compositionId":3,"textSource":"GCS_RESULTSTR","utf16Length":2,"scalarCount":2,"isValidUtf16":true,"sessionDigest":"9e471d4d3a5732aa4fd27dba86aa4c11","correlationStatus":"none"}
{"schemaVersion":1,"recordType":"wndproc_leave","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":44,"sourceBackend":"Win32WndProc","observationPoint":"after_old_proc","qpcTicks":518821300,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"wndProcCallId":19,"callDepth":1,"compositionId":3,"messageId":271,"messageName":"WM_IME_COMPOSITION","forwarded":true,"forwardResultHex":"0x0000000000000000"}
{"schemaVersion":1,"recordType":"flush_boundary","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":45,"sourceBackend":"HostEventLoop","observationPoint":"input_batch_end","qpcTicks":518821400,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"compositionId":3,"flushReason":"input_batch_end","flushLevel":"buffer","bufferedRecordCount":44,"lostCount":0}
```
示例中的摘要不可反推出原始文字,会话密钥也不得写入文件。
### 16. 记录类型清单
schema v1 至少定义:
```text
session_start
session_end
window_attach
window_detach
wndproc_enter
wndproc_leave
easyx_key
imm_query
host_batch_begin
host_batch_end
flush_boundary
records_lost
probe_error
```
不得用一个含义模糊的 `event` 类型承载所有数据。
### 17. 配置边界
0A 配置只允许控制:
- 是否启用探测。
- 输出目录。
- soft/hard limit。
- 最大滞留时间。
- 是否启用显式明文测试模式。
- 是否记录非输入类窗口消息。
0A 配置不得包含“消费消息”选项。任何消费规则都属于 0B,并使用独立配置结构和明显的实验标识。
### 18. 0A 需要回答的问题
1. EasyX 主循环中的键盘消息是否经过当前 WndProc。
2. WndProc 和 EasyX `EX_KEY` 是否重复报告同一输入。
3. WndProc、EasyX、IMM 查询和宿主批次的真实顺序。
4. `WM_KEYDOWN` 后是否出现 `WM_CHAR/WM_SYSCHAR`。
5. 窗口实际为 ANSI 还是 Unicode。
6. `WM_UNICHAR` 是否出现、从哪个观察链出现。
7. `WM_IME_STARTCOMPOSITION` 是否总在 COMPOSITION 前出现。
8. `GCS_RESULTSTR`、`WM_IME_CHAR`、`WM_CHAR` 是否重复以及如何分段。
9. ANSI `WM_CHAR` 是否以 lead/trail 分开发送。
10. `WM_GETDLGCODE` 是否到达当前窗口过程。
11. 普通循环和 Dialog 模态循环是否经过同一链路。
12. Window 激活/失活与 IME End/Cancel 的顺序。
13. 不同输入法的消息序列差异。
14. 是否存在无法通过现有观察点可靠关联的消息。
“消费某类消息是否影响 EasyX/IME”不属于 0A 结论,由 0B 单变量实验回答。
### 19. 0B 和阶段 1 的输入契约
0B 复用:
- 同一 schema 和 sequence。
- 同一来源后端。
- 同一 Key/IME 解析结果。
- 同一缓冲、隐私和 flush 规则。
- 额外增加 `experimentId/ruleId/action`,不得改写 0A 字段语义。
阶段 1 复用:
- 已验证的消息来源和顺序。
- HWND A/W 属性。
- Key lParam 解析。
- IME result/echo 边界。
- ANSI/DBCS 实际分段。
- 需要保留或丢弃 EasyX `EX_KEY` 的证据。
### 20. 第一切片落地边界(2026-10-03)
第一切片已经实现以下能力:
- 编译期开关:`/p:StellarXInputProbe=true`;默认值为 `false`。
- 运行期开关:`STELLARX_INPUT_PROBE=1`;只有编译期和运行期同时开启才创建探测会话。
- 输出目录:默认 `TestResults/InputProbe/`,可用 `STELLARX_INPUT_PROBE_DIR` 覆盖。
- 不新增公共 API;关闭编译开关时内部调用全部内联为空操作。
- 在现有 WndProc、普通事件循环和 Dialog 模态循环的原路径上被动观察,不额外读取 EasyX 队列,不改变消息参数、转发路径和返回值。
- 记录 WndProc enter/leave、EasyX `EX_KEY`、宿主批次边界、Key lParam 解析、IME flags/组合状态、窗口 A/W 属性、DPI 和线程快照。
- 使用固定容量环形缓冲;WndProc 只尝试获取 SRW 独占锁,竞争时不等待并登记丢失;宿主安全点在锁内复制快照,JSON 序列化和文件 I/O 在锁外执行。
- 每个普通或模态宿主批次结束后尝试安全点 flush;`WM_IME_ENDCOMPOSITION`、失焦、失活和关闭消息只请求耐久 flush,不在 WndProc 内写盘。
- 文本类消息不记录原始 `wParam`,当前第一切片也不读取或保存组合串、结果串原文。
第一切片当时尚未实现的后续项,当前进度如下:
- `ImmGetCompositionStringW` 的长度、UTF-16 有效性、scalar count 和会话摘要记录。
- `GCS_RESULTSTR` 与后续 `WM_IME_CHAR/WM_CHAR` 的在线回声关联。
- 真实中文 IME 覆盖:微软拼音和搜狗均已完成人工执行。
- 0B 的逐消息消费实验仍未开始;当前实现没有任何消费配置。
### 21. 第二切片落地边界(2026-10-03)
第二切片新增:
- `WM_IME_COMPOSITION + GCS_RESULTSTR` 时调用 `ImmGetCompositionStringW`,记录字节长度、UTF-16 code unit 数、Unicode scalar count 和 UTF-16 合法性。
- 会话启动时生成只驻留内存的随机 HMAC 密钥;结果串只写入截断至 128 bit 的 HMAC-SHA-256 摘要,不记录原文,也不记录密钥。
- 对超过 64 KiB、奇数字节长度、无 IME context、空结果和读取失败分别记录明确状态。
- 为每次结果建立短期内存回声状态;Unicode 窗口比较 UTF-16 units,ANSI 窗口按活动输入布局的 ANSI 代码页生成预期字节。
- `WM_IME_CHAR` 与 `WM_CHAR` 使用独立 cursor,分别输出 `candidate/matched/partial/mismatched/expired/unrepresentable`,不预设两条通道必然都出现。
- ANSI `WM_IME_CHAR` 按高字节 lead、低字节 trail 展开;ANSI `WM_CHAR` 按单字节连续比较。
- 新结果、新组合、失焦、窗口失活、输入语言切换和关闭会结束当前回声窗口并写隐私安全的汇总记录;IME End 在旧窗口过程返回后的 leave 阶段收口,避免漏掉 END 处理期间嵌套产生的字符回声。
- `WM_INPUTLANGCHANGE` 更新活动布局代码页;实测中文布局为 CP936。
独立 `EasyX.ThreadProbe` 不链接 StellarX,已经确认:EasyX HWND/WndProc 属于内部窗口线程,`peekmessage` 属于 main/宿主线程,双线程是 EasyX 固有模型而非 UiFixture 假象。该结论已写回 v0.5 设计稿。
第二切片仍不负责:
- 生产 `NativeInputQueue`、焦点系统或 TextBox 输入。
- 消费、拦截或改写任何 IME/字符消息。
- 用自动化伪造真实 IME 结果;真正的提交、候选、取消和输入法切换必须由人工运行微软拼音与搜狗。
### 22. 微软拼音实测与关闭链修复(2026-10-04)
微软拼音真实人工会话已经完成,结果目录为 `TestResults/InputProbe/manual-20261004-194521-908-MicrosoftPinyin`:
- JSONL 共 4609 条,全部可解析,`sequence` 严格递增,文字原始 `wParam` 泄露为 0。
- WndProc 线程 ID 为 27744,宿主线程 ID 为 33148;线程 ID 仅代表本次会话。
- 观察到 6 次 `WM_IME_STARTCOMPOSITION`、35 次 `WM_IME_COMPOSITION`、6 次 `WM_IME_ENDCOMPOSITION`。
- 3 次 `GCS_RESULTSTR` 查询成功;每次为 2 个 UTF-16 code units、2 个 Unicode scalar,当前 CP936 回声预期为 4 个字节且未使用默认替代字符。
- 3 次 `WM_IME_CHAR` 回声均为 4/4 `matched`;对应 3 次 `WM_CHAR` 通道均为 0/4 `expired`。当前微软拼音 + ANSI EasyX 窗口组合下,应优先防止 `GCS_RESULTSTR` 与 `WM_IME_CHAR` 重复提交,不能假定还会出现 `WM_CHAR` 回声。
- `WM_INPUTLANGCHANGE` 观察数为 0,因此“切换中英文一次”没有形成该消息;该会话不能据此验证输入布局切换清理路径。
- 共有 5 条 `records_lost`,原因均为 `wndproc_lock_contention`;所有丢失均被显式记账,不能把本次日志称为零丢失。
本次真实关闭还暴露 `BUG-20261004-0001`:WndProc 已观察到 `WM_CLOSE -> WM_DESTROY -> WM_NCDESTROY`,但宿主 `peekmessage` 没有取得 `WM_CLOSE`,造成窗口消失后进程继续运行。修复后由 WndProc 在完成 `WM_NCDESTROY` 转发与探测 leave 后,以 release 发布原生窗口死亡状态;普通和模态宿主循环以 acquire 读取并在安全点退出。发布点不得提前,否则会造成仍在执行的 WndProc 访问已析构 `Window`。
自动关闭 smoke 在 `Start-Process + SC_CLOSE + EX_SHOWCONSOLE` 组合下还确认:EasyX 可能以 `0xC000013A` 直接结束,最后一批 WndProc 内存记录来不及由宿主安全点刷盘。探测脚本不得无条件忽略该退出码;只有日志可解析、sequence 严格递增、无文字原值泄露且已有 `closeObserved=true` 时才接受。0A 继续坚持 WndProc 不做磁盘 I/O,不为了补齐 `session_end` 改变被测时序。
### 23. 搜狗实测与输入布局请求补观测(2026-10-04)
搜狗真实提交会话目录为 `TestResults/InputProbe/manual-20261004-204556-353-Sogou`:
- JSONL 共 2120 条,全部可解析,`sequence` 严格递增,文字原始 `wParam` 泄露为 0。
- WndProc 与宿主线程分别为 12600 和 668;线程 ID 仅代表本次会话。
- 观察到 3 次 START、3 次 COMPOSITION、3 次 END,3 次 `GCS_RESULTSTR` 均成功。
- 三次结果分别为 7、2、6 个有效 Unicode scalar;对应 CP936 预期回声为 14、4、12 字节,均未使用默认替代字符。
- `WM_IME_CHAR` 三次分别为 14/14、4/4、12/12 `matched`;`WM_CHAR` 三次均为 0/N `expired`。
- 共有 3 条 `records_lost`,原因均为 `wndproc_lock_contention`;关闭链完整且写出 `window_detach/session_end`。
微软拼音与搜狗在当前 ANSI EasyX + CP936 环境下给出相同提交拓扑:`GCS_RESULTSTR` 是正式提交,随后出现完整匹配的 `WM_IME_CHAR` 回声,没有形成可匹配的 `WM_CHAR` 回声。阶段 1 必须以结果串只提交一次,并把匹配回声标记为观察/转发而不重复入队;不得按字符内容做全局去重。
短输入布局切换会话目录为 `TestResults/InputProbe/manual-20261004-223833-893-Sogou`:
- JSONL 共 1048 条,全部可解析,`sequence` 严格递增,无文字原值泄露;4 条丢失均为 `wndproc_lock_contention`。
- 日志观察到 `VK_LWIN`、焦点往返、Shift 和 A,但 `WM_INPUTLANGCHANGE` 为 0,关闭与 `session_end` 完整。
- 该会话运行时的探测器尚未把 `WM_INPUTLANGCHANGEREQUEST` 列入默认观察集合,因此不能用“日志里没有 request”证明系统没有发送 request。
- 这暴露了 0A schema/观察面的组织性缺口,而不是用户操作失败。现已把 request 纳入 message name、window category、原始参数和方向 flags 观察,并在分析摘要中分别统计 request/change。
- IME 内部中英文模式、TSF 输入配置文件和 HKL/代码页是不同层次;只有实际 `WM_INPUTLANGCHANGE` 才先清 `pendingLead`、再更新活动代码页,不能把 Win+Space、Shift 或配置文件切换本身当作代码页已经变化。
补观测后的专用 Win+Space 短测目录为 `TestResults/InputProbe/manual-20261004-231414-758-Sogou`:
- JSONL 共 1243 条,全部可解析,`sequence` 严格递增,无文字原值泄露;2 条丢失均为已显式记账的 `wndproc_lock_contention`,关闭链与 `session_end` 完整。
- 新版观察面下 `WM_INPUTLANGCHANGEREQUEST=0`、`WM_INPUTLANGCHANGE=0`,同时也未出现 `WM_IME_SELECT`。
- 日志观察到 `VK_LWIN`、窗口焦点往返;输入 A 时按键进入 `VK_PROCESSKEY`,随后产生 1 次 IME composition/result 和匹配 `WM_IME_CHAR` 回声,说明切换后的输入配置文件仍属于中文 IME 路径。
- `Get-WinUserLanguageList` 只返回 `zh-Hans-CN`,但含多个输入配置文件。因此这次 Win+Space 只能验证同一中文 HKL/CP936 内的 TSF 配置文件切换,不具备跨语言 HKL 条件。
- 结论:同 HKL 的 TSF 配置文件切换不保证产生 request/change;跨语言 HKL 的消息行为仍未实测。验证后未新增英文键盘布局,因为那会改变用户系统配置。
## 实现与影响
- 关键实现点:
- 已增加内部 `InputProbe` 第一、二切片,使用固定内存记录和安全点 JSONL 序列化。
- 实测 EasyX 窗口过程与框架宿主循环位于不同线程,因此共享状态按双线程模型实现:WndProc 使用非阻塞 `TryAcquireSRWLockExclusive`,宿主安全点复制快照后在锁外写盘。
- WndProc enter/leave 覆盖现有提前返回分支;调用旧窗口过程后原样返回其结果。
- 0A 和 0B 必须共用 schema,消费实验不得另造不可比较的日志格式。
- 涉及文件 / 类 / 函数:
- `src/StellarX/internal/SxInputProbeInternal.h`:编译开关下的内部接口及默认关闭空实现。
- `src/StellarX/SxInputProbe.cpp`:会话、记录、缓冲、序列化、flush 与窗口/键盘/IME 元数据采集。
- `src/StellarX/Window.cpp`:WndProc、普通事件循环和安全点接入。
- `src/StellarX/Dialog.cpp`:模态循环批次与安全点接入。
- `StellarX.Core.vcxproj`:源文件登记及 `StellarXInputProbe` 编译期开关。
- `tests/EasyXThreadProbe.cpp`、`tests/EasyX.ThreadProbe.vcxproj`:不链接 StellarX 的线程归属独立复现。
- `scripts/run-easyx-thread-probe.ps1`:独立复现运行入口。
- `scripts/run-input-probe-manual.ps1`、`scripts/analyze-input-probe.ps1`:微软拼音/搜狗人工测试与隐私安全汇总入口。
- 兼容性影响:
- 探测关闭时不产生文件、线程、定时器或公共 API 变化;实测即使设置运行环境变量也不会创建日志。
- 探测开启时业务消息参数、转发次数和返回值保持不变。
- 性能影响:
- 开启探测会增加 QPC、IMM 查询、UTF-16 扫描、HMAC、字段解析和内存写入开销;全部仅存在于显式探测构建与运行开关下。
- 通过预分配和安全点批量序列化避免在 WndProc 执行文件 I/O。
- 风险点:
- 探测本身仍会产生微小时序扰动,不能把日志视为零扰动的硬件级跟踪。
- 缓冲达到 hardLimit 时因果链可能不完整,必须依赖 `records_lost` 判断。
- 错误记录原始 `wParam`、组合串或明文测试日志可能泄露用户输入。
- WndProc 消息时间来源不可靠时,错误填充 `messageTime` 会制造虚假顺序证据。
## 测试与验证
- 测试范围:
- 自动验证默认关闭与探测开启两种 Debug x64 构建、现有单元测试和 JSONL 结构。
- 固定消息 smoke 验证 WndProc/EasyX 双观察链、线程模型、enter/leave 配对、隐私字段和丢失记录。
- 合成 IME smoke 只验证 `imm_query` schema、空结果错误路径和隐私边界,不冒充真实 IME 提交验证。
- 已执行微软拼音与搜狗真实人工测试;结论只覆盖当前 Windows/EasyX/CP936 环境,不能外推到所有 IME。
- 验证步骤:
1. 实现前只读定位 WndProc、EasyX EX_KEY、普通循环和模态循环的现有接入点。
2. 实现 0A 后比较探测关闭/开启时旧窗口过程参数、调用次数、返回值和现有业务行为。
3. 使用预定非敏感测试串验证 WndProc/EasyX/IME 记录、在线回声关联、缓冲溢出和各类 flush 边界。
- 验证结果:
- 2026-10-03:默认关闭的 `StellarX.Core` Debug x64 编译通过;探测开启的 `StellarX.Core` 与 `StellarX.UiFixture` Debug x64 编译、链接通过。
- 2026-10-03:`scripts/test.ps1 -Suite Unit -Configuration Debug -Platform x64` 通过,15/15。
- 2026-10-03 固定消息 smoke:91 行 JSONL 全部可解析,`sequence` 严格递增;固定 A 键的 Down/Up 在 WndProc 与 EasyX 两条链均可观察,所有键消息 enter/leave 配对,无文本原始 `wParam` 泄露,无 `records_lost`。
- 同次 smoke 实测 WndProc 线程 ID 为 11940、宿主循环线程 ID 为 15220,证明二者不是同一线程;线程 ID 仅代表本次会话,不是稳定常量。
- 同次 smoke 实测 `isWindowUnicode=false`,即当前 EasyX 窗口为 ANSI;`dpiForWindow=96`。
- 测试窗口短暂取得前台期间还观察到环境产生的其他键盘消息,因此固定消息结论按 virtual key、scan code 和 lParam flags 精确匹配,不以本次键消息总数作为稳定断言。
- `WM_CLOSE` enter 之后旧窗口过程没有返回到当前 thunk,进程正常结束,因而该消息没有 leave,析构路径也没有写出 `window_detach/session_end`;不得伪造配对或会话结束记录。
- 默认关闭构建在设置 `STELLARX_INPUT_PROBE=1` 后运行,指定输出目录文件数仍为 0。
- 独立 EasyX 复现实测:main/`peekmessage` 线程 18860;HWND 属主、裸 WndProc 和固定键 WndProc 线程 29472;固定键在 WndProc/EasyX 两面各观察 2 条;窗口为 ANSI。线程 ID 只代表该次会话。
- 第二切片合成 IME smoke:24 行 JSONL 全部可解析、sequence 严格递增、产生 1 条 `imm_query`,无真实结果时状态为 `empty`,无原始文本泄露且无 `records_lost`。
- 2026-10-04 微软拼音真实会话:4609 行 JSONL 全部可解析、sequence 严格递增;START/END 为 6/6,3 次结果均为 2 个 scalar,3 次 `WM_IME_CHAR` 均 4/4 matched,3 次 `WM_CHAR` 均 0/4 expired;无文字原值泄露,存在 5 条显式 `wndproc_lock_contention` 丢失。
- 同次真实关闭发现并修复 `BUG-20261004-0001`;普通主循环和真实模态循环的原生关闭回归均在 5 秒内自行退出。
- 探测开启的最终自动关闭 smoke 产生 8 份可解析 JSONL,全部 sequence 严格递增、`closeObserved=true`、无文字原值泄露、无 `records_lost`。较早一轮中两条 `Start-Process + SC_CLOSE` 用例曾以 `0xC000013A` 结束且未刷出最后的 `WM_CLOSE`,说明该尾批落盘存在宿主时序差异;最终成功轮次不抹去该已知限制。
- 2026-10-04 搜狗真实提交会话:2120 行 JSONL;3 次结果分别为 7、2、6 个 scalar,`WM_IME_CHAR` 分别 14/14、4/4、12/12 matched,`WM_CHAR` 均 expired;无文字原值泄露,3 条丢失均为 `wndproc_lock_contention`,正常写出 `session_end`。
- 2026-10-04 输入布局短会话:1048 行 JSONL,观察到 LWin/Shift/A 与焦点往返,未观察到 `WM_INPUTLANGCHANGE`;旧探测构建未采集 `WM_INPUTLANGCHANGEREQUEST`,因此该会话对 request 的存在性不作结论。
- 2026-10-04 request 补观测最终实现后:探测开启的 `StellarX.Core` 与 `StellarX.UiFixture` Debug x64 重新编译、链接通过;UI 回归 1 轮通过,产生 8 份 JSONL,全部可解析、`sequence` 严格递增且无文字原值泄露;其中 1 份有 1 条已显式记账的 `wndproc_lock_contention`,其余 7 份无丢失。自动场景没有执行输入布局切换,因此 request/change 均为 0,不冒充人工切换证据。
- 新版 `scripts/analyze-input-probe.ps1` 已重新读取微软拼音与两份搜狗旧日志;新增 request 统计不破坏旧 schema,但旧日志中的 0 仅表示当时没有采集该消息。
- 同一最终源码重新执行 Debug x64 单元测试,15/15 通过;编译输出仅包含 EasyX 匿名联合和仓库原有成员遮蔽/未使用项警告。
- 2026-10-04 专用 Win+Space 短测:1243 条 JSONL 可解析且 sequence 严格递增,request/change 均为 0;当前系统仅有 `zh-Hans-CN`,该结果只证明同 HKL 的 TSF 输入配置文件切换不产生这两类消息。
- 2026-10-05 提交前复验:独立线程探针再次得到 `separateWindowThread=true`、`wndProcMatchesOwner=true`、`keyCrossedBothBackends=true`、`windowUnicode=false`;探测开启 UI 回归 1 轮产生 8 份 JSONL,全部可解析、sequence 严格递增、无文字原值泄露、无 `records_lost`,自动原生关闭均保留 `closeObserved=true` 且不伪造 `session_end`;探测关闭构建的 VSTest 15/15 通过。
- 已知限制 / 遗留问题:
- 微软拼音和搜狗的结果摘要与回声顺序已经确认;其他 IME、其他 EasyX/Windows 版本仍可能不同。
- `WM_INPUTLANGCHANGEREQUEST` 补观测是在两次搜狗会话之后加入,既有日志无法追溯恢复当时被过滤的 request;补观测后的短测已完成,但当前机器没有第二种语言/HKL,仍无法覆盖真实 request/change 分支。
- 后续短测使用 `scripts/run-input-probe-manual.ps1 -ImeName Sogou -InputLanguageOnly`;该开关只缩短人工提示,不改变构建、探测、日志或摘要路径。
- 默认缓冲容量和最大滞留时间仍需在真实 IME 日志量下校准。
- WndProc 与 EasyX 记录能否精确匹配取决于实际可见字段,允许得出 `ambiguous` 结论。
- 旧窗口过程处理 `WM_CLOSE` 时可能不返回;异常或直接终止场景只保证保留此前安全点已写出的记录,不保证存在 `session_end`。
- 宽字符 GDI 和 DPI 探测由 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md) 承接,不硬塞进键盘消息 schema。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:[`BUG-20261004-0001`](../BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md) / [`Fix-BUG-20261004-0001`](../Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md)
- Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测期间发现并修复的原生窗口关闭缺陷)
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [TranslateMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-translatemessage)
- [WM_UNICHAR](https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-unichar)
- [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition)
- [WM_IME_CHAR](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-char)
- [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw)
@@ -0,0 +1,377 @@
# 新增功能模块 / 模块重构
> 适用场景:新增模块、重大模块重构、核心架构能力演进。
> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
## 基本信息
- 模块 ID:Module-20261001-0003
- 模块名称:阶段 0C 宽字符绘制与 IME 坐标探测规格
- 状态:设计中(评审意见已并入,探测器尚未实现)
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / GDI / IMM32 / DPI
- 版本 / 分支:`master`,前置阶段 0A 基线 `c218c25`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + GDI + IMM32
- 负责人:Codex 协作设计
- 阶段定位:阶段 0 探测的能力分类之一(0A 消息链路 / 0B 消费实验 / 0C 绘制与坐标)。编号不代表严格执行顺序,建议顺序为 0A → 0C → 0B。
## 背景与目标
- 背景:
- 设计基线已锁定“绘制优先使用宽字符 GDI,不默认降级 CP936”和“系统候选框定位到 TextBox caret”两条决策。
- 两条决策都依赖尚未验证的事实:(1) `TextOutW/DrawTextW/GetTextExtentPoint32W` 能否真正画入 EasyX 当前工作图像;(2) EasyX 绘制坐标空间与 HWND 客户区/屏幕坐标是否一致,尤其是不同 DPI 下。
- 这两项属于“EasyX 绘制表面与 Win32 原生坐标空间的兼容边界”,无法从 0A 键盘消息 JSONL 推断,需要独立的视觉与坐标证据。
- 当前痛点:
- 若宽字符 GDI 实际画不进 EasyX 工作图像,却按“可用”推进,会在阶段 5 TextBox 自绘时集中爆发。
- 若 DPI 坐标换算错误,候选框在 125%/150%/跨屏场景整体偏移,且这种 bug 难用单元测试覆盖。
- 测量结果、像素证据和人工视觉结论若混在一起,无法定位问题出在测量、绘制还是观察环节。
- 目标:
- 定义阶段 0C 的验证方法、固定非敏感测试串、证据分层、DPI 矩阵和失败/降级判定。
- 为阶段 5 的 TextBox 自绘和阶段 6 的 IME 定位提供“宽字符绘制是否可行、坐标如何换算”的可复核证据与结论。
- 非目标:
- 不实现 TextBox 自绘、不修改任何控件或框架代码。
- 不决定是否降级 CP936,该结论由 0C 证据决定。
- 不把视觉/坐标结果并入 0A 键盘 JSONL schema。
- 不验证消费消息的影响(属 0B)。
## 模块边界
- 职责:
- 定义宽字符 GDI 的目标 HDC 与工作图像验证方法。
- 定义固定非敏感测试串集合。
- 定义绘制、测量、像素、人工视觉四层证据及其记录格式。
- 定义 DPI、客户区/屏幕坐标和 caret anchor 的换算验证。
- 定义 COMPOSITIONFORM / CANDIDATEFORM 定位证据。
- 定义自动检查与必须人工观察的分层。
- 定义阶段门槛、失败判定和降级条件。
- 不负责什么:
- 不新增框架公共 API。
- 不在 WndProc 中执行 IME UI 修改。
- 不用真实用户输入作为测试串。
- 不把结论写入生产代码默认配置。
- 外部依赖:
- EasyX `SetWorkingImage` / 工作图像 HDC。
- GDI `TextOutW` / `DrawTextW` / `GetTextExtentPoint32W`。
- `GetDpiForWindow`、进程 DPI awareness。
- `COMPOSITIONFORM` / `CANDIDATEFORM` 与 caret anchor。
- 对外能力 / API:
- 仅定义内部诊断与证据格式,不新增框架公共 API。
- 必须由测试/诊断构建开关显式启用,正式默认关闭。
- 关键数据 / 状态:
- 目标 HDC 与工作图像身份、GDI 状态保存/恢复校验。
- 每类测试串的测量结果、像素采样、人工结论。
- DPI 值、坐标换算结果、候选框定位证据。
## 设计说明
### 1. 证据分层
四类证据必须分开记录,禁止互相替代:
| 证据层 | 内容 | 来源 | 可自动化 |
|---|---|---|---|
| 绘制结果 | 实际调用了哪些 GDI 函数、目标 HDC、返回值 | 运行日志 | 是 |
| 测量结果 | `GetTextExtentPoint32W` 返回的宽高 | 运行日志 | 是 |
| 像素证据 | 工作图像指定区域的像素采样/截图 | 位图采样 | 是(采样点) |
| 人工视觉结论 | 候选框实际位置、缺字/方框、对齐是否正常 | 人工观察 | 否 |
### 2. 固定非敏感测试串
只使用预先约定的非敏感串,不使用用户输入。测试实现必须根据下表的 Unicode scalar 序列构造 UTF-16,不得把源文件中的可见文字字面量作为判定依据。
| `testStringId` | Unicode scalar 序列 | 便于人工识别的显示 | 目的 |
|---|---|---|---|
| `ascii` | `U+0053 U+0074 U+0065 U+006C U+006C U+0061 U+0072 U+0058 U+0020 U+0030 U+0043 U+0020 U+0050 U+0072 U+006F U+0062 U+0065` | `StellarX 0C Probe` | ASCII 基线 |
| `cjk_bmp` | `U+661F U+57A3 U+7ED8 U+5236 U+6D4B U+8BD5` | `星垣绘制测试` | BMP 中文与字体覆盖 |
| `supplementary` | `U+1F600` | `😀` | 增补平面字符;UTF-16 应为 `D83D DE00` |
| `combining_nfd` | `U+0065 U+0301` | `e` + combining acute | 明确验证 NFD 组合序列,不允许换成预组字符 `U+00E9` |
| `mixed` | `U+661F U+57A3 U+0020 U+0041 U+1F600 U+0042 U+0020 U+0065 U+0301` | `星垣 A😀B é` | 混合相邻布局、代理对和组合字符 |
每个用例在绘制前记录构造后的 UTF-16 code unit 序列和有效性。字体不包含某个 glyph 时,应归类为字体覆盖问题,不能据此认定宽字符 API 不可用。
### 3. 宽字符 GDI 目标 HDC 验证
验证对象:
- 默认主画面(未调用 `SetWorkingImage`)上的 `TextOutW/DrawTextW/GetTextExtentPoint32W`。
- 调用 `SetWorkingImage` 之后的目标工作图像 HDC。
单纯比较 HDC handle 不足以证明绘制表面身份。每个目标必须记录以下表面指纹:
```text
HDC handle
GetCurrentObject(hdc, OBJ_BITMAP) 返回的 HBITMAP
HBITMAP 宽高、位深和 stride(能够取得时)
GetCurrentObject(hdc, OBJ_FONT) 返回的 HFONT 与 LOGFONTW
GetMapMode / viewport origin / window origin / viewport extent / window extent
GetClipBox
GetBkMode / GetTextColor / GetBkColor / GetTextAlign
GetTextCharacterExtra
```
主画面和工作图像分别使用不同的固定哨兵底色,并按以下步骤验证:
1. `SetWorkingImage` 切换前后分别采集 HDC、selected bitmap 和 GDI 状态。
2. 对目标表面与非目标表面记录绘制前像素摘要。
3. 调用 `SaveDC`,设置受控字体、颜色、背景模式、mapping mode 和 clip。
4. 使用 W 版本 API 绘制唯一的固定测试图案和测试串。
5. 从目标表面回读像素,验证目标发生预期变化。
6. 从非目标表面回读相同区域,验证没有串画。
7. 调用 `RestoreDC`,重新采集状态并验证未污染 EasyX 后续绘制。
宽字符能力与字体覆盖分开判定:
- `TextOutW/DrawTextW` 调用成功、目标表面变化且非目标表面不变,证明 W 绘制链可达。
- 出现缺字方框、空 glyph 或黑白 emoji 时,记录字体覆盖/字体回退结论,不直接判定 W 绘制失败。
- `GetTextExtentPoint32W` 返回当前 HDC、字体和文字状态下的逻辑 advance,不是像素墨迹包围盒,也不考虑裁剪。
- 不要求 `SIZE.cx` 与像素 ink bounds 严格相等;应比较同一 HDC/字体/状态下的 caret advance、裁剪安全性和相邻文本是否重叠。
- 若 `DrawTextW` 使用特定 flags,应额外使用相同 flags 的 `DT_CALCRECT` 结果比较,不能把不同 API/flags 的结果强行等同。
### 4. DPI 与坐标空间
验证 EasyX 绘制坐标空间与 HWND 客户区坐标是否一致:
- 记录进程、线程和目标窗口的 DPI awareness context,以及 `GetDpiForWindow(hwnd)`。
- 记录 EasyX 工作表面尺寸、HWND 客户区尺寸和窗口屏幕矩形。
- 验证 EasyX 逻辑坐标是否等于 HWND 目标坐标,还是需要显式换算。
- IMM 输入链只定义为:合成控件局部坐标 → EasyX 绘制坐标 → 目标 HWND 相对坐标 → `COMPOSITIONFORM/CANDIDATEFORM`。
- 同时把目标 HWND 相对坐标换算为屏幕坐标,作为截图标注和诊断证据;屏幕坐标不是预设的 IMM 输入中间态。
规则:只有实测发现 EasyX 坐标空间与目标 HWND 坐标空间不一致时,才引入显式换算;否则直接使用,避免双重缩放。
验收预期必须服从实际 DPI awareness:
| Awareness | `GetDpiForWindow` 预期 | 跨屏预期 |
|---|---|---|
| DPI unaware | 96 | 不要求随显示器改变;记录系统虚拟化 |
| System aware | 系统 DPI | 不要求随显示器改变;记录系统虚拟化 |
| Per-monitor aware | 窗口所在显示器 DPI | 跨屏后应更新,并重新计算证据 |
如果实际 awareness 无法支持某项跨屏预期,该用例标记为 `not_available`,不能误判为坐标实现失败。
### 5. IME 定位证据
0C 发生在阶段 5 TextBox 自绘之前,因此不依赖真实 TextBox。探测 Fixture 必须提供以下合成 anchor:
```text
anchor_top_left_safe
anchor_center
anchor_bottom_right_safe
```
三个 anchor 均与屏幕边缘保留足够安全距离,用于区分框架换算错误和系统为避开屏幕边界进行的主动调整。
每个 anchor 记录:
```text
controlLocalCaretTopLeft
controlLocalCaretBottom
controlLocalLineRect
easyxSurfacePoint/Rect
targetHwndRelativePoint/Rect
diagnosticScreenPoint/Rect
COMPOSITIONFORM 输入值与 API 返回值
CANDIDATEFORM 输入值与 API 返回值
```
测试至少覆盖微软拼音和搜狗输入法,并记录输入法标识、版本(可取得时)、HKL 和测试时间。不同 IME 可能主动调整位置或不显示独立组合窗口:
- API 返回成功不能代替视觉验收。
- 组合窗口不存在但候选窗口可观察时,只验收实际存在的系统 UI,不把“未显示组合窗口”自动判失败。
- 系统因避让屏幕边缘进行的调整应记录为 `system_adjusted`,不能直接归因于框架坐标错误。
- 候选框/组合窗口实际位置必须截图并人工标注,不能只写“正常”。
阶段 6 再使用真实 TextBox 的 `caretTopLeft/caretLineRect` 重跑定位矩阵。0C 通过只证明坐标转换和 IMM 调用链可行,不证明 TextBox 自身 anchor 生成正确。
### 6. DPI 矩阵
基础矩阵:
```text
显示缩放:100% / 125% / 150%
跨屏:主屏 100% + 副屏 150%(或反向)
IME:微软拼音 / 搜狗输入法
anchor:top_left_safe / center / bottom_right_safe
表面:主画面 / EasyX 工作图像
```
执行时记录实际 awareness,并按 §4 的 awareness 规则确定预期。单显示器每档 DPI 至少验证一次两类表面和 center anchor;跨屏场景必须验证窗口完全位于每块显示器时的 DPI、坐标和候选窗口定位,窗口跨越两块显示器但未完成归属切换的瞬态只记录、不作为稳定验收点。
### 7. 自动与人工分层
- 自动检查:HDC/selected bitmap 身份、GDI 返回值、GDI 状态恢复、逻辑测量、目标/非目标表面像素摘要、DPI/坐标换算数值。
- 必须人工观察:候选框/组合窗口的实际屏幕位置、字体缺字/回退外观、组合字符外观、跨屏拖动后的定位。
自动检查结果不能替代人工观察,人工观察也必须留下记录(截图 + 结论),不能只写“正常”。
### 8. 结果格式
使用独立的视觉/坐标结果格式,不并入 0A 键盘 JSONL schema。
建议输出:
```text
TestResults/VisualProbe/<sessionId>/manifest-v1.json
TestResults/VisualProbe/<sessionId>/evidence/*.png
```
`visualProbeSchemaVersion` 初始为 1。manifest 必须包含:
| 字段 | 必需 | 说明 |
|---|---:|---|
| `visualProbeSchemaVersion` | 是 | 初始为 1 |
| `sessionId` | 是 | 本次 0C 会话 UUID |
| `startedAtUtc` | 是 | UTC 时间 |
| `processId/threadId` | 是 | 进程和 GUI 线程 |
| `osBuild/easyxVersion` | 是 | 能取得的运行环境版本 |
| `testCaseId` | 是 | 矩阵中唯一用例 ID |
| `testStringId/codePoints/utf16Units` | 绘制用例 | 固定测试串身份与实际构造结果 |
| `surfaceId` | 绘制用例 | `main` 或指定 working image |
| `hdc/hbitmap/hfont/logFont` | 绘制用例 | 表面和字体指纹 |
| `gdiStateBefore/gdiStateAfter` | 绘制用例 | mapping、clip、颜色、对齐等 |
| `apiResults` | 绘制用例 | 各 GDI API 的函数特定返回语义 |
| `logicalExtent` | 绘制用例 | 相同 HDC/字体/状态下的测量结果 |
| `targetPixelsBefore/After` | 绘制用例 | 目标区域摘要 |
| `controlPixelsBefore/After` | 绘制用例 | 非目标表面未串画证据 |
| `dpiAwareness/windowDpi` | 坐标用例 | 实际 awareness 与 DPI |
| `clientRect/windowScreenRect/surfaceSize` | 坐标用例 | 坐标空间基线 |
| `syntheticAnchorId` | 坐标用例 | 合成 anchor |
| `anchorCoordinates` | 坐标用例 | local/surface/HWND-relative/screen 四组坐标 |
| `compositionForm/candidateForm` | IME 用例 | 输入值、style、API 结果 |
| `imeId/imeVersion/hkl` | IME 用例 | 输入法身份 |
| `positionDisposition` | IME 用例 | `as_requested/system_adjusted/not_observable` |
| `automatedStatus` | 是 | `pass/fail/inconclusive/not_available` |
| `manualStatus` | 是 | `pass/fail/inconclusive/not_reviewed/not_available` |
| `failureCategory` | 否 | 失败分类,不得只写自由文本 |
| `evidenceFiles` | 是 | 相对路径、用途、SHA-256、裁剪区域 |
| `notes` | 否 | 补充说明,不能替代结构化结论 |
截图只能包含探测窗口和固定非敏感测试内容;不得截取其他应用或真实用户输入。每个 evidence 文件记录 SHA-256,防止结果文件和图片错配。
`failureCategory` 只允许以下初始枚举;新增类别必须提升或兼容扩展 schema:
```text
surface_unreachable
surface_cross_write
gdi_state_not_restored
measurement_relation_invalid
font_coverage_missing
coordinate_transform_invalid
ime_position_invalid
awareness_not_supported
evidence_missing
```
`positionDisposition=system_adjusted` 不是自动失败。它必须结合安全 anchor 复测、截图和人工结论,最终落到 `manualStatus`。
示例只展示核心字段:
```json
{
"visualProbeSchemaVersion": 1,
"sessionId": "c73ab2ee-5f83-4d38-a253-58d1d775ca95",
"startedAtUtc": "2026-10-02T08:00:00Z",
"processId": 12000,
"threadId": 12400,
"testCases": [
{
"testCaseId": "gdi-working-cjk-150",
"testStringId": "cjk_bmp",
"codePoints": ["U+661F", "U+57A3", "U+7ED8", "U+5236", "U+6D4B", "U+8BD5"],
"utf16Units": ["661F", "57A3", "7ED8", "5236", "6D4B", "8BD5"],
"surfaceId": "working-image-1",
"hdc": "0x0000000012345678",
"hbitmap": "0x0000000087654321",
"logicalExtent": {"cx": 144, "cy": 24},
"targetPixelsBefore": "sha256:...",
"targetPixelsAfter": "sha256:...",
"controlPixelsBefore": "sha256:...",
"controlPixelsAfter": "sha256:...",
"dpiAwareness": "per_monitor_aware_v2",
"windowDpi": 144,
"automatedStatus": "pass",
"manualStatus": "not_reviewed",
"failureCategory": null,
"evidenceFiles": [
{"path": "evidence/gdi-working-cjk-150.png", "purpose": "target_crop", "sha256": "..."}
]
}
]
}
```
### 9. 阶段门槛、失败判定与降级
| 结果 | 判定 | 后续动作 |
|---|---|---|
| W API 成功、目标表面变化、非目标表面不变 | 通过 | 阶段 5 可采用宽字符 GDI 路径 |
| W API 失败或无法写入 EasyX 目标表面 | 阻塞 | 排查 HDC/selected bitmap/clip/state;确认宽字符路径确实不可用后才评估降级 |
| 绘制成功但逻辑测量、caret advance 或裁剪关系异常 | 阻塞 | 排查字体、mapping mode、字符间距和 flags;不得直接降级 CP936 |
| 某些 glyph 缺失但其他宽字符正常 | 字体问题 | 选择覆盖字体或字体回退;不得按编码问题降级 CP936 |
| 坐标空间一致且当前 awareness 下定位正确 | 通过 | 使用目标 HWND 相对坐标,不额外缩放 |
| 坐标空间不一致但换算规则经矩阵验证 | 条件通过 | 固化显式换算和适用 awareness,防双重缩放 |
| awareness 不支持所测跨屏预期 | 不可用 | 标记 `not_available`,不当作实现失败 |
| API 成功但候选框视觉位置异常 | 未通过 | 阻塞阶段 6,排查 anchor、坐标转换和 IME 差异 |
| 系统因屏幕边界调整候选框 | 系统调整 | 记录 `system_adjusted`,在安全 anchor 复测后判定 |
CP936 只允许作为已证实宽字符绘制链无法使用后的显式显示降级;测量差异、字体缺字或单个 IME 定位异常都不是 CP936 降级依据。任何降级必须带证据并保留 UTF-16 内部原文。
## 实现与影响
- 关键实现点:
- 当前只形成规格清单,尚未增加任何探测代码。
- 实现时根据 Unicode scalar 列表构造固定测试串,使用合成 anchor、预定义采样区域和主/工作图像双表面哨兵,不依赖用户输入或真实 TextBox。
- 实现结果由版本化 manifest 和独立 PNG 证据组成,不复用 0A 键盘 JSONL schema。
- 涉及文件 / 类 / 函数:
- 预计观察现有 `Window`、EasyX 工作图像和 GDI 调用链。
- 具体文件名必须在实现前只读检查后确定,本规格不授权修改框架代码。
- 兼容性影响:
- 探测关闭时不应产生文件、线程、定时器或公共 API 变化。
- 性能影响:
- 仅测试构建开启,对正式路径无影响。
- 风险点:
- 视觉结论依赖人眼,必须与表面指纹、目标/非目标像素摘要和截图证据相互印证。
- 跨屏 DPI 依赖真实多显示器环境,CI 环境可能不具备,需人工实测。
- HDC handle 可能复用,若不同时记录 selected bitmap 和 GDI 状态,可能得到错误的表面身份结论。
- IME 可能主动调整候选窗口,必须在安全 anchor、不同 IME 和实际 awareness 下区分系统调整与框架换算错误。
## 测试与验证
- 测试范围:
- 当前仅验证规格完整性,探测器未实现。
- 验证步骤:
1. 实现前只读定位 EasyX `SetWorkingImage`、主画面/工作图像 HDC、selected bitmap 和 GDI 调用位置。
2. 实现 0C 后,根据明确码点构造固定测试串,使用双表面哨兵验证目标表面变化、非目标表面不变和 GDI 状态恢复。
3. 使用合成 anchor,在实际 DPI awareness 下完成 100%/125%/150%、跨屏、微软拼音和搜狗矩阵,保存结构化 manifest 与截图证据。
4. 依据 §9 分别判定宽字符链、测量链、字体覆盖、坐标换算和 IME 定位;禁止把不同失败类型合并成 CP936 降级。
5. 阶段 6 使用真实 TextBox anchor 重跑坐标与 IME 定位集成验证。
- 验证结果:
- 截至 2026-10-02,仅完成规格评审意见并入与文档归档,未执行任何绘制或坐标探测。
- 已知限制 / 遗留问题:
- 跨屏不同 DPI 结论依赖实机环境。
- 字体回退行为与 EasyX 字体设置的耦合需要实测确认。
- 0C 使用合成 anchor,只验证坐标转换和 IMM 调用链;真实 TextBox anchor 必须在阶段 6 复验。
- 某些 IME 不显示独立组合窗口或会主动调整候选框,允许用例得到 `not_available`、`system_adjusted` 或 `inconclusive`,但必须保留证据。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:无
- Commit:无实现提交(本文件仅归档 0C 规格;0C 探测器尚未实现)
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [TextOutW](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-textoutw)
- [DrawTextW](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-drawtextw)
- [GetTextExtentPoint32W](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-gettextextentpoint32w)
- [GetCurrentObject](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getcurrentobject)
- [SaveDC](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-savedc)
- [RestoreDC](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-restoredc)
- [GetDpiForWindow](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getdpiforwindow)
- [GetWindowDpiAwarenessContext](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowdpiawarenesscontext)
- [COMPOSITIONFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-compositionform)
- [CANDIDATEFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-candidateform)