From 3d08bac1b91fc890bf02230cd56713e451e4e948 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 01:14:58 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=BE=93=E5=85=A5=E7=B3=BB=E7=BB=9F=20?= =?UTF-8?q?v0.7=20=E8=AE=BE=E8=AE=A1=E4=B8=8E=200A=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-0001-EasyX原生窗口销毁后宿主事件循环未退出.md | 57 + ...61004-0001-原生窗口销毁后的宿主循环退出修复.md | 95 ++ ...-20261001-0001-通用输入焦点与键盘路由系统设计.md | 1124 +++++++++++++++++ ...dule-20261001-0002-阶段0A输入链路被动探测规格.md | 792 ++++++++++++ ...261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md | 377 ++++++ 5 files changed, 2445 insertions(+) create mode 100644 开发记录/BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md create mode 100644 开发记录/Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md create mode 100644 开发记录/模块/Module-20261001-0001-通用输入焦点与键盘路由系统设计.md create mode 100644 开发记录/模块/Module-20261001-0002-阶段0A输入链路被动探测规格.md create mode 100644 开发记录/模块/Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md diff --git a/开发记录/BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md b/开发记录/BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md new file mode 100644 index 0000000..38255a8 --- /dev/null +++ b/开发记录/BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md @@ -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 `;普通循环和真实 `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`。 diff --git a/开发记录/Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md b/开发记录/Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md new file mode 100644 index 0000000..d178619 --- /dev/null +++ b/开发记录/Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md @@ -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 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:无 +- 发布版本:未发布 +- 备注: 未删除任何探测结果;微软拼音和搜狗真实输入法人工测试均已完成。 diff --git a/开发记录/模块/Module-20261001-0001-通用输入焦点与键盘路由系统设计.md b/开发记录/模块/Module-20261001-0001-通用输入焦点与键盘路由系统设计.md new file mode 100644 index 0000000..4ca7e68 --- /dev/null +++ b/开发记录/模块/Module-20261001-0001-通用输入焦点与键盘路由系统设计.md @@ -0,0 +1,1124 @@ +# 新增功能模块 / 模块重构 + +> 适用场景:新增模块、重大模块重构、核心架构能力演进。 +> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。 + +## 基本信息 + +- 模块 ID:Module-20261001-0001 +- 模块名称:通用输入采集、焦点与键盘路由系统 +- 状态:设计中(v0.7 TSF/HKL 证据修订;阶段 0A 已实施,阶段 1 尚未开始) +- 类型:新增模块 / 架构演进 +- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox +- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25` +- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置 +- 负责人:Codex 协作设计 + +## 背景与目标 + +- 背景: + - 当前框架已经具备 Window、Control、Canvas、Button、TextBox、Table、TabControl、Dialog 等基础能力,但尚未形成统一的输入采集、逻辑焦点和键盘定向路由体系。 + - 当前 TextBox 仍依赖 EasyX 输入能力,焦点也不能继续作为 TextBox 独有状态;Button、Table 分页按钮、TabControl 页签按钮和 Dialog 都需要进入同一套键盘交互模型。 + - 阶段 0 已确认 EasyX HWND/WndProc 与宿主 `peekmessage` 分属两个线程,当前窗口为 ANSI;微软拼音与搜狗的提交/回声顺序已经取得真实输入法证据。 +- 当前痛点: + - 缺少“哪个控件接收键盘”的统一规则。 + - 键盘物理按键、字符输入和 IME 提交结果没有明确分层。 + - Table、TabControl 等复合控件的内部 Button 缺少统一的 Tab 顺序和事件边界。 + - Dialog 默认按钮、Escape 关闭、TextBox 编辑和 IME 候选操作可能争用同一按键。 + - 控件删除、隐藏、禁用、切页和模态重入可能使裸指针焦点失效。 + - 工程仍为 MBCS,现有文本链路不能直接承担完整 Unicode 编辑内核。 +- 目标: + - 建立“输入采集与规范化 → 通用焦点 → 键盘定向路由 → 控件接入”的稳定分层。 + - 让 Button、TextBox、Table 内部分页 Button、TabControl 页签 Button 等交互控件使用同一焦点系统。 + - 建立不依赖控件裸指针的焦点和路由生命周期。 + - 为 TextBox 自绘、UTF-16 编辑内核、剪贴板和 IME 提交提供统一输入基础。 + - 在框架接管输入前,通过阶段 0 探测 EasyX 和 Win32 的真实消息链路。 +- 非目标: + - v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。 + - v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。 + - v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。 + - 本设计稿不代表框架代码已经实现,也不构成运行验证结果。 + +## 模块边界 + +- 职责: + - WndProc 同步窗口协议的就地应答。 + - 键盘、字符、IME 和窗口激活消息的异步采集与规范化。 + - 每个 Window 的 NativeInputQueue、单一 drainer 和嵌套模态循环输入所有权。 + - 通用逻辑焦点、焦点作用域、Tab 顺序和焦点回退。 + - 焦点目标键盘路由、祖先冒泡和 Dialog/Window 默认动作。 + - Button、TextBox、Table、TabControl、Dialog 的统一键盘行为。 + - UTF-16 文本输入、ANSI/DBCS 回退和 IME 正式提交去重。 +- 不负责什么: + - 不替代操作系统键盘布局、IMM32 或系统候选窗口。 + - 不让绘制控件变成原生 HWND 子控件。 + - 不在路由层回溯修改已经结束的 WndProc 返回值。 + - 不把非活动 Tab 页面作为可访问焦点树的一部分。 + - 不在 v1 中把所有指针输入迁移到原生 WndProc 采集。 +- 外部依赖: + - Win32 窗口消息和旧窗口过程。 + - EasyX 消息循环和工作图像。 + - IMM32:`ImmGetCompositionStringW`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow`、`ImmNotifyIME`。 + - 宽字符 GDI:`TextOutW` / `DrawTextW` / `GetTextExtentPoint32W`。 +- 对外能力 / API: + - 本稿只确定职责和接口形状,公共 API 名称在阶段 0 完成后再落地。 + - 控件通过焦点能力、键盘处理、文本输入和 Tab stop 收集接口接入。 +- 关键数据 / 状态: + - `NativeInputEvent`、`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`。 + - `ControlHandle`、`FocusScope`、逻辑焦点句柄和窗口键盘激活状态。 + - 每个 Window 的输入队列、drainer 所有者、dispatch/modal 深度。 + - 每个 HWND 的 IME 状态、`compositionId`、结果回声和 ANSI 解码状态。 + +## 设计说明 + +### 1. 总体建设顺序 + +```text +阶段 0:输入链路探测 + ↓ +阶段 1:输入采集与规范化 + ↓ +阶段 2:通用焦点系统 + ↓ +阶段 3:键盘定向路由 + ↓ +阶段 4:Button / Table / TabControl / Dialog 接入 + ↓ +阶段 5:TextBox 编辑内核与自绘 + ↓ +阶段 6:IME、Unicode、DPI 完整接入 +``` + +阶段 0 是实现门槛,不是可选调研。阶段 0A 的详细规格见: + +- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md) + +#### 1.1 阶段 0 已确认事实(更新至 2026-10-04) + +独立 `EasyX.ThreadProbe` 不链接 StellarX,只执行裸 `initgraph`、WndProc 子类化、固定键投递和 `peekmessage`。实测: + +- main 在 `initgraph` 前后保持同一线程。 +- EasyX HWND 属于另一个线程。 +- 裸 WndProc 和固定键 WndProc 都在 HWND 属主线程执行。 +- `peekmessage` 仍由 main/宿主线程调用。 +- 固定键同时经过 WndProc 和 EasyX `EX_KEY` 两个观察面。 +- `IsWindowUnicode(hwnd) == false`,当前 EasyX 窗口类为 ANSI;活动中文布局代码页实测为 936。 + +因此,双线程不是 UiFixture 或 StellarX 钩子制造的假象,而是当前 EasyX `initgraph` 的固有运行模型。阶段 1 不得再使用“WndProc 与宿主循环同线程”的假设。 + +微软拼音与搜狗真实人工日志进一步确认: + +- 两者均以 `WM_IME_COMPOSITION + GCS_RESULTSTR` 提供正式提交结果。 +- 当前 ANSI/CP936 窗口中,随后出现与结果完整匹配的 `WM_IME_CHAR` 回声;两种输入法均未形成可匹配的 `WM_CHAR` 回声。 +- 微软拼音 3 次结果均为 2 个 scalar;搜狗 3 次结果分别为 7、2、6 个 scalar,证明去重不能依赖固定长度或文本内容。 +- 两次“中英文/输入法切换”会话均未观察到 `WM_INPUTLANGCHANGE`。IME 内部模式、TSF 输入配置文件与 HKL/代码页不得混为同一状态。 +- 原探测器遗漏了 `WM_INPUTLANGCHANGEREQUEST` 默认观察,v0.6 已补入 request/change 分层:request 只观察和转发,change 才更新活动代码页。 +- v0.7 补观测后的 Win+Space 专用短测仍未出现 request/change;当前系统仅安装 `zh-Hans-CN` 语言标签,实际切换发生在同一中文 HKL/CP936 下的 TSF 输入配置文件之间,因此该结果确认“TSF 配置文件切换不保证产生 HKL 消息”,但不代表跨语言 HKL 切换也不会产生。 + +### 2. 分层架构 + +```text +Win32 / EasyX + │ + ▼ +InputCollector +同步应答、异步采集、Unicode/DBCS 解码、IME 状态与去重 + │ + ▼ +NativeInputQueue +SPSC 语义的跨线程通道:EasyX 窗口线程生产,宿主线程消费 + │ + ▼ +InputDispatcher +单 drainer、模态重入、逐条出队 + ├───────────────┐ + ▼ ▼ +FocusManager KeyboardRouter + └───────┬───────┘ + ▼ +Button / TextBox / Table / TabControl / Dialog +``` + +鼠标 v1 暂不在 WndProc 重新采集。现有 EasyX 鼠标事件进入控件分发前转换成框架 `PointerEvent`,由 PointerDown 在命中焦点叶子时申请焦点。 + +### 3. 关键不变量 + +1. NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。 +2. EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。 +3. 同步窗口协议不得进入异步输入队列。 +4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。 +5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `ControlHandle`。 +6. 同一 Window 同时只能由最内层消息循环 drain 输入队列。 +7. 每次只从队列取一条事件,不预取整个批次到局部容器。 +8. WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。 +9. NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通 `std::deque`。 +10. `sequence` 提供全局因果编号,但不能代替队列同步。 + +### 4. Win32 语义边界 + +#### 4.1 `TranslateMessage` 与 WndProc + +标准 Win32 循环通常先执行 `TranslateMessage`,再通过 `DispatchMessage` 进入 WndProc。WndProc 对当前 `WM_KEYDOWN` 返回 0 不能回溯撤销已经产生的字符消息。 + +真正需要探测的是 EasyX 是否调用 `TranslateMessage`、调用顺序是否标准、普通循环和模态循环是否一致。因此 v1 默认采用“采集 + 转发”,只允许对实测确认后的窄白名单消费。 + +#### 4.2 `WM_UNICHAR` + +设计不假设 `WM_UNICHAR` 只会到达某一种窗口: + +- `UNICODE_NOCHAR` 是同步能力查询,支持真实 `WM_UNICHAR` 时立即返回 `TRUE`。 +- 真实 `WM_UNICHAR` 使用 UTF-32,收到后统一校验并转换为 UTF-16。 +- 真实字符若由框架处理,则返回 0,防止 DefWindowProc 再产生 `WM_CHAR`。 +- 阶段 0 记录其实际来源,不把未被微软文档承诺的系统投递规律写成框架契约。 + +#### 4.3 `WM_IME_SETCONTEXT` + +IME UI 由“是否转发、转发时保留哪些 `ISC_SHOWUI*` 标志、被调用窗口过程的返回值”共同决定。 + +v1 使用系统 IME UI: + +1. 不修改 `lParam`。 +2. 调用旧窗口过程。 +3. 返回旧窗口过程结果。 + +未来自绘组合串时,才在转发前清除 `ISC_SHOWUICOMPOSITIONWINDOW` 等由框架接管的标志。 + +### 5. WndProc 消息分类 + +#### 5.1 同步窗口协议 + +以下消息必须就地处理,不进入 NativeInputQueue: + +| 消息 | v1 行为 | +|---|---| +| `WM_GETDLGCODE` | 根据当前框架焦点能力,选择性合并并返回 `DLGC_WANTTAB/WANTARROWS/WANTCHARS` 等 | +| `WM_UNICHAR + UNICODE_NOCHAR` | 支持真实 `WM_UNICHAR` 时立即返回 `TRUE` | +| `WM_IME_SETCONTEXT` | 原样转发旧窗口过程并返回其结果 | +| `WM_SETCURSOR` | 框架拥有命中区域光标时就地设置,否则转发 | +| `WM_MOUSEACTIVATE` | 根据激活策略同步返回 | +| `WM_GETMINMAXINFO` | 保留现有同步处理 | +| `WM_SIZING` | 保留现有同步处理 | +| `WM_ERASEBKGND` | 保留现有同步处理 | +| `WM_NCHITTEST` 等 | 返回值直接影响当前系统行为的消息保持同步 | + +`WM_GETDLGCODE` 不得无条件返回 `DLGC_WANTALLKEYS`,必须结合当前焦点能力、`wParam` 和可选 `MSG*` 选择性声明。 + +#### 5.2 异步输入消息 + +| 消息 | 规范化结果 | 初始处置 | +|---|---|---| +| `WM_KEYDOWN/UP` | `KeyEvent` | 采集后转发 | +| `WM_SYSKEYDOWN/UP` | `KeyEvent` | 采集后转发,保留系统组合键 | +| `WM_CHAR` | `TextInputEvent` 候选来源 | 解码、去重后转发 | +| 真实 `WM_UNICHAR` | UTF-32 转 UTF-16 | 采集并返回 0 | +| `WM_DEADCHAR` | 只记录状态 | 不产生文本,继续转发 | +| `WM_SYSCHAR/SYSDEADCHAR` | 通常不进入控件 | 继续转发 | +| `WM_IME_STARTCOMPOSITION` | `CompositionEvent::Start` | 采集后转发 | +| `WM_IME_COMPOSITION` | Update / Commit / Cancel | 采集后转发 | +| `WM_IME_ENDCOMPOSITION` | `CompositionEvent::End` | 采集后转发 | +| `WM_IME_CHAR` | IME 回声或兼容来源 | 观察、去重、转发 | +| `WM_SETFOCUS/KILLFOCUS` | `WindowActivationEvent` | 采集后转发 | +| `WM_INPUTLANGCHANGEREQUEST` | 输入布局切换请求 | 观察请求方向与目标 HKL,原样转发,不提前更新代码页 | +| `WM_INPUTLANGCHANGE` | 更新输入代码页状态 | 同步更新状态并转发 | + +#### 5.3 消费策略 + +初始策略: + +```text +KeyDown/KeyUp:采集 + 转发 +WM_CHAR:采集 + 转发 +IME 消息:采集 + 转发 +真实 WM_UNICHAR:采集 + 消费 +``` + +只有阶段 0 证明转发会产生重复行为或原生副作用后,才允许建立窄白名单,例如: + +- 非组合态 Tab。 +- 非组合态且框架明确拥有的方向键。 +- 非组合态 Button 的 Enter/Space。 +- 框架明确拥有的 Ctrl 快捷键。 + +硬规则:`imeComposingAtCapture == true` 的键不能进入消费白名单。 + +### 6. 输入事件模型 + +#### 6.1 `KeyEvent` + +```cpp +struct KeyEvent +{ + KeyAction action; // Down / Up + + UINT virtualKey; // 消息原始 VK + UINT physicalVirtualKey; // 区分左右修饰键后的 VK + UINT scanCode; + + uint16_t repeatCount; + bool extended; + bool wasDown; + bool altContext; + bool systemKey; + + bool imeComposingAtCapture; + + KeyModifiers modifiers; // left/right Ctrl/Shift/Alt +}; +``` + +规则: + +```cpp +bool isAutoRepeat = action == KeyAction::Down && wasDown; +``` + +- `modifiers.alt()` 是业务逻辑依据。 +- `altContext` 只保留 Win32 lParam bit 29 原始值。 +- 左右 Ctrl/Alt 使用 extended 位,Shift 使用 scan code 区分。 +- Button 判断首次按下使用 `wasDown`,不使用 `repeatCount > 1` 代替。 + +#### 6.2 `TextInputEvent` + +```cpp +enum class TextSource +{ + ImeResult, + UnicodeChar, + UnicodeScalar, + AnsiCharFallback, + Clipboard +}; + +struct TextInputEvent +{ + std::wstring text; + TextSource source; + uint64_t compositionId; // 非 IME 路径为 0 +}; +``` + +该事件只表示已经形成的文字,不表示物理按键。 + +#### 6.3 `CompositionEvent` + +```cpp +enum class CompositionStage +{ + Start, + Update, + Commit, + Cancel, + End +}; + +struct CompositionEvent +{ + CompositionStage stage; + uint64_t compositionId; + std::wstring compositionText; + uint32_t caretOffset; +}; +``` + +`Commit` 只描述 IME 状态变化。真正插入 TextBox 的内容仍然只通过一条 `TextInputEvent` 发送。 + +#### 6.4 `WindowActivationEvent` + +```cpp +struct WindowActivationEvent +{ + bool keyboardFocusActive; + HWND otherWindow; +}; +``` + +窗口失活时可以保留逻辑焦点句柄,但 `keyboardFocusActive == false` 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。 + +#### 6.5 队列外壳 + +```cpp +struct NativeInputEvent +{ + uint64_t sequence; + DWORD messageTime; + HWND hwnd; + + std::variant< + KeyEvent, + TextInputEvent, + CompositionEvent, + WindowActivationEvent + > payload; +}; +``` + +键盘、文本和 IME 事件不合并、不静默丢弃。 + +#### 6.6 跨线程队列模型 + +当前拓扑是单生产者/单消费者: + +```text +EasyX HWND/WndProc 线程 + └─ InputCollector / push + ↓ + NativeInputQueue + ↓ +宿主 main/runEventLoop 线程 + └─ 最内层 drainer / pop one +``` + +阶段 1 的正确性基线采用 `mutex + std::deque`: + +1. WndProc 每次只在短临界区内移动一条完整事件入队。 +2. 宿主每次只在短临界区内弹出一条事件,释放锁后再执行路由、回调和控件 mutation。 +3. 禁止在持锁期间调用旧窗口过程、IMM、用户回调、控件方法或文件 I/O。 +4. 当前宿主循环持续轮询,不额外增加唤醒;未来改成阻塞等待时再使用 Event/条件变量。 +5. 队列不得静默丢输入;若以后改为固定容量 lock-free SPSC,必须先定义文本负载所有权、满队列和分配失败策略。 +6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。 +7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。 + +探测器使用的“固定环形缓冲 + 尝试锁 + 允许 `records_lost`”只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。 + +### 7. Unicode 和文字来源 + +#### 7.1 窗口属性 + +初始化时记录: + +```cpp +bool unicodeWindow = IsWindowUnicode(hwnd); +``` + +窗口类 A/W 属性决定 `WM_CHAR` 是 UTF-16 还是当前进程代码页数据,但不用于拒绝真实 `WM_UNICHAR`。 + +#### 7.2 正式提交优先级 + +```text +1. ImmGetCompositionStringW(GCS_RESULTSTR) +2. Unicode HWND 的 WM_CHAR +3. 真实 WM_UNICHAR +4. ANSI HWND 的状态化 WM_CHAR / WM_IME_CHAR 回退 +``` + +`ImmGetCompositionStringW` 返回的是字节数,即使使用 W 版本也必须换算为 UTF-16 code unit 数量。 + +#### 7.3 IME 结果去重 + +收到 `GCS_RESULTSTR` 时: + +1. 立即产生唯一的 `TextInputEvent`。 +2. 记录当前 `compositionId`。 +3. 保存预期 UTF-16 或 ANSI 回声序列。 +4. 后续紧邻且匹配的 `WM_IME_CHAR/WM_CHAR` 只观察和转发,不再次入队。 +5. 遇到不匹配字符、新组合、焦点切换或越过本次结果边界时清空回声状态。 + +不能使用“字符内容相同就去重”,否则连续输入两个相同汉字时会误删第二个。具体回声顺序以阶段 0 日志决定。 + +#### 7.4 ANSI / DBCS 回退 + +```cpp +struct AnsiDecoderState +{ + UINT codePage; + std::optional pendingLead; +}; +``` + +处理规则: + +1. 使用当前实际 ANSI 代码页。 +2. 使用 `IsDBCSLeadByteEx` 判断 lead byte。 +3. lead byte 暂存,不立即形成文本。 +4. 下一字节作为 trail。 +5. 使用 `MultiByteToWideChar` 严格转换。 +6. 转换失败时清除状态并记录诊断。 +7. 焦点切换、窗口失活、IME 开始时清除残留 `pendingLead`。 +8. 收到 `WM_INPUTLANGCHANGEREQUEST` 时只记录目标布局并转发,不提前改变解码状态。 +9. 收到 `WM_INPUTLANGCHANGE` 时,必须先清除 `pendingLead`,再更新活动代码页,禁止跨代码页拼接字节。 +10. 窗口挂接和重新获得键盘焦点时,按 HWND 属主线程调用 `GetKeyboardLayout` 做防御性快照;若快照与当前 HKL/代码页不同,同样先清 `pendingLead` 再更新,并记录状态来源。该快照只用于修复消息遗漏或失焦期间变化,不把 TSF 配置文件误建模为代码页变化。 + +`WM_IME_CHAR` 在 ANSI 窗口可能携带一个双字节字符;转发给 DefWindowProc 后又可能拆成两个 `WM_CHAR`,因此必须纳入结果去重状态机。 + +### 8. IME 状态机 + +#### 8.1 v1 策略 + +- 保留系统组合串和候选框 UI。 +- TextBox 自绘已提交文本、选区和光标。 +- 框架设置系统 IME UI 的位置。 +- `WM_IME_SETCONTEXT` 原样转发。 +- v1 不清除 `ISC_SHOWUICOMPOSITIONWINDOW`。 + +#### 8.2 `compositionId` + +每个 Window 维护独立的单调递增 `uint64_t nextCompositionId`,0 保留给非 IME 输入。 + +```text +WM_IME_STARTCOMPOSITION: + compositionId = ++nextCompositionId +``` + +如果在 Idle 状态直接收到 `WM_IME_COMPOSITION`,必须惰性分配新的 `compositionId`。该 ID 不得沿用上一次 Commit 或 Cancel 的组合,避免迟到或乱序消息污染已经结束的状态。 + +#### 8.3 状态流 + +```text +Idle + └─ WM_IME_STARTCOMPOSITION → 分配 id → Composing + +Composing + ├─ GCS_COMPSTR → Update + ├─ GCS_RESULTSTR → Commit + TextInputEvent + ├─ 无任何 GCS_* → Cancel + └─ WM_IME_ENDCOMPOSITION → End → Idle +``` + +#### 8.4 Cancel 来源 + +`CompositionStage::Cancel` 由以下情况合成: + +1. 活跃组合中收到无 `GCS_*` 的 `WM_IME_COMPOSITION`。 +2. 框架调用 `ImmNotifyIME(..., NI_COMPOSITIONSTR, CPS_CANCEL, 0)` 成功。 +3. 收到 `WM_IME_ENDCOMPOSITION`,但本次组合没有 Commit。 + +每个 `compositionId` 最多产生一次 Cancel。主动取消成功后,后续系统消息不能再次发出 Cancel。 + +#### 8.5 组合期间按键 + +组合开始后: + +1. 取消 Button 等控件残留的键盘按压态。 +2. KeyEvent 继续采集并转发旧窗口过程。 +3. KeyEvent 不发送给控件命令处理器。 +4. 不执行 Tab 导航。 +5. 不执行 Dialog 的 Enter/Escape。 +6. 不切换 TabControl 页签。 +7. 文本只接受 IME 正式提交结果。 + +判断依据是事件自己的 `imeComposingAtCapture`,不是稍后读取的全局状态。 + +### 9. IME 位置与 DPI + +TextBox 提供: + +```cpp +struct TextInputAnchor +{ + POINT caretTopLeft; + RECT caretLineRect; +}; +``` + +v1 使用: + +```text +COMPOSITIONFORM: + CFS_POINT + ptCurrentPos = caretBottom + +CANDIDATEFORM: + CFS_EXCLUDE + ptCurrentPos = caretBottom + rcArea = 当前文字行矩形 +``` + +DPI 规则: + +1. 记录进程 DPI awareness 和 `GetDpiForWindow(hwnd)`。 +2. 验证 EasyX 绘制坐标是否与目标 HWND 坐标空间一致。 +3. 一致时直接使用,不手工乘 DPI 比例。 +4. 只有实测发现坐标空间不同才做明确转换。 +5. 验证 100%、125%、150% 和跨显示器移动。 + +### 10. 通用焦点系统 + +#### 10.1 焦点能力 + +```cpp +enum class FocusPolicy +{ + None, + Programmatic, + Click, + TabAndClick +}; +``` + +| 控件 | 焦点角色 | +|---|---| +| Label | 不可聚焦 | +| Canvas | 透明焦点容器 | +| Button | 焦点叶子 | +| TextBox | 焦点叶子 | +| Table | 焦点容器,暴露内部翻页 Button | +| TabControl | 页签焦点组和活动页面容器 | +| Dialog | 焦点作用域 | +| Window | 根焦点作用域 | + +#### 10.2 `ControlHandle` + +```cpp +struct ControlHandle +{ + WindowId owner; + uint32_t slot; + uint32_t generation; +}; +``` + +- slot 重用必须递增 generation。 +- 失效句柄解析为空。 +- `collectTabStops()` 返回 `ControlHandle`。 +- `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。 + +#### 10.3 焦点有效条件 + +```text +句柄有效 +&& effectiveVisible +&& effectiveEnabled +&& FocusPolicy 允许 +&& 位于最上层活动 FocusScope +&& 位于活动 Tab 页面 +&& 不在 pending removal +``` + +#### 10.4 焦点切换 + +```cpp +FocusChangeResult requestFocus( + ControlHandle target, + FocusReason reason); +``` + +顺序: + +1. 验证目标。 +2. 保存旧句柄。 +3. 更新 FocusManager。 +4. 向旧目标发送 `FocusLost`。 +5. 向新目标发送 `FocusGained`。 +6. 使相关区域失效并重绘。 +7. 新目标为 TextBox 时更新 IME 锚点。 + +#### 10.5 焦点失效回退 + +焦点控件被隐藏、禁用、删除或移入非活动页时: + +```text +同一作用域下一个可聚焦项 +→ 上一个可聚焦项 +→ 作用域 initialFocus +→ 无焦点 +``` + +控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。 + +#### 10.6 Dialog 作用域 + +Dialog 打开: + +1. 压入新 FocusScope。 +2. 保存外层焦点句柄。 +3. 选择 `initialFocus`,否则选择第一个 Tab stop。 +4. 完成作用域建立后才允许模态循环 drain。 + +Dialog 关闭: + +1. 停止内层 drain。 +2. 弹出作用域。 +3. 验证并恢复原焦点。 +4. 原焦点失效时执行外层回退。 + +### 11. Tab 顺序与复合控件 + +#### 11.1 Tab 顺序 + +顺序由显式 `tabIndex` 和控件树稳定顺序决定,过滤: + +- 隐藏控件。 +- 禁用控件。 +- pending removal 控件。 +- 非活动页面控件。 +- FocusPolicy 不包含 Tab 的控件。 + +Tab 只在当前最上层 FocusScope 内循环。 + +#### 11.2 Table + +Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和下一页 Button。 + +- 第一页时上一页 Button 禁用并退出 Tab 顺序。 +- 最后一页时下一页 Button 禁用并退出 Tab 顺序。 +- 翻页后当前焦点 Button 若被禁用,立即执行焦点回退。 +- 翻页 Button 只向 Table 和外层祖先冒泡,不能把按键转发到表格内容。 + +#### 11.3 TabControl + +页签使用 roving-tab-stop: + +- 只有当前选中页签位于外部 Tab 顺序。 +- Tab 进入 TabControl 时先到当前页签。 +- Left/Right 在页签组内移动并激活页签。 +- Tab 从页签进入活动页面第一个焦点项。 +- Shift+Tab 从页面第一个焦点项回到活动页签。 +- 非活动页面完全退出焦点树。 + +页签 Button 与页面是兄弟关系: + +```text +页签 Button → TabControl → 外层容器 +``` + +禁止: + +```text +页签 Button → 活动页面 +``` + +活动页面控件可以向祖先 TabControl 冒泡,但 TabControl 只有在原始焦点是页签 Button 时才处理 Left/Right。 + +### 12. 键盘路由 + +#### 12.1 总流程 + +```text +取出一条事件 +→ 验证窗口激活状态 +→ IME 预处理 +→ Tab 导航 +→ 锁定原始焦点句柄 +→ 构造句柄路由路径 +→ 焦点叶子 +→ 父容器冒泡 +→ FocusScope 默认动作 +→ Window 默认动作 +→ 提交当前事件产生的 mutation +``` + +`TextInputEvent` 只发送给实现文本输入接口的当前焦点叶子,不冒泡。 + +#### 12.2 路由结果 + +```cpp +struct KeyRouteResult +{ + bool stopBubble; + bool preventFrameworkDefault; +}; +``` + +- `stopBubble`:不再交给父容器。 +- `preventFrameworkDefault`:冒泡结束后不再执行 Dialog/Window 默认动作。 +- 两者均不影响已经完成的原生 WndProc 返回值。 + +#### 12.3 路由路径与 mutation + +事件开始时使用句柄构造路径快照: + +```text +FocusedLeaf → Parent → ... → FocusScope → Window +``` + +每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。 + +### 13. 按键归属 + +#### 13.1 Tab + +- IME 组合中交给 IME,不移动焦点。 +- 非组合态由 FocusManager 处理。 +- Shift+Tab 反向移动。 +- 普通 Button/TextBox 不直接接收 Tab。 + +#### 13.2 Space + +Button: + +```text +首次 KeyDown → armed + pressed +重复 KeyDown → 忽略 +KeyUp 且仍聚焦、仍 armed → 触发一次 click +焦点丢失或控件禁用 → 取消 armed +``` + +TextBox 不从 KeyEvent 插入空格,只接受 `TextInputEvent(U+0020)`。 + +#### 13.3 Enter + +```text +IME +→ 聚焦 Button +→ 聚焦控件自定义动作 +→ Dialog 默认按钮 +→ Window 默认动作 +``` + +- 单行 TextBox 不插入换行,默认不阻止 Dialog 默认按钮。 +- Button 只在首次 KeyDown 激活,自动重复不能重复点击。 + +#### 13.4 Escape + +```text +IME 取消 +→ Button 清除按压态 +→ 子控件局部取消 +→ Dialog 关闭 +``` + +- Button 清除 pressed 只恢复视觉状态,不阻止 Dialog 默认动作。 +- 子控件存在真正局部取消状态时,可以设置 `preventFrameworkDefault=true`。 +- 普通文本选区不建议定义为 Escape 可取消状态。 + +#### 13.5 方向键 + +```text +TextBox 光标/选区 +→ 原始焦点为页签 Button 时由 TabControl 切页 +→ 其他容器自定义导航 +``` + +#### 13.6 Ctrl 快捷键与控制字符 + +TextBox v1 处理 `Ctrl+A/C/X/V`。这些命令走 KeyEvent;相应 `WM_CHAR 0x01–0x1A` 即使形成 TextInputEvent 也不得插入文本。 + +#### 13.7 Button 触发时机注释 + +Button 的 Space 在 KeyUp 确认、Enter 在首次 KeyDown 确认是有意设计: + +- Space 对应按压释放语义,允许按住后因焦点移走或状态变化而放弃。 +- Enter 对应默认按钮即时确认语义。 + +禁止为了表面一致把两者统一到同一个触发时机。 + +### 14. TextBox 编辑内核与自绘 + +#### 14.1 内部模型 + +```cpp +class TextEditModel +{ + std::wstring text; + size_t caret; + size_t selectionAnchor; + size_t selectionActive; + size_t horizontalScroll; +}; +``` + +最小 Unicode 保证: + +- 光标不能落在代理项中间。 +- Backspace/Delete 不能删除半个代理对。 +- UTF-32 输入拒绝大于 `U+10FFFF` 和代理区值。 +- v1 暂不承诺完整 grapheme cluster 删除语义。 + +#### 14.2 KeyEvent 与 TextInputEvent 分工 + +KeyEvent 处理: + +- Left/Right、Home/End。 +- Shift 选区。 +- Ctrl+A/C/X/V。 +- Backspace/Delete。 + +TextInputEvent 处理: + +- 普通字符。 +- 空格。 +- IME 正式结果。 +- 剪贴板文字。 + +#### 14.3 单行控制字符 + +单行 TextBox 默认拒绝 `U+0000–U+001F` 和 `U+007F`。Backspace、Tab、Enter、Escape 走 KeyEvent,不通过 TextInputEvent 插入。 + +粘贴建议规范: + +```text +CRLF / CR / LF → 一个空格 +Tab → 一个空格 +其他控制字符 → 删除 +``` + +#### 14.4 自绘顺序 + +```text +背景 +→ 边框 +→ 选区背景 +→ 文本 +→ 光标 +→ 焦点边框 +``` + +必须支持内容裁剪、水平滚动、光标可见、选区颜色、焦点/窗口失活样式和 IME 锚点刷新。 + +#### 14.5 宽字符绘制 + +首选: + +```text +TextOutW / DrawTextW +GetTextExtentPoint32W +``` + +直接操作 EasyX 当前工作图像对应的 HDC。MBCS 工程配置不等于必须把内部文本降级成 CP936。 + +阶段 0 必须验证: + +- W API 是否实际画入 EasyX 当前 working image。 +- `SetWorkingImage` 后 HDC 是否指向正确图像。 +- 中文、代理对和字体回退。 +- 宽字符测量与实际绘制是否一致。 + +#### 14.6 CP936 显示降级 + +只有宽字符 GDI 实测不可用时才使用 `WideCharToMultiByte(CP936, WC_NO_BEST_FIT_CHARS, ...)`。 + +- 无法表示的字符显示 `?`。 +- 检查 `usedDefaultChar`。 +- 内部 `std::wstring` 原文不变。 +- 维护 UTF-16 索引到绘制字节偏移的映射。 +- 这是显示降级,不是文本存储降级。 + +旧 `std::string` API 可保留兼容,同时增加宽字符接口;旧接口在明确的 CP936 边界转换,不能污染内部模型。 + +### 15. 模态循环与单 drainer + +不能使用简单的 `bool managedDispatchActive` 表示输入调度状态,需改为深度和所有者: + +```cpp +struct DispatchState +{ + uint32_t dispatchDepth; + uint32_t modalDepth; + DrainToken activeDrainer; +}; +``` + +规则: + +1. 外层循环持有 drainer。 +2. Dialog 进入模态前先压入 FocusScope。 +3. 最内层模态循环接管 drainer。 +4. 外层停止取下一条事件。 +5. 内层退出后归还 drainer。 +6. 外层继续剩余队列。 +7. 每次只弹一条事件。 +8. 禁止一次性预取整个队列。 + +模态窗口可能在 Button 回调中打开,因此 pending removal 中的句柄立即失效,但对象内存直到最外层 `dispatchDepth == 0` 才回收。任何借用裸指针都不得跨越 `showModal()`。 + +### 16. 控件行为矩阵 + +| 控件 | 鼠标焦点 | Tab 焦点 | Enter | Space | Escape | 方向键 | +|---|---:|---:|---|---|---|---| +| Button | 是 | 是 | 首次 KeyDown 激活 | KeyUp 激活 | 清 pressed,默认动作可继续 | 默认不处理 | +| TextBox | 是 | 是 | 单行不插入 | TextInput 插入 | 局部状态决定 | 光标/选区 | +| Table | 否 | 否 | 内部 Button | 内部 Button | 冒泡 | 可扩展 | +| Table 翻页 Button | 是 | 是 | 翻页 | 翻页 | 清 pressed | 不处理 | +| Tab 页签 Button | 是 | roving | 激活页签 | 激活页签 | 冒泡 | 切换页签 | +| Canvas | 否 | 否 | 冒泡 | 冒泡 | 冒泡 | 冒泡 | +| Dialog | 作用域 | 作用域 | 默认按钮 | 无 | 关闭 | 无 | +| Label | 否 | 否 | 无 | 无 | 无 | 无 | + +### 17. 阶段 0 门槛 + +在实现 FocusManager 前必须验证: + +1. `IsWindowUnicode(hwnd)` 实际结果。 +2. EasyX 主循环是否经过当前 WndProc。 +3. WndProc 和 EasyX `EX_KEY` 是否重复报告。 +4. EasyX 是否调用 `TranslateMessage`。 +5. `GCS_RESULTSTR`、`WM_IME_CHAR`、`WM_CHAR` 的真实顺序。 +6. 微软拼音和搜狗输入法差异。 +7. 消费 `WM_KEYDOWN` 是否影响 EasyX 或 IME 状态。 +8. 是否收到 `WM_GETDLGCODE`。 +9. ANSI 中文是否拆成 lead/trail `WM_CHAR`。 +10. 鼠标、键盘、IME、窗口焦点消息顺序。 +11. 模态循环是否仍经过同一 WndProc。 +12. 宽字符 GDI 是否画入当前 EasyX 工作图像。(归属阶段 0C) +13. 100%、125%、150% 候选框定位。(归属阶段 0C) +14. 跨显示器后的 DPI 和候选框坐标。(归属阶段 0C) +15. 接管后 EasyX 原有鼠标、缩放和退出行为是否正常。 + +其中第 5、6、9 项在当前微软拼音/搜狗 + ANSI EasyX + CP936 环境下已有结论:正式提交来自 `GCS_RESULTSTR`,`WM_IME_CHAR` 是完整匹配回声,未观察到匹配的 `WM_CHAR` lead/trail 回声。第 7 项仍属于 0B 消费实验;第 12–14 项仍属于 0C。 + +阶段 0A 仅被动观察;阶段 0C 采集宽字符绘制与 IME 坐标证据;阶段 0B 才允许在测试开关下按消息类型、虚拟键和 IME 状态执行隔离消费实验。三者是能力分类,编号不代表执行顺序,建议顺序为 0A → 0C → 0B。 + +### 18. 分阶段落地 + +#### 阶段 0A:被动探测 + +- 只记录消息和来源。 +- 不修改参数、旧窗口过程调用、返回值和业务分发。 +- 不为探测新增额外 EasyX `peekmessage`。 +- 输出版本化、隐私受控的 JSONL 日志。 + +#### 阶段 0C:宽字符绘制与 IME 坐标探测 + +- 验证 `TextOutW` / `DrawTextW` / `GetTextExtentPoint32W` 在主画面和 EasyX 工作图像上的行为。 +- 记录 `SetWorkingImage` 切换前后的真实目标 HDC。 +- 用固定非敏感测试串覆盖 ASCII、中文、代理对和组合字符。 +- 分离记录绘制结果、测量结果、像素证据和人工视觉结论。 +- 验证 DPI awareness、`GetDpiForWindow`、客户区/屏幕坐标和 TextBox caret anchor。 +- 覆盖 100%、125%、150% 和跨屏不同 DPI。 +- 采集 `COMPOSITIONFORM` / `CANDIDATEFORM` 定位证据。 +- 详细规格见 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。 + +#### 阶段 0B:隔离消费实验 + +使用测试专用规则: + +```cpp +struct ProbeConsumeRule +{ + UINT message; + std::optional virtualKey; + ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly + ProbeAction action; // ObserveAndForward / ObserveAndConsume +}; +``` + +- 每次只启用一条或一小组明确规则。 +- 日志携带实验 ID 和规则 ID。 +- 同步窗口协议禁止进入规则表。 +- 正式构建默认没有任何消费规则。 + +#### 阶段 1:InputCollector + +- 输入事件结构和 NativeInputQueue。 +- 按已确认双线程模型实现 SPSC 语义的跨线程发布;正确性版本先用短临界区互斥队列。 +- `sequence` 使用 atomic 分配,队列同步独立提供 happens-before。 +- KeyEvent 完整解析。 +- Unicode/ANSI 解码。 +- IME 状态机。 +- EasyX `EX_KEY` 排空但不业务路由。 + +#### 阶段 2:FocusManager + +- ControlHandle。 +- 焦点申请与回退。 +- Window/Dialog FocusScope。 +- PointerDown 申请焦点。 +- mutation 生命周期。 + +#### 阶段 3:KeyboardRouter + +- Tab 导航。 +- 句柄路由路径。 +- 冒泡和框架默认动作。 +- Button 和 Dialog 键盘行为。 + +#### 阶段 4:复合控件 + +- Table 翻页 Button。 +- TabControl roving tab stop。 +- 活动页面过滤和页签/页面路由隔离。 + +#### 阶段 5:TextBox + +- UTF-16 编辑模型。 +- 选区、光标、剪贴板。 +- Ctrl 快捷键。 +- 宽字符测量和自绘。 + +#### 阶段 6:IME 与 DPI 收口 + +- `GCS_RESULTSTR` 和回声去重。 +- 系统组合窗口、候选框定位。 +- 微软拼音、搜狗、DPI 和跨屏测试。 + +### 19. 已锁定决策 + +1. 输入采集先于焦点系统。 +2. v1 键盘默认采集并转发,不预设消费。 +3. NativeInputQueue 是唯一键盘业务源。 +4. EasyX `EX_KEY` 只排空和观察,不进入业务路由。 +5. v1 使用系统 IME UI,不自绘组合串。 +6. IME 正式提交优先使用 `GCS_RESULTSTR`。 +7. TextBox 内部使用 UTF-16。 +8. 绘制优先使用宽字符 GDI,不默认降级 CP936。 +9. 长期焦点、队列和遍历结果只使用 ControlHandle。 +10. 路由结果区分停止冒泡和阻止框架默认动作。 +11. TabControl 页签事件绝不传入活动页面。 +12. IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。 +13. 模态循环采用最内层单 drainer。 +14. TextBox v1 至少保证代理对安全,完整字素簇以后增强。 +15. `pendingLead` 在输入法/代码页切换前清除。 +16. `compositionId` 在 START 时分配,缺失 START 时惰性分配新 ID。 +17. Space KeyUp 与 Enter 首次 KeyDown 的触发差异属于有意交互语义。 +18. EasyX WndProc 是生产线程,宿主 `runEventLoop/peekmessage` 是消费线程;NativeInputQueue 必须跨线程同步。 +19. 当前 EasyX HWND 为 ANSI,DBCS 解码和 `WM_IME_CHAR/WM_CHAR` 回声路径属于阶段 1 必经路径。 +20. 阶段 1 先使用互斥 SPSC 队列;只有定义好变长文本所有权与满队列策略后才允许换成 lock-free ring。 +21. 当前微软拼音和搜狗均以 `GCS_RESULTSTR` 为正式提交、`WM_IME_CHAR` 为匹配回声;阶段 1 不得把回声再次入队。 +22. `WM_INPUTLANGCHANGEREQUEST` 只表示请求,不能提前更新代码页;只有 `WM_INPUTLANGCHANGE`,或挂接/重新获焦时确认 HKL 快照确已变化,才执行“清 pending lead → 更新代码页”。 +23. IME 内部中英文模式或 TSF 输入配置文件切换不保证产生 `WM_INPUTLANGCHANGE`,不得把该消息当作所有输入模式变化的统一通知。 +24. 当前环境只有 `zh-Hans-CN`,无法实测跨语言 HKL;阶段 1 在窗口挂接和重新获焦时必须用 HWND 属主线程的 `GetKeyboardLayout` 快照补偿潜在消息缺口。 + +## 实现与影响 + +- 关键实现点: + - 阶段 0A 探测器已经进入框架,FocusManager、生产 NativeInputQueue 和 TextBox 改造尚未开始。 + - 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束。 +- 涉及文件 / 类 / 函数: + - 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。 + - 实际文件和接口必须在阶段 0 与实现前只读检查后确定。 +- 兼容性影响: + - 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。 + - 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。 +- 性能影响: + - 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。 + - TextBox 宽字符测量、选区和水平滚动需要缓存策略,具体数据待实现阶段评估。 +- 风险点: + - EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。 + - 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。 + - 该文档是设计基线,不应被误读为已实现或已验证能力。 + +## 测试与验证 + +- 测试范围: + - 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。 + - 独立线程归属探测、0A 构建、固定消息 smoke 及微软拼音/搜狗真实输入法测试已完成;生产输入队列和焦点系统尚未开始。 +- 验证步骤: + +1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。 +2. 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。 +3. 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。 +4. 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。 + +- 验证结果: + - v0.4 设计经过多轮评审,已作为后续探测和实现的基线。 + - 2026-10-03:独立线程探针确认 EasyX WndProc/宿主循环双线程,当前 EasyX HWND 为 ANSI、中文代码页为 CP936。 + - 2026-10-04:微软拼音和搜狗真实结果均为 `GCS_RESULTSTR -> WM_IME_CHAR matched -> WM_CHAR expired`;日志隐私检查通过,丢失均按 `wndproc_lock_contention` 显式记账。 + - 2026-10-04:两次布局/模式切换未形成 `WM_INPUTLANGCHANGE`,并据此发现旧探测器未观察 `WM_INPUTLANGCHANGEREQUEST`;v0.6 已把请求与完成消息拆开。 + - 2026-10-04:request 补观测的探测开启构建与 1 轮 UI 回归通过,8 份日志均可解析、sequence 严格递增且无文字原值泄露;共 1 条 `wndproc_lock_contention` 被显式记账。 + - 2026-10-04:专用 Win+Space 短测产生 1243 条可解析记录,request/change 仍为 0;日志观察到 Win 键、焦点往返及 `VK_PROCESSKEY -> IME composition`,系统语言列表仅有 `zh-Hans-CN`,故本次结论限定为同 HKL 的 TSF 配置文件切换,跨语言 HKL 分支仍缺少实机样本。 + - 2026-10-04:最终源码的 Debug x64 单元测试 15/15 通过。 + - 2026-10-05:提交前重新执行独立线程探针、探测开启 UI 回归和探测关闭单元测试;再次确认 EasyX 双线程与 ANSI/CP936,8 份自动日志均严格递增、零文字原值泄露、零丢失,VSTest 15/15 通过。 +- 已知限制 / 遗留问题: + - §5.3 的最终消费白名单必须等待阶段 0B 数据。 + - §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。 + - 宽字符 GDI 与 DPI 坐标仍需实机验证,由阶段 0C 规格承接。 + +## 落地信息 + +- 关联功能变更 ID:无 +- 关联 BUG / Fix:无 +- Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复) +- PR:无 +- 发布版本:未发布 +- 相关文档: + - [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.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_GETDLGCODE](https://learn.microsoft.com/en-us/windows/win32/dlgbox/wm-getdlgcode) + - [WM_IME_SETCONTEXT](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-setcontext) + - [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition) + - [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw) + - [ImmNotifyIME](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immnotifyime) + - [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) diff --git a/开发记录/模块/Module-20261001-0002-阶段0A输入链路被动探测规格.md b/开发记录/模块/Module-20261001-0002-阶段0A输入链路被动探测规格.md new file mode 100644 index 0000000..911cfd4 --- /dev/null +++ b/开发记录/模块/Module-20261001-0002-阶段0A输入链路被动探测规格.md @@ -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-.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) diff --git a/开发记录/模块/Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md b/开发记录/模块/Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md new file mode 100644 index 0000000..55bc937 --- /dev/null +++ b/开发记录/模块/Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md @@ -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//manifest-v1.json +TestResults/VisualProbe//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)