Files
StellarX-kaifa/开发记录/模块/Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md
T

516 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 新增功能模块 / 模块重构
> 适用场景:新增模块、重大模块重构、核心架构能力演进。
> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
## 基本信息
- 模块 ID:Module-20261001-0003
- 模块名称:阶段 0C 宽字符绘制与 IME 坐标探测规格
- 状态:当前可用环境基线通过(0C-1/0C-2 自动基线通过;0C-3 微软拼音与搜狗真实定位通过;100%/125%/跨屏矩阵仍受实机条件限制)
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / GDI / IMM32 / DPI
- 版本 / 分支:`master`,本轮实现起点 `225bc35`,前置阶段 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
```
`BeginBatchDraw` 前后即使 HDC 值相同,其内选入的 bitmap 也可能变化,因此表面身份必须在“实际绘制发生的那一刻”采集。绘制矩阵必须覆盖批次状态:
```text
默认画面 / SetWorkingImage
×
批次外 / BeginBatchDraw 批次内
×
绘制后 / FlushBatchDraw 后 / EndBatchDraw 后
×
EasyX 绘制 / 宽字符 GDI 绘制
```
这样才能区分五种不同结果:
- API 返回成功但画到了错误 bitmap。
- 已进入离屏缓冲但尚未合成上屏。
- `FlushBatchDraw` 后才可见(合成链正确)。
- `EndBatchDraw` 后被后续 EasyX 绘制覆盖。
- 工作图像正确但屏幕合成失败。
主画面和工作图像分别使用不同的固定哨兵底色,并按以下步骤验证:
1. `SetWorkingImage` 切换前后分别采集 HDC、selected bitmap 和 GDI 状态。
2. 对目标表面与非目标表面记录绘制前像素摘要。
3. 调用 `SaveDC`,设置受控字体、颜色、背景模式、mapping mode 和 clip。
4. 使用 W 版本 API 绘制唯一的固定测试图案和测试串。
5. 从目标表面回读像素,验证目标发生预期变化。
6. 从非目标表面回读相同区域,验证没有串画。
7. 调用 `RestoreDC`,重新采集状态并验证未污染 EasyX 后续绘制。
8. 记录 `FlushBatchDraw` 前、后以及 `EndBatchDraw` 后的屏幕证据,区分“已进离屏未合成”与“合成后可见”。
9. 在 `EndBatchDraw` 后对同一区域用 EasyX 再绘制一次并回读,确认宽字符 GDI 结果是否被后续 EasyX 绘制覆盖。
实测还确认了一个 EasyX 特有的调用顺序约束:不得在 `SetWorkingImage(&working)` 保持为当前目标时直接进入 `BeginBatchDraw()`;本轮隔离运行中该顺序稳定触发 `0xC0000005`。探测器和后续框架应使用:
```text
SetWorkingImage(nullptr)
BeginBatchDraw()
SetWorkingImage(&working) // 只在批次内部切换工作图像
...绘制...
SetWorkingImage(nullptr) // Flush/End 前回到主画面
FlushBatchDraw()/EndBatchDraw()
```
这不是“工作图像不能在批次内绘制”,而是“批次必须从主画面安全进入和退出”。工作图像上的宽字符结果仍需通过显式 `putimage` 合成到主画面。
宽字符能力与字体覆盖分开判定:
- `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 输入中间态。
- 额外记录:`GetMapMode`、viewport/window origin 与 extent、当前 graphics mode / world transform、`GetClientRect`、`GetWindowRect`,以及 `ClientToScreen → ScreenToClient` 的往返误差。
验证 EasyX 是否隐式缩放时,不只画文字,而是在同一组数值坐标上分别绘制:
```text
EasyX 十字和矩形
原生 GDI 十字和矩形
EasyX 文字基线
TextOutW 文字基线
```
然后通过像素 bounding box 比较位置和尺寸,区分“坐标相同但字体度量不同”与“整个绘图坐标系发生缩放”。
规则:只有实测发现 EasyX 坐标空间与目标 HWND 坐标空间不一致时,才引入显式换算;否则直接使用,避免双重缩放。
DPI awareness 不得在窗口创建后临时切换;需要比较不同 awareness 模式时各自启动独立进程,否则结论易受 Windows 虚拟化状态污染。
验收预期必须服从实际 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 返回值
```
IME 位置采用“双时机写入”,不得假定所有输入法都会先向目标 HWND 发送相同的组合生命周期消息:
1. 焦点、caret 或合成 anchor 变化时,立即对当前 IME context 调用 `ImmSetCompositionWindow/ImmSetCandidateWindow`,为下一次组合预设位置。
2. 收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` 时使用最新 anchor 再校准一次。
3. 两类调用必须分别记录 `positionTrigger`、调用次数与成功次数;预设调用成功不能代替真实输入和视觉验收。
4. 诊断程序至少还要有一次真实 IME 生命周期定位事件完成 context 获取与两个 setter,才允许自动运行整体完成;仅切换三个 anchor 或只观察到失败的生命周期调用不得伪造通过。
测试至少覆盖微软拼音和搜狗输入法,并记录输入法标识、版本(可取得时)、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
TestResults/VisualProbe/<sessionId>/manual-evidence/*.png
TestResults/VisualProbe/<sessionId>/manual-review-v1.json
```
`visualProbeSchemaVersion` 初始为 1。manifest 必须包含:
| 字段 | 必需 | 说明 |
|---|---:|---|
| `visualProbeSchemaVersion` | 是 | 初始为 1 |
| `sessionId` | 是 | 本次 0C 会话 UUID |
| `startedAtUtc` | 是 | UTC 时间 |
| `processId/threadId` | 是 | 进程和 GUI 线程 |
| `osBuild/easyxVersion` | 是 | 能取得的运行环境版本 |
| `testCaseId` | 是 | 矩阵中唯一用例 ID |
| `testStringId/codePoints/utf16Units` | 绘制用例 | 固定测试串身份与实际构造结果 |
| `surfaceId` | 绘制用例 | `main` 或指定 working image |
| `batchState` | 绘制用例 | `outside / inside`,描述绘制发生时是否位于批次括号内 |
| `observationPhase` | 绘制用例 | `after_draw / after_flush / after_end`;不适用的组合写 `not_applicable` |
| `hdc/hbitmap/bitmapWidth/bitmapHeight/planes/bitsPerPixel/stride/dibSection/hfont/logFont` | 绘制用例 | 表面和字体指纹;无法从 DDB 稳定取得的字段允许写 `not_available` |
| `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 用例 | 输入法身份 |
| `positionTrigger/compositionActiveAtAttempt` | IME 用例 | 区分 anchor 预设与 IME 生命周期校准,并记录调用时是否处于组合态 |
| `preconfigureApiAttemptCount/lifecycleApiAttemptCount` | IME 用例 | 两类定位调用及其成功次数,避免把不同触发来源混为一类 |
| `positionDisposition` | IME 用例 | `as_requested/system_adjusted/not_observable` |
| `automatedStatus` | 是 | `pass/fail/inconclusive/not_available` |
| `manualStatus` | 是 | `pass/fail/inconclusive/not_reviewed/not_available` |
| `sourceManualReview/manualReview` | 有人工评审时 | 评审旁车路径与哈希、实际输入法身份来源、评审时间和摘要 |
| `failureCategory` | 否 | 失败分类,不得只写自由文本 |
| `evidenceFiles` | 是 | 相对路径、用途、SHA-256、裁剪区域 |
| `notes` | 否 | 补充说明,不能替代结构化结论 |
截图只能包含探测窗口和固定非敏感测试内容;不得截取其他应用或真实用户输入。每个 evidence 文件记录 SHA-256,防止结果文件和图片错配。
人工结论通过独立 `manual-review-v1.json` 输入 analyzer,不直接手改生成的 manifest。analyzer 必须验证 review 覆盖所有用例、状态和位置枚举合法、`pass` 用例具有明确的 `manual-evidence` 文件、人工通过不覆盖自动失败,并校验总体状态可由逐用例状态推导;任一条件不满足即拒绝生成 manifest。
`failureCategory` 只允许以下初始枚举;新增类别必须提升或兼容扩展 schema:
```text
surface_unreachable
surface_cross_write
surface_not_composited
screen_composite_failed
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 内部原文。
### 10. 运行模式与进程隔离
0C-1 与 0C-2 可以并行开发和独立运行,但不得在同一进程内同时探测:EasyX 工作图像、批次状态和 DPI awareness 都带有进程级或全局状态,混在一次运行中会互相污染。
同一诊断工程提供独立模式,共享 manifest、截图和固定测试串基础设施,但每种模式独立启动进程:
```text
--mode wide-text
--mode dpi-coordinate
--mode ime-position
```
`--auto-close-ms` 仅用于不输入文字的 0C-3 生命周期烟测,正式人工运行不得启用;否则三个 anchor 不会产生真实 IME 位置证据,结果应为失败而不是伪通过。
### 11. 0C 基线产物与 0B 引用契约
0C 完成后必须生成可哈希的基线 manifest(绘制基线 + 坐标基线),并计算其 SHA-256。0B 的每轮实验结果必须强制记录:
```text
drawingBaselineManifest
coordinateBaselineManifest
drawingBaselineSha256
coordinateBaselineSha256
combinedBaselineId
dpiAwareness
dpiForWindow
imeIdentity
hkl
```
如果 0B 运行环境与 0C 基线不一致,该轮 0B 的候选框结论只能标记为 `inconclusive`,不得判定消费策略通过或失败。
## 实现与影响
- 关键实现点:
- 新增不链接 StellarX Core 的 `EasyX.VisualProbe` 独立诊断工程,避免框架 WndProc、控件或事件循环污染 EasyX 原始行为。
- `wide-text` 覆盖主画面/工作图像 × 批次外/批次内,构造五类固定 Unicode scalar 测试串,验证 W API、目标/非目标表面、GDI 状态恢复、Flush/End 合成和后续 EasyX 绘制兼容性。
- `dpi-coordinate` 在同一数值坐标上绘制 EasyX/GDI 矩形、十字和文本,记录 mapping、DPI awareness、像素 bounds 与客户区/屏幕往返误差。
- `ime-position` 提供三个合成 caret anchor;按总设计 v1 契约使用 `COMPOSITIONFORM/CFS_POINT` 与 `CANDIDATEFORM/CFS_EXCLUDE + caretLineRect`;anchor 变化时先预设位置,IME 生命周期消息到达时再校准;子类化分支只记录消息阶段、触发来源、anchor、HKL/IME 身份与 IMM API 返回值,不记录组合串或候选文字。
- 客户区截图优先使用 `GetDC(hwnd) + BitBlt`,只有失败才回退 `PrintWindow`,并把实际捕获后端写入 manifest,避免 `WM_PRINT` 扰动 Flush 前观察。
- PowerShell runner 为每个模式启动独立进程;analyzer 计算原始结果、manifest 和每张 PNG 的 SHA-256,并生成 0B 可引用的组合基线 ID。
- 执行顺序:0C-1(宽字符/批次绘制)与 0C-2(DPI/坐标)可并行 → 0C-3(真实 IME 定位)→ 回写带哈希的 0C 基线 → 0B 单消息消费实验 → 阶段 1 输入采集与规范化。
- 实现时根据 Unicode scalar 列表构造固定测试串,使用合成 anchor、预定义采样区域和主/工作图像双表面哨兵,不依赖用户输入或真实 TextBox。
- 实现结果由版本化 manifest 和独立 PNG 证据组成,不复用 0A 键盘 JSONL schema。
- 涉及文件 / 类 / 函数:
- `tests/EasyX.VisualProbe.vcxproj`
- `tests/visual-probe/VisualProbeCommon.h/.cpp`
- `tests/visual-probe/WideTextProbe.cpp`
- `tests/visual-probe/DpiCoordinateProbe.cpp`
- `tests/visual-probe/ImePositionProbe.cpp`
- `tests/visual-probe/VisualProbeMain.cpp`
- `scripts/run-visual-probe.ps1`
- `scripts/analyze-visual-probe.ps1`
- `imGui-easyX.sln`
- 兼容性影响:
- 探测关闭时不应产生文件、线程、定时器或公共 API 变化。
- 性能影响:
- 仅测试构建开启,对正式路径无影响。
- 风险点:
- 视觉结论依赖人眼,必须与表面指纹、目标/非目标像素摘要和截图证据相互印证。
- 跨屏 DPI 依赖真实多显示器环境,CI 环境可能不具备,需人工实测。
- HDC handle 可能复用,若不同时记录 selected bitmap 和 GDI 状态,可能得到错误的表面身份结论。
- IME 可能主动调整候选窗口,必须在安全 anchor、不同 IME 和实际 awareness 下区分系统调整与框架换算错误。
## 测试与验证
- 测试范围:
- Debug x64 独立工程构建。
- 0C-1 四组表面/批次矩阵、五类固定测试串、30 张 PNG 证据与 40 个结构化 manifest 用例。
- 0C-2 当前生产 awareness(inherit/unaware)和独立 Per-Monitor-V2 进程下的坐标、像素与 DPI 证据。
- 0C-3 自动关闭失败门槛烟测,以及微软拼音/搜狗真实候选框定位与搜狗提交后 stale-position 回归。
- 验证步骤:
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 定位集成验证。
当前机器没有 `pwsh`,人工 0C-3 使用 Windows PowerShell,分别执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-visual-probe.ps1 -Mode ime-position -ImeName MicrosoftPinyin
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-visual-probe.ps1 -Mode ime-position -ImeName Sogou
```
每轮依次用 F1/F2/F3 选择三个 anchor,只输入固定非敏感串 `ceshi`,观察候选框后按 Escape 取消。随后在 F3 提交一次固定候选,再切回 F1 输入 `ceshi`,确认没有沿用 F3 旧位置。要关闭 0C-3,必须在脚本打印的 `manual-evidence` 目录保存三个 anchor 的紧裁剪截图,并为提交后回到 F1 的回归场景再保存一张截图;建议文件名分别以 `anchor_top_left_safe_`、`anchor_center_`、`anchor_bottom_right_safe_` 开头。没有截图时即使 IMM API 自动状态通过,人工状态仍保持 `not_reviewed`。
- 验证结果:
- 2026-10-05:`EasyX.VisualProbe` Debug x64、Release x64、Debug Win32 构建通过;仅有 EasyX 25.9.10 头文件自身的 C4201 匿名联合警告。解决宿主同时存在 `PATH/Path` 时,构建必须沿用干净子进程隔离,否则会触发环境导致的 MSB6001。
- 2026-10-05:最终自动组合基线位于忽略目录 `TestResults/VisualProbe/20261005-025951-856-06d08ac1-58fa-426f-b4c8-803d246b47dd`,`sessionId=06d08ac1-58fa-426f-b4c8-803d246b47dd`,自动状态为 `pass`,人工状态为 `not_reviewed`。
- 绘制 manifest SHA-256:`BCD0E3DD5D90C5D6F892F9127D81FD124C1DC7D619260B236DBCB8E53CBEC9CA`;坐标 manifest SHA-256:`35D15BF2DD54D9A57B1F8470C8415025F1BAE6CE98D4628F13B64AB1EC381000`;组合基线 ID:`sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5`。
- 0C-1:`TextOutW`、`DrawTextW`、`GetTextExtentPoint32W` 在主画面和工作图像、批次内外全部成功;目标表面变化、非目标表面不变、GDI 状态恢复;主画面批次内在 Flush 前不可见、Flush 后可见;工作图像不会自行上屏,经 `putimage + FlushBatchDraw` 后正确合成;End 后追加 EasyX 绘制未改变受保护文字区域的像素摘要。
- 0C-1 截图复核可见 BMP 中文、代理对 emoji 的单色 glyph、NFD 组合字符和混排结果;这属于模型辅助复核,不能替代规格要求的人类视觉签字,因此 manifest 仍保持 `manualStatus=not_reviewed`。
- 0C-2:当前程序继承模式为 DPI-unaware,`GetDpiForWindow=96`;在同一台实际 150% 显示器上,以独立 Per-Monitor-V2 进程运行得到 `GetDpiForWindow=144`。两种 awareness 下 EasyX 与原生 GDI 的像素 bounds 均与客户区一致,四个 `ClientToScreen -> ScreenToClient` 往返误差均为 0,未观察到 EasyX 自身额外隐式缩放。
- Per-Monitor-V2 补充 manifest SHA-256:`091E446F77BD997428452979A0AF122883129497104035F2688A3535C1A191E1`;该结果只覆盖当前 144 DPI 显示器,不代表 100%/125% 或跨屏矩阵已完成。
- 0C-3 无输入自动关闭烟测按预期生成三个 anchor 客户区证据与可解析 manifest,并因没有真实 IME API 事件返回失败;这证明失败门槛没有被烟测绕过,不构成候选框定位通过证据。
- 2026-10-05:微软拼音在三个 anchor 上的 IMM 调用均成功,人工截图未观察到相对 caret 的整体偏移;两轮实际都使用微软拼音,第二轮脚本参数 `Sogou` 仅为预期标签,不得当作搜狗证据。
- 2026-10-05 23:51:首次真实搜狗定位运行 `20261005-235150-704-4e01df99-019b-41bd-b8ea-b2ff508626e0` 按门槛正确失败。F1/F2 的 IMM API 尝试均为 0,F3 为 2/2;人工截图显示 F1/F2 候选框位于窗口外,首次获得 F3 生命周期定位后又沿用 F3 旧位置。
- 2026-10-06:确认根因是探测器只在 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` 到达后设置位置。已改为 anchor 变化立即预设、生命周期消息再次校准,并增加触发来源及分项计数;三平台构建与搜狗真实复验均已完成。
- 2026-10-06:修复后搜狗运行 `20261006-010231-219-3fc5186a-f494-4d88-8988-67d80b25ed7e` 自动与人工状态均为 `pass`。F1 预设 2/2、F2 预设 1/1、F3 预设 1/1 且生命周期校准 2/2;提交后回到 F1 的截图与初始 F1 裁剪字节一致,证明没有沿用 F3 stale position。
- 成功搜狗 manifest SHA-256:`4908B1F9B5C29555216EF62BE64F2F6780F7EF213D8E1ECE16817B5915FF9AED`;人工评审旁车 SHA-256:`94459F19A3F74E46C23322E6276C03B3AF054D6293DA4D090220FF06470A32A3`;四张人工证据和三张自动 anchor 图均已进入 manifest,`evidenceCount=7`。
- 已知限制 / 遗留问题:
- 当前可用环境下微软拼音与搜狗的三个 anchor 已完成真实视觉验收,搜狗“提交一次后再次切换”回归也已通过;0C-3 当前环境门槛关闭。
- 当前 `-ImeName` 只是操作者预期标签;同一中文 HKL 下的 TSF 配置文件切换不能由现有 HKL/IMM32 身份字段自动证明,最终身份需保留操作者确认边界。
- 当前只覆盖同一台 150% 显示器上的 DPI-unaware 虚拟化视图(96 DPI)和 Per-Monitor-V2 物理视图(144 DPI);100%、125% 与跨屏不同 DPI 仍依赖实机环境。
- 字体回退行为与 EasyX 字体设置的耦合需要实测确认。
- 0C 使用合成 anchor,只验证坐标转换和 IMM 调用链;真实 TextBox anchor 必须在阶段 6 复验。
- 某些 IME 不显示独立组合窗口或会主动调整候选框,允许用例得到 `not_available`、`system_adjusted` 或 `inconclusive`,但必须保留证据。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:无
- Commit:`43cf4ac`(实现起点 `225bc35`)
- 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)