2 Commits

Author SHA1 Message Date
Codex 18caf91633 docs: 收口阶段 0B 输入消费结论 2026-10-07 04:09:36 +08:00
Codex 0db94a7d41 feat(diagnostic): 完成阶段 0B 原生键盘隔离消费探测 2026-10-07 04:06:59 +08:00
13 changed files with 2616 additions and 83 deletions
+210 -4
View File
@@ -1,4 +1,4 @@
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Path [string]$Path
) )
@@ -39,7 +39,183 @@ $textLeaks = @($records | Where-Object {
$_.messageCategory -eq 'text' -and $_.wParamHex -ne $null $_.messageCategory -eq 'text' -and $_.wParamHex -ne $null
}) })
$lost = @($records | Where-Object recordType -eq 'records_lost') $lost = @($records | Where-Object recordType -eq 'records_lost')
$lostTotal = 0
foreach ($lostRecord in $lost) {
if ($null -ne $lostRecord.lostCount) {
$lostTotal += [int]$lostRecord.lostCount
}
else {
$lostTotal++
}
}
$attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1 $attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1
$closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0
$sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0
$experimentConfigs = @($records | Where-Object recordType -eq 'experiment_config')
if ($experimentConfigs.Count -gt 1) {
throw "More than one experiment_config record was found: $($experimentConfigs.Count)"
}
$experimentConfig = $experimentConfigs | Select-Object -First 1
$experimentTargetEnters = @()
$experimentTargetLeaves = @()
$experimentEnters = @()
$experimentLeaves = @()
$matchingEasyX = @()
$easyxVisibility = $null
$experimentValidity = 'not_configured'
$imePositionAttempts = @()
$imePositionSuccesses = @()
$imePositionResultRecords = @()
$imePositionOrphans = @()
$runtimeFailOpenEnters = @()
$runtimeFailOpenLeaves = @()
$runtimeFailOpenReason = $null
if ($experimentConfig) {
$schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
$experimentTargetEnters = @($records | Where-Object {
$_.recordType -eq 'wndproc_enter' -and
$_.experimentId -eq $experimentConfig.experimentId -and
$_.ruleId -eq $experimentConfig.ruleId
})
$targetCallIds = @($experimentTargetEnters | Select-Object -ExpandProperty wndProcCallId)
$experimentTargetLeaves = @($records | Where-Object {
$_.recordType -eq 'wndproc_leave' -and
$targetCallIds -contains $_.wndProcCallId
})
$runtimeFailOpenEnters = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true)
$runtimeFailOpenCallIds = @($runtimeFailOpenEnters | Select-Object -ExpandProperty wndProcCallId)
$runtimeFailOpenLeaves = @($experimentTargetLeaves | Where-Object {
$runtimeFailOpenCallIds -contains $_.wndProcCallId -and $_.failOpenApplied -eq $true
})
$runtimeFailOpenRecords = @($runtimeFailOpenEnters) + @($runtimeFailOpenLeaves)
$runtimeFailOpenReasons = @($runtimeFailOpenRecords |
Select-Object -ExpandProperty failOpenReason -Unique)
if ($runtimeFailOpenReasons.Count -eq 1) {
$runtimeFailOpenReason = [string]$runtimeFailOpenReasons[0]
}
$experimentEnters = @($experimentTargetEnters | Where-Object ruleMatched -eq $true)
$matchedCallIds = @($experimentEnters | Select-Object -ExpandProperty wndProcCallId)
$experimentLeaves = @($records | Where-Object {
$_.recordType -eq 'wndproc_leave' -and
$matchedCallIds -contains $_.wndProcCallId
})
$easyXMessageType = if ([string]$experimentConfig.targetMessage -match 'UP$') {
'WM_KEYUP'
}
else {
'WM_KEYDOWN'
}
$targetScanSpecified = $null -ne $experimentConfig.targetScanCode
$matchingEasyX = @($records | Where-Object {
$_.recordType -eq 'easyx_key' -and
$_.easyxMessageType -eq $easyXMessageType -and
[int]$_.easyxVkCode -eq [int]$experimentConfig.targetVirtualKey -and
(-not $targetScanSpecified -or
[int]$_.easyxScanCode -eq [int]$experimentConfig.targetScanCode)
})
$imePositionResultRecords = @($records | Where-Object recordType -eq 'ime_position')
# 兼容本修复前把定位结果直接挂在 wndproc_enter 上的 schema v2 日志。
$legacyImePositionAttempts = @($records | Where-Object {
$_.recordType -eq 'wndproc_enter' -and $_.imePositionAttempted -eq $true
})
$imePositionAttempts = @($imePositionResultRecords) + @($legacyImePositionAttempts)
$imePositionSuccesses = @($imePositionAttempts | Where-Object {
$_.imePositionContextAvailable -eq $true -and
$_.compositionPositionSucceeded -eq $true -and
$_.candidatePositionSucceeded -eq $true
})
$sequenceLookup = @{}
foreach ($entry in $records) { $sequenceLookup[[string]$entry.sequence] = $true }
$imePositionOrphans = @($imePositionResultRecords | Where-Object {
$null -eq $_.parentSequence -or
-not $sequenceLookup.ContainsKey([string]$_.parentSequence) -or
[uint64]$_.parentSequence -ge [uint64]$_.sequence
})
$easyxVisibility = if ($matchingEasyX.Count -eq 0) {
'absent'
}
elseif ($matchingEasyX.Count -eq 1) {
'present_once'
}
else {
'present_multiple'
}
if ($schemaVersions.Count -ne 1 -or [int]$schemaVersions[0] -ne 2) {
$experimentValidity = 'invalid_schema'
}
elseif (-not $sequenceStrict) {
$experimentValidity = 'inconclusive_sequence'
}
elseif ($textLeaks.Count -gt 0) {
$experimentValidity = 'inconclusive_text_leak'
}
elseif ($lostTotal -gt 0) {
$experimentValidity = 'inconclusive_capture_loss'
}
elseif ($imePositionOrphans.Count -gt 0) {
$experimentValidity = 'inconclusive_ime_position_link'
}
elseif (-not $closeObserved) {
$experimentValidity = 'inconclusive_close_missing'
}
elseif (-not $sessionEndObserved) {
$experimentValidity = 'inconclusive_session_end_missing'
}
elseif (-not $experimentConfig.configurationValid) {
$experimentValidity = 'invalid_configuration'
}
elseif (-not $experimentConfig.ruleEligible) {
$experimentValidity = 'ineligible_fail_open'
}
elseif ([string]$experimentConfig.inputSource -eq 'Manual' -and
(-not $experimentConfig.imePositionConfigured -or $imePositionSuccesses.Count -eq 0)) {
$experimentValidity = 'inconclusive_ime_position'
}
elseif ($experimentTargetEnters.Count -ne [int]$experimentConfig.maxMatches) {
$experimentValidity = 'unexpected_target_count'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
$experimentEnters.Count -eq 0 -and
$runtimeFailOpenEnters.Count -eq [int]$experimentConfig.maxMatches -and
$runtimeFailOpenLeaves.Count -eq $runtimeFailOpenEnters.Count -and
$runtimeFailOpenReasons.Count -eq 1 -and
-not [string]::IsNullOrWhiteSpace($runtimeFailOpenReason) -and
@($runtimeFailOpenLeaves | Where-Object {
$_.actualAction -ne 'ObserveAndForward' -or $_.forwarded -ne $true
}).Count -eq 0) {
$experimentValidity = 'runtime_fail_open'
}
elseif ($experimentEnters.Count -ne [int]$experimentConfig.maxMatches) {
$experimentValidity = 'unexpected_match_count'
}
elseif ($experimentLeaves.Count -ne $experimentEnters.Count) {
$experimentValidity = 'missing_leave_record'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndConsume').Count -gt 0) {
$experimentValidity = 'consume_action_mismatch'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object forwarded -eq $true).Count -gt 0) {
$experimentValidity = 'consume_was_forwarded'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object forwardResultHex -ne '0x0000000000000000').Count -gt 0) {
$experimentValidity = 'consume_result_nonzero'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndForward').Count -gt 0) {
$experimentValidity = 'forward_action_mismatch'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
@($experimentLeaves | Where-Object forwarded -ne $true).Count -gt 0) {
$experimentValidity = 'forward_was_not_forwarded'
}
else {
$experimentValidity = 'valid'
}
}
$correlationCounts = [ordered]@{} $correlationCounts = [ordered]@{}
foreach ($entry in $echoSummaries) { foreach ($entry in $echoSummaries) {
@@ -51,8 +227,10 @@ foreach ($entry in $echoSummaries) {
[pscustomobject]@{ [pscustomobject]@{
file = $file.FullName file = $file.FullName
recordCount = $records.Count recordCount = $records.Count
schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
sequenceStrict = $sequenceStrict sequenceStrict = $sequenceStrict
recordsLost = $lost.Count recordsLost = $lostTotal
lossRecordCount = $lost.Count
textRawWParamLeaks = $textLeaks.Count textRawWParamLeaks = $textLeaks.Count
isWindowUnicode = $attach.isWindowUnicode isWindowUnicode = $attach.isWindowUnicode
ansiCodePage = $attach.ansiCodePage ansiCodePage = $attach.ansiCodePage
@@ -72,6 +250,34 @@ foreach ($entry in $echoSummaries) {
successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount) successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount)
digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count
correlationCounts = [pscustomobject]$correlationCounts correlationCounts = [pscustomobject]$correlationCounts
closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0 closeObserved = $closeObserved
sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0 sessionEndObserved = $sessionEndObserved
experimentConfigured = $null -ne $experimentConfig
experimentId = if ($experimentConfig) { $experimentConfig.experimentId } else { $null }
ruleId = if ($experimentConfig) { $experimentConfig.ruleId } else { $null }
ruleEligible = if ($experimentConfig) { [bool]$experimentConfig.ruleEligible } else { $null }
experimentFailOpenReason = if ($experimentConfig) { $experimentConfig.failOpenReason } else { $null }
runtimeFailOpenReason = $runtimeFailOpenReason
requestedAction = if ($experimentConfig) { $experimentConfig.requestedAction } else { $null }
targetMessage = if ($experimentConfig) { $experimentConfig.targetMessage } else { $null }
targetVirtualKey = if ($experimentConfig) { $experimentConfig.targetVirtualKey } else { $null }
targetScanCode = if ($experimentConfig) { $experimentConfig.targetScanCode } else { $null }
baselineId = if ($experimentConfig) { $experimentConfig.baselineId } else { $null }
targetObservationCount = $experimentTargetEnters.Count
targetLeaveCount = $experimentTargetLeaves.Count
failOpenObservationCount = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true).Count
failOpenForwardedCount = @($experimentTargetLeaves | Where-Object {
$_.failOpenApplied -eq $true -and $_.forwarded -eq $true
}).Count
ruleMatchCount = $experimentEnters.Count
consumedMatchCount = @($experimentEnters | Where-Object actualAction -eq 'ObserveAndConsume').Count
forwardedMatchCount = @($experimentLeaves | Where-Object forwarded -eq $true).Count
matchingEasyXKeyCount = $matchingEasyX.Count
easyxVisibility = $easyxVisibility
imePositionAttemptCount = $imePositionAttempts.Count
imePositionSuccessCount = $imePositionSuccesses.Count
imePositionReady = $imePositionSuccesses.Count -gt 0
imePositionResultRecordCount = $imePositionResultRecords.Count
imePositionOrphanCount = $imePositionOrphans.Count
experimentValidity = $experimentValidity
} }
@@ -0,0 +1,41 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet('MicrosoftPinyin', 'Sogou')]
[string]$ImeName,
[Parameter(Mandatory = $true)]
[ValidateSet('ObserveAndForward', 'ObserveAndConsume')]
[string]$Action,
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
[string]$Message = 'WM_KEYDOWN',
[ValidateRange(1, 255)]
[int]$VirtualKey = 229,
[ValidateRange(-1, 255)]
[int]$ScanCode = 1,
[string]$OperatorKeyLabel = 'Escape',
[ValidateSet('IdleOnly', 'ComposingOnly')]
[string]$ImeCondition = 'ComposingOnly',
[ValidateSet('none', 'ime_condition_not_met')]
[string]$ExpectedFailOpenReason = 'none',
[string]$BaselineSet,
[switch]$ConfirmImeIdentity
)
$ErrorActionPreference = 'Stop'
$runner = Join-Path $PSScriptRoot 'run-input-consume-probe.ps1'
$arguments = @{
Action = $Action
Message = $Message
VirtualKey = $VirtualKey
ScanCode = $ScanCode
OperatorKeyLabel = $OperatorKeyLabel
InputSource = 'Manual'
ImeCondition = $ImeCondition
ImeName = $ImeName
ConfirmImeIdentity = [bool]$ConfirmImeIdentity
ExpectedFailOpenReason = $ExpectedFailOpenReason
}
if ($BaselineSet) {
$arguments.BaselineSet = $BaselineSet
}
& $runner @arguments
+576
View File
@@ -0,0 +1,576 @@
param(
[ValidateSet('Both', 'ObserveAndForward', 'ObserveAndConsume')]
[string]$Action = 'Both',
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
[string]$Message = 'WM_KEYDOWN',
[ValidateRange(1, 255)]
[int]$VirtualKey = 0x87,
[ValidateRange(-1, 255)]
[int]$ScanCode = -1,
[string]$OperatorKeyLabel,
[string]$BaselineSet,
[ValidateRange(1, 65535)]
[int]$ExpectedAnsiCodePage = 936,
[string]$RuleBaselineId,
[ValidateSet('none', 'baseline_id_mismatch', 'ime_condition_not_met')]
[string]$ExpectedFailOpenReason = 'none',
[ValidateSet('SendInput', 'Manual')]
[string]$InputSource = 'SendInput',
[ValidateSet('IdleOnly', 'ComposingOnly')]
[string]$ImeCondition = 'IdleOnly',
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
[string]$ImeName = 'unspecified',
[switch]$ConfirmImeIdentity
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj'
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
$expectedBaselineId = 'sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5'
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
if ($ExpectedFailOpenReason -ne 'none' -and $Action -ne 'ObserveAndConsume') {
throw 'A fail-open negative run must request only ObserveAndConsume'
}
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and -not $RuleBaselineId) {
throw 'A baseline mismatch run must pass -RuleBaselineId with an intentionally different value'
}
if ($InputSource -eq 'SendInput' -and $ImeCondition -ne 'IdleOnly') {
throw 'Automatic SendInput runs are limited to IdleOnly; Composition requires real manual IME input'
}
if ($InputSource -eq 'Manual' -and $Action -eq 'Both') {
throw 'Manual runs must select one action so each process contains exactly one operator exercise'
}
if ($InputSource -eq 'Manual' -and ($ImeName -eq 'unspecified' -or -not $ConfirmImeIdentity)) {
throw 'Manual runs require -ImeName and the explicit -ConfirmImeIdentity switch'
}
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and $InputSource -ne 'SendInput') {
throw 'The baseline mismatch negative check uses the controlled SendInput path'
}
if ($ExpectedFailOpenReason -eq 'ime_condition_not_met' -and
($InputSource -ne 'Manual' -or $ImeCondition -ne 'ComposingOnly')) {
throw 'The IME condition runtime fail-open check requires Manual + ComposingOnly'
}
$effectiveScanCode = if ($ScanCode -lt 0) { 256 } else { $ScanCode }
$operatorKeyDisplay = if ([string]::IsNullOrWhiteSpace($OperatorKeyLabel)) {
"VK=$VirtualKey"
}
else {
$OperatorKeyLabel.Trim()
}
function Get-Sha256Text([string]$text) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($text)
return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '')
}
finally {
$sha.Dispose()
}
}
function Resolve-BaselineSet([string]$requestedPath) {
if ($requestedPath) {
return (Resolve-Path -LiteralPath $requestedPath).Path
}
$candidates = @(Get-ChildItem -LiteralPath (Join-Path $root 'TestResults\VisualProbe') `
-Filter 'baseline-set-v1.json' -File -Recurse -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending)
foreach ($candidate in $candidates) {
try {
$candidateValue = Get-Content -Raw -LiteralPath $candidate.FullName | ConvertFrom-Json
if ([string]$candidateValue.combinedBaselineId -eq $expectedBaselineId) {
return $candidate.FullName
}
}
catch {
# 无效历史产物不参与当前基线选择,继续寻找下一个可解析候选。
continue
}
}
throw 'No matching 0C baseline-set-v1.json was found; pass -BaselineSet explicitly'
}
function Read-VerifiedBaseline([string]$path) {
$resolvedPath = (Resolve-Path -LiteralPath $path).Path
$sidecarPath = $resolvedPath + '.sha256'
if (-not (Test-Path -LiteralPath $sidecarPath)) { throw "Baseline sidecar missing: $sidecarPath" }
$actualBaselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPath).Hash
$sidecarHash = ((Get-Content -Raw -LiteralPath $sidecarPath).Trim() -split '\s+')[0].ToUpperInvariant()
if ($actualBaselineHash -ne $sidecarHash) { throw 'Baseline-set SHA-256 does not match its sidecar' }
$baseline = Get-Content -Raw -LiteralPath $resolvedPath | ConvertFrom-Json
if ([int]$baseline.visualProbeBaselineSchemaVersion -ne 1) { throw 'Unsupported visual baseline schema' }
if ([string]$baseline.automatedStatus -ne 'pass') { throw '0C baseline automatedStatus is not pass' }
$directory = Split-Path -Parent $resolvedPath
$drawingPath = Join-Path $directory ([string]$baseline.drawingBaselineManifest)
$coordinatePath = Join-Path $directory ([string]$baseline.coordinateBaselineManifest)
if (-not (Test-Path -LiteralPath $drawingPath)) { throw "Drawing baseline missing: $drawingPath" }
if (-not (Test-Path -LiteralPath $coordinatePath)) { throw "Coordinate baseline missing: $coordinatePath" }
$drawingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $drawingPath).Hash
$coordinateHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $coordinatePath).Hash
if ($drawingHash -ne [string]$baseline.drawingBaselineSha256) { throw 'Drawing baseline SHA-256 mismatch' }
if ($coordinateHash -ne [string]$baseline.coordinateBaselineSha256) { throw 'Coordinate baseline SHA-256 mismatch' }
$combined = 'sha256:' + (Get-Sha256Text "$drawingHash`:$coordinateHash")
if ($combined -ne [string]$baseline.combinedBaselineId) { throw 'Combined baseline ID mismatch' }
if ($combined -ne $expectedBaselineId) { throw "Unsupported 0C baseline ID: $combined" }
$coordinate = Get-Content -Raw -LiteralPath $coordinatePath | ConvertFrom-Json
$awarenessMap = @{ unaware = 0; system = 1; 'per-monitor' = 2; 'per-monitor-v2' = 2 }
$awarenessName = [string]$baseline.dpiAwareness
if (-not $awarenessMap.ContainsKey($awarenessName)) {
throw "Unsupported DPI awareness in baseline: $awarenessName"
}
[pscustomobject]@{
path = $resolvedPath
sha256 = $actualBaselineHash
combinedBaselineId = $combined
dpiForWindow = [int]$baseline.dpiForWindow
dpiAwareness = [int]$awarenessMap[$awarenessName]
windowUnicode = [bool]$coordinate.environment.windowUnicode
drawingSha256 = $drawingHash
coordinateSha256 = $coordinateHash
}
}
# Windows PowerShell 5.1 能精确收口宿主注入的 PATH/Path 重复键;只影响当前 runner。
$savedPath = $env:Path
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
$baselineInfo = Read-VerifiedBaseline (Resolve-BaselineSet $BaselineSet)
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 `
/p:StellarXInputProbe=true /v:minimal
if ($LASTEXITCODE -ne 0) { throw 'Input consume probe fixture build failed' }
if (-not (Test-Path -LiteralPath $fixture)) { throw "Fixture not found: $fixture" }
Add-Type @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class StellarXInputConsumeNative {
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
}
[StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT {
public ushort Vk; public ushort Scan; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
}
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
[FieldOffset(0)] public MOUSEINPUT Mouse;
[FieldOffset(0)] public KEYBDINPUT Keyboard;
}
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
public uint Type; public INPUTUNION Data;
}
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern IntPtr FindWindowW(string className, string title);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int command);
[DllImport("user32.dll", SetLastError=true)] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetWindowRect(IntPtr h, out RECT rect);
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
[DllImport("user32.dll", SetLastError=true)] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool value);
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint message, IntPtr w, IntPtr l);
[DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
public static IntPtr FindWindowByTitle(string title) { return FindWindowW(null, title); }
public static uint GetWindowProcessId(IntPtr h) {
uint processId;
if (GetWindowThreadProcessId(h, out processId) == 0 || processId == 0) throw new Win32Exception();
return processId;
}
public static bool ActivateWindow(IntPtr h) {
uint ignored;
uint currentThread = GetCurrentThreadId();
uint targetThread = GetWindowThreadProcessId(h, out ignored);
IntPtr foreground = GetForegroundWindow();
uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, out ignored);
bool attachedForeground = foregroundThread != 0 && foregroundThread != currentThread &&
AttachThreadInput(currentThread, foregroundThread, true);
bool attachedTarget = targetThread != 0 && targetThread != currentThread && targetThread != foregroundThread &&
AttachThreadInput(currentThread, targetThread, true);
try {
ShowWindow(h, 9);
BringWindowToTop(h);
SetForegroundWindow(h);
return GetForegroundWindow() == h;
}
finally {
if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
}
}
public static long ClickWindowForActivation(IntPtr h) {
RECT rect;
POINT original;
if (!GetWindowRect(h, out rect) || !GetCursorPos(out original)) throw new Win32Exception();
const uint noMoveNoSize = 0x0001 | 0x0002;
SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, noMoveNoSize);
int x = rect.Left + Math.Max(16, (rect.Right - rect.Left) / 2);
int y = rect.Top + Math.Max(40, (rect.Bottom - rect.Top) / 2);
try {
if (!SetCursorPos(x, y)) throw new Win32Exception();
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
int outsideX = rect.Left > 48 ? rect.Left - 32 : rect.Right + 32;
int outsideY = rect.Top > 48 ? rect.Top - 32 : rect.Bottom + 32;
SetCursorPos(outsideX, outsideY);
}
finally {
SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, noMoveNoSize);
}
return ((long)original.X << 32) | (uint)original.Y;
}
public static void RestoreCursorPosition(long packed) {
SetCursorPos((int)(packed >> 32), (int)(packed & 0xffffffffL));
}
private static void SendKey(ushort virtualKey, bool up) {
var inputs = new INPUT[1];
inputs[0].Type = 1; inputs[0].Data.Keyboard.Vk = virtualKey;
if (up) inputs[0].Data.Keyboard.Flags = 0x0002;
if (SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
}
public static void SendKeyDown(ushort virtualKey) { SendKey(virtualKey, false); }
public static void SendKeyUp(ushort virtualKey) { SendKey(virtualKey, true); }
}
'@
function Wait-Window([string]$title, [int]$timeoutMs = 15000) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
$window = [StellarXInputConsumeNative]::FindWindowByTitle($title)
if ($window -ne [IntPtr]::Zero) {
$fixturePid = [StellarXInputConsumeNative]::GetWindowProcessId($window)
try {
$fixtureProcess = [Diagnostics.Process]::GetProcessById([int]$fixturePid)
return [pscustomobject]@{
window = $window
process = $fixtureProcess
}
}
catch {
# 窗口可能正处于销毁边界;继续等待同标题的有效新实例。
$fixtureProcess = $null
}
}
Start-Sleep -Milliseconds 50
}
throw "Window not found: $title"
}
function Invoke-ProbeRun([string]$requestedAction, [string]$sessionRoot, [string]$experimentId) {
$actionSlug = if ($requestedAction -eq 'ObserveAndForward') { 'forward' } else { 'consume' }
$runDirectory = Join-Path $sessionRoot $actionSlug
$fixtureDirectory = Join-Path $runDirectory 'fixture'
New-Item -ItemType Directory -Force -Path $fixtureDirectory | Out-Null
$scanSlug = if ($ScanCode -lt 0) { 'scan-any' } else { "scan-$ScanCode" }
$ruleId = "$($Message.ToLowerInvariant())-vk$VirtualKey-$scanSlug-$($ImeCondition.ToLowerInvariant())-$actionSlug"
$windowUnicode = if ($baselineInfo.windowUnicode) { '1' } else { '0' }
$effectiveRuleBaselineId = if ($RuleBaselineId) { $RuleBaselineId } else { $baselineInfo.combinedBaselineId }
$launchParts = @(
'set "STELLARX_INPUT_PROBE=1"',
'set "STELLARX_INPUT_PROBE_0B=1"',
"set `"STELLARX_INPUT_PROBE_DIR=$runDirectory`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=$experimentId`"",
"set `"STELLARX_INPUT_PROBE_0B_RULE_ID=$ruleId`"",
"set `"STELLARX_INPUT_PROBE_0B_ACTION=$requestedAction`"",
"set `"STELLARX_INPUT_PROBE_0B_MESSAGE=$Message`"",
"set `"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=$VirtualKey`"",
"set `"STELLARX_INPUT_PROBE_0B_SCAN_CODE=$effectiveScanCode`"",
"set `"STELLARX_INPUT_PROBE_0B_IME_CONDITION=$ImeCondition`"",
'set "STELLARX_INPUT_PROBE_0B_MAX_MATCHES=1"',
"set `"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=$InputSource`"",
"set `"STELLARX_INPUT_PROBE_0B_BASELINE_ID=$effectiveRuleBaselineId`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=$windowUnicode`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=$ExpectedAnsiCodePage`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=$($baselineInfo.dpiForWindow)`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=$($baselineInfo.dpiAwareness)`""
)
if ($InputSource -eq 'Manual') {
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED=1"'
$launchParts += "set `"STELLARX_INPUT_PROBE_0B_IME_NAME=$ImeName`""
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_X=225"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_Y=282"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT=200"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_TOP=255"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT=680"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM=284"'
}
$launchParts += ('"{0}" input-consume-probe "{1}"' -f $fixture, $fixtureDirectory)
$launchCommand = $launchParts -join ' & '
$title = 'StellarX.UI.input-consume-probe'
if ([StellarXInputConsumeNative]::FindWindowByTitle($title) -ne [IntPtr]::Zero) {
throw "A stale fixture window is already open: $title"
}
$launcherProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/c', $launchCommand) -PassThru
$window = [IntPtr]::Zero
$fixtureProcess = $null
$savedCursorPosition = $null
$keyDownSent = $false
$keyUpSent = $false
try {
$windowState = Wait-Window $title
$window = $windowState.window
$fixtureProcess = $windowState.process
if ($InputSource -eq 'Manual') {
Write-Host "Manual 0B window ready: $ImeName / $ImeCondition / $requestedAction"
Write-Host 'Complete the printed checklist in the EasyX window, then close it.'
[void]$fixtureProcess.WaitForExit()
}
else {
Start-Sleep -Milliseconds 400
$windowShell = New-Object -ComObject WScript.Shell
[void]$windowShell.AppActivate($fixtureProcess.Id)
Start-Sleep -Milliseconds 400
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
[void][StellarXInputConsumeNative]::ActivateWindow($window)
Start-Sleep -Milliseconds 200
}
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
$savedCursorPosition = [StellarXInputConsumeNative]::ClickWindowForActivation($window)
Start-Sleep -Milliseconds 300
}
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
throw 'Fixture did not become the foreground window'
}
[StellarXInputConsumeNative]::SendKeyDown([uint16]$VirtualKey)
$keyDownSent = $true
Start-Sleep -Milliseconds 250
[StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey)
$keyUpSent = $true
Start-Sleep -Milliseconds 800
if (-not [StellarXInputConsumeNative]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) {
throw 'Failed to post WM_CLOSE to the input consume fixture'
}
if (-not $fixtureProcess.WaitForExit(10000)) { throw 'Fixture did not exit after WM_CLOSE' }
}
}
finally {
if ($keyDownSent -and -not $keyUpSent) {
try { [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) }
catch { Write-Verbose 'Failed to release synthetic key during cleanup' }
}
if ($fixtureProcess) { $fixtureProcess.Refresh() }
if ($fixtureProcess -and -not $fixtureProcess.HasExited -and $window -ne [IntPtr]::Zero) {
[void][StellarXInputConsumeNative]::PostMessage(
$window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
[void]$fixtureProcess.WaitForExit(3000)
$fixtureProcess.Refresh()
}
if ($fixtureProcess -and -not $fixtureProcess.HasExited) {
Stop-Process -Id $fixtureProcess.Id -Force -ErrorAction SilentlyContinue
}
$launcherProcess.Refresh()
if (-not $launcherProcess.HasExited) {
[void]$launcherProcess.WaitForExit(3000)
$launcherProcess.Refresh()
}
if (-not $launcherProcess.HasExited) {
Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue
}
if ($null -ne $savedCursorPosition) {
[StellarXInputConsumeNative]::RestoreCursorPosition([long]$savedCursorPosition)
}
}
$summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDirectory
if ($ExpectedFailOpenReason -eq 'none') {
if ($summary.experimentValidity -ne 'valid') {
throw "Invalid $requestedAction experiment: $($summary.experimentValidity)"
}
}
elseif ($ExpectedFailOpenReason -eq 'baseline_id_mismatch') {
$failOpenValid =
$summary.experimentValidity -eq 'ineligible_fail_open' -and
$summary.experimentFailOpenReason -eq $ExpectedFailOpenReason -and
$summary.targetObservationCount -eq 1 -and
$summary.targetLeaveCount -eq 1 -and
$summary.failOpenObservationCount -eq 1 -and
$summary.failOpenForwardedCount -eq 1 -and
$summary.ruleMatchCount -eq 0
if (-not $failOpenValid) {
throw "Invalid fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.experimentFailOpenReason)"
}
}
else {
$failOpenValid =
$summary.experimentValidity -eq 'runtime_fail_open' -and
$summary.runtimeFailOpenReason -eq $ExpectedFailOpenReason -and
$summary.targetObservationCount -eq 1 -and
$summary.targetLeaveCount -eq 1 -and
$summary.failOpenObservationCount -eq 1 -and
$summary.failOpenForwardedCount -eq 1 -and
$summary.ruleMatchCount -eq 0 -and
$summary.consumedMatchCount -eq 0 -and
$summary.matchingEasyXKeyCount -eq 1
if (-not $failOpenValid) {
throw "Invalid runtime fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.runtimeFailOpenReason)"
}
}
$actualFailOpenReason = if ($summary.experimentFailOpenReason -and
$summary.experimentFailOpenReason -ne 'none') {
$summary.experimentFailOpenReason
}
else {
$summary.runtimeFailOpenReason
}
[pscustomobject][ordered]@{
action = $requestedAction
runDirectory = $runDirectory
file = $summary.file
fixtureProcessId = if ($fixtureProcess) { $fixtureProcess.Id } else { $null }
sequenceStrict = $summary.sequenceStrict
recordsLost = $summary.recordsLost
ruleMatchCount = $summary.ruleMatchCount
consumedMatchCount = $summary.consumedMatchCount
forwardedMatchCount = $summary.forwardedMatchCount
matchingEasyXKeyCount = $summary.matchingEasyXKeyCount
easyxVisibility = $summary.easyxVisibility
imePositionAttemptCount = $summary.imePositionAttemptCount
imePositionSuccessCount = $summary.imePositionSuccessCount
imePositionReady = $summary.imePositionReady
imePositionResultRecordCount = $summary.imePositionResultRecordCount
imePositionOrphanCount = $summary.imePositionOrphanCount
closeObserved = $summary.closeObserved
sessionEndObserved = $summary.sessionEndObserved
expectedFailOpenReason = $ExpectedFailOpenReason
actualFailOpenReason = $actualFailOpenReason
experimentFailOpenReason = $summary.experimentFailOpenReason
runtimeFailOpenReason = $summary.runtimeFailOpenReason
targetObservationCount = $summary.targetObservationCount
targetLeaveCount = $summary.targetLeaveCount
failOpenObservationCount = $summary.failOpenObservationCount
failOpenForwardedCount = $summary.failOpenForwardedCount
experimentValidity = $summary.experimentValidity
}
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$comparisonId = [Guid]::NewGuid().ToString()
$sessionRoot = Join-Path $root "TestResults\InputProbe\0B\$stamp-$comparisonId"
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
$actions = if ($Action -eq 'Both') {
@('ObserveAndForward', 'ObserveAndConsume')
}
else {
@($Action)
}
$results = @()
if ($InputSource -eq 'Manual') {
Write-Host "Manual IME consume probe: $ImeName"
$scanDescription = if ($ScanCode -lt 0) { 'any scan code' } else { "scanCode=$ScanCode" }
Write-Host " Rule: $Message VK=$VirtualKey, $scanDescription, $ImeCondition, $Action"
Write-Host ' 1. Confirm the selected input method really matches -ImeName, then click the EasyX window.'
if ($ImeCondition -eq 'ComposingOnly') {
Write-Host ' 2. Type only ceshi until the composition/candidate UI is visible.'
Write-Host " 3. Press $operatorKeyDisplay exactly once while composition is active; release it normally."
}
else {
Write-Host " 2. Before starting composition, press $operatorKeyDisplay exactly once; release it normally."
Write-Host ' 3. Then type only ceshi to confirm ordinary candidate behavior still works.'
}
Write-Host ' 4. Check candidate, commit/cancel behavior and position against the accepted 0C baseline.'
Write-Host ' 5. Close the EasyX window. The runner will validate privacy-safe structural evidence.'
}
foreach ($requestedAction in $actions) {
Write-Host "Running 0B $requestedAction $Message VK=$VirtualKey scan=$effectiveScanCode"
$results += Invoke-ProbeRun $requestedAction $sessionRoot $comparisonId
}
$forward = $results | Where-Object action -eq 'ObserveAndForward' | Select-Object -First 1
$consume = $results | Where-Object action -eq 'ObserveAndConsume' | Select-Object -First 1
$allRunsComplete = @($results | Where-Object {
-not $_.sequenceStrict -or
$_.recordsLost -gt 0 -or
-not $_.closeObserved -or
-not $_.sessionEndObserved
}).Count -eq 0
$comparisonStatus = if (-not $allRunsComplete) {
'inconclusive'
}
elseif ($ExpectedFailOpenReason -ne 'none' -and @($results | Where-Object {
$_.actualFailOpenReason -ne $ExpectedFailOpenReason -or
$_.failOpenObservationCount -ne 1 -or
$_.failOpenForwardedCount -ne 1
}).Count -gt 0) {
'inconclusive'
}
elseif ($Action -eq 'Both' -and (-not $forward -or -not $consume)) {
'inconclusive'
}
elseif ($Action -eq 'Both' -and $forward.matchingEasyXKeyCount -lt 1) {
'inconclusive'
}
else {
'pass'
}
$observedRelation = if (-not $consume) {
'not_measured'
}
elseif ($consume.experimentValidity -eq 'runtime_fail_open') {
'easyx_visible_after_fail_open_forward'
}
elseif ($consume.easyxVisibility -eq 'absent') {
'easyx_suppressed_by_wndproc_consume'
}
elseif ($consume.easyxVisibility -eq 'present_once' -or $consume.easyxVisibility -eq 'present_multiple') {
'easyx_still_visible_after_wndproc_consume'
}
else {
'inconclusive'
}
$comparison = [pscustomobject][ordered]@{
inputConsumeProbeSchemaVersion = 1
comparisonId = $comparisonId
createdAtUtc = [DateTime]::UtcNow.ToString('o')
targetMessage = $Message
targetVirtualKey = $VirtualKey
targetScanCode = if ($ScanCode -lt 0) { $null } else { $ScanCode }
inputSource = $InputSource
imeCondition = $ImeCondition
imeName = if ($InputSource -eq 'Manual') { $ImeName } else { $null }
imeIdentityConfirmed = $InputSource -eq 'Manual' -and $ConfirmImeIdentity
expectedFailOpenReason = $ExpectedFailOpenReason
baselineSet = $baselineInfo.path
baselineSetSha256 = $baselineInfo.sha256
combinedBaselineId = $baselineInfo.combinedBaselineId
drawingBaselineSha256 = $baselineInfo.drawingSha256
coordinateBaselineSha256 = $baselineInfo.coordinateSha256
automatedStatus = $comparisonStatus
observedRelation = $observedRelation
finalVerdict = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
'MustForward'
}
else {
'Inconclusive'
}
finalVerdictReason = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
'The candidate UI was visible, but the HWND exposed no reliable composing state; the consume request fail-opened to Forward.'
}
elseif ($InputSource -eq 'Manual') {
'Structural capture only; operator review of IME behavior and 0C position evidence is still required.'
}
else {
'Core EX_KEY relation only; the required message and IME matrix is not complete.'
}
runs = @($results)
}
$comparisonPath = Join-Path $sessionRoot 'comparison-v1.json'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[IO.File]::WriteAllText($comparisonPath,
($comparison | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
$comparisonHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $comparisonPath).Hash
$comparison | Format-List
Write-Host "Comparison: $comparisonPath"
Write-Host "Comparison SHA-256: $comparisonHash"
if ($comparisonStatus -ne 'pass') { throw '0B automatic comparison is inconclusive' }
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -710,6 +710,19 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
return DefWindowProc(h, m, w, l); return DefWindowProc(h, m, w, l);
} }
const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l); const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l);
if (probeCall.observeAndConsume)
{
// 阶段 0B 的唯一消费出口:仅诊断构建、有效单规则和匹配基线下可达。
// 默认构建的 token 恒为 false,不改变生产窗口过程行为。
return StellarX::Internal::inputProbeWndProcLeave(probeCall, 0, false);
}
if (m == WM_CLOSE)
{
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
// WM_NCDESTROY 或宿主循环。这里是诊断会话最后一个可证明到达的耐久边界。
// 默认构建中 inputProbeDetach 为空操作,不改变生产关闭路径。
StellarX::Internal::inputProbeDetach(h);
}
// 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁 // 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁
if (m == WM_ERASEBKGND) if (m == WM_ERASEBKGND)
@@ -922,7 +935,8 @@ int Window::runEventLoop()
{ {
if (!hasLiveNativeWindow()) if (!hasLiveNativeWindow())
{ {
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); // 宿主循环已经确定退出;在 Window/EasyX 进一步改写句柄状态前结束探测会话。
StellarX::Internal::inputProbeDetach(hWnd);
return 0; return 0;
} }
@@ -1047,7 +1061,9 @@ int Window::runEventLoop()
StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped); StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped);
if (batch.stopped || !hasLiveNativeWindow()) if (batch.stopped || !hasLiveNativeWindow())
{ {
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); // batch.stopped(典型为 WM_CLOSE)是宿主线程最后一个可靠安全点。
// detach 会落下 window_detach/session_end;默认构建中该调用为空操作。
StellarX::Internal::inputProbeDetach(hWnd);
return 0; return 0;
} }
@@ -17,6 +17,12 @@ namespace StellarX::Internal
UINT message = 0; UINT message = 0;
bool depthCounted = false; bool depthCounted = false;
bool recorded = false; bool recorded = false;
bool experimentTagged = false;
bool ruleMatched = false;
bool observeAndConsume = false;
bool failOpenApplied = false;
std::uint8_t failOpenReason = 0;
std::uint32_t matchOrdinal = 0;
}; };
struct InputProbeBatchToken struct InputProbeBatchToken
+29 -3
View File
@@ -35,7 +35,10 @@ int main(int argc, char** argv)
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。 // fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
const std::string scenario = argc > 1 ? argv[1] : "geometry"; const std::string scenario = argc > 1 ? argv[1] : "geometry";
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults"; const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
Window window(480, 320, 1, Background, "StellarX.UI." + scenario); const bool consumeProbeScenario = scenario == "input-consume-probe";
const int windowWidth = consumeProbeScenario ? 720 : 480;
const int windowHeight = consumeProbeScenario ? 420 : 320;
Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
if (scenario == "ownership") if (scenario == "ownership")
{ {
@@ -89,10 +92,19 @@ int main(int argc, char** argv)
button->setTooltipText("stationary tip"); button->setTooltipText("stationary tip");
window.addControl(std::move(button)); window.addControl(std::move(button));
} }
else if (scenario == "input-probe") else if (scenario == "input-probe" || scenario == "input-consume-probe")
{ {
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。 // 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
const std::array<std::string, 5> instructions{ const bool consumeProbe = scenario == "input-consume-probe";
const std::array<std::string, 5> instructions = consumeProbe
? std::array<std::string, 5>{
"StellarX stage 0B isolated consume probe",
"This window is driven by the bounded diagnostic runner.",
"Only one message, key and action rule is active.",
"No control receives business keyboard routing.",
"The runner will close this window after capture."
}
: std::array<std::string, 5>{
"StellarX input probe - manual IME run", "StellarX input probe - manual IME run",
"1. Click this window, then switch to the requested IME.", "1. Click this window, then switch to the requested IME.",
"2. Type only the agreed non-sensitive test strings.", "2. Type only the agreed non-sensitive test strings.",
@@ -107,6 +119,20 @@ int main(int argc, char** argv)
window.addControl(std::move(label)); window.addControl(std::move(label));
y += 42; y += 42;
} }
if (consumeProbe)
{
// 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284),
// caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。
auto anchorLabel = std::make_unique<Label>(20, 260, "IME anchor:", WHITE, Background);
anchorLabel->setTextdisap(true);
window.addControl(std::move(anchorLabel));
auto lineRegion = std::make_unique<Canvas>(200, 255, 480, 29);
lineRegion->setCanvasBkColor(RGB(45, 70, 95));
window.addControl(std::move(lineRegion));
auto caret = std::make_unique<Canvas>(224, 258, 2, 24);
caret->setCanvasBkColor(HoverColor);
window.addControl(std::move(caret));
}
} }
else if (scenario == "native-close") else if (scenario == "native-close")
{ {
@@ -0,0 +1,57 @@
# BUG-20261006-0002
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261006-0002
- 标题: 阶段 0B 夹具缺少 IME 锚点、组合态 Escape 规则无法命中且采集存在锁竞争丢失
- 状态:已修复并完成微软拼音与搜狗人工验证
- 严重性:S2
- 优先级:P0
- 模块: 阶段 0B / InputProbe / UiFixture / IMM32
- 版本 / 分支: `master`,0B 开发中未提交改动
- 环境: Windows 11、EasyX 25.9.10、ANSI HWND、CP936、DPI-unaware 96 DPI、微软拼音与搜狗人工探测
- 发现人: 用户真实 IME 人工探测 + 日志复核
- 关联 Fix ID: Fix-BUG-20261006-0002
## 问题描述
- 现象 1: `input-consume-probe` 窗口中开始中文组合后,微软拼音和搜狗候选 UI 都出现在桌面左下角,而不是探测窗口内。
- 现象 2: 人工按一次 Escape 后,配置为 `WM_KEYDOWN + VK_ESCAPE + ComposingOnly` 的规则命中数为 0。
- 现象 3: 本轮日志出现 3 条 `wndproc_lock_contention`,`recordsLost=3`,分析结果为 `inconclusive_capture_loss`。
- 影响范围: 0B-3 的真实 IME A/B 无法形成有效证据;如果误把该运行当成消费行为结果,可能把夹具缺陷错误归因于 IME 或 WndProc 消费策略。
- 期望结果: 夹具使用与 0C 同源的客户区 anchor 定位候选 UI;规则匹配 WndProc 实际可观察的原生键三元组;探测器自身不因 flush 锁竞争丢记录。
## 复现信息
- 前置条件: 运行 0B Composition 人工探针,选择微软拼音或搜狗。
- 复现步骤:
1. 点击 `StellarX.UI.input-consume-probe`,输入固定非敏感串 `ceshi`。
2. 观察候选 UI 位于桌面左下角。
3. 在组合态按一次 Escape 并关闭窗口。
4. 分析 JSONL 中的原生键和 `records_lost`。
- 复现概率:旧夹具在本轮稳定复现
- 证据目录: `TestResults/InputProbe/0B/20261006-140720-218-96b7392e-548a-44cf-9343-5d1449bf73fa`
- 人工截图: `manual-evidence/composition-candidate-position-wrong.png`
- 截图 SHA-256: `61E4A04CEE329B13BB22C919DC233A07ABF3EF6C298E64FB7B6E70F6D60DE0A3`
## 初步分析
- 候选位置根因: 0B 夹具没有调用 `ImmSetCompositionWindow` 或 `ImmSetCandidateWindow`,却要求操作者按 0C 基线判断位置;两套环境实际上不可比。
- 规则根因: 组合态按 Escape 时,WndProc 记录为 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`;IME 结束后才出现 `WM_KEYUP + VK_ESCAPE(27)`。旧规则错误假设 KeyDown 仍保留逻辑 VK。
- API 时机边界: 微软文档说明 `ImmGetVirtualKey` 只能在应用调用 `TranslateMessage` 前恢复原 VK。当前钩子已经在 EasyX WndProc,不能可靠恢复该键,只能记录真实原生值。
- 采集丢失根因: 宿主 safe-point 在状态锁内复制整个记录环,WndProc 使用 `try-lock`,争用时直接记丢失;探测器自身制造了实验失效条件。
- 证据边界: 失败运行混有多次组合,且存在采集丢失,只能证明夹具缺陷,不能区分输入法差异,也不能判断 Consume 是否安全。
## 跟踪信息
- 首次发现时间: 2026-10-06 14:07
- 最后更新时间: 2026-10-07
- 修复版本: 待提交
- 验证版本: Debug x64 探测构建、默认单元测试 15/15、UI fixture 1 轮、F24 自动 A/B、微软拼音 Forward/Consume 与搜狗运行时 fail-open 人工验证通过
- 参考: <https://learn.microsoft.com/windows/win32/api/immdev/nf-immdev-immgetvirtualkey>
- 后续关联: 固定锚点实现最初在状态锁内调用 IMM32 setter,引发 WndProc 重入自锁;详见 `BUG-20261006-0003`。
- 关闭边界: 搜狗候选态不暴露标准 Composition 生命周期,Consume 请求必须以 `ime_condition_not_met` fail-open;这是 `MustForward` 的输入法兼容结论,不是本 BUG 的残留夹具缺陷。
@@ -0,0 +1,60 @@
# BUG-20261006-0003
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261006-0003
- 标题: 阶段 0B 持锁调用 IMM32 定位导致 WndProc 重入死锁
- 状态:已修复(微软拼音 Forward/Consume 与搜狗 fail-open 人工运行均保持响应)
- 严重性:S1
- 优先级:P0
- 模块: 阶段 0B / InputProbe / WndProc / IMM32
- 版本 / 分支: `master`,0B 开发中未提交改动
- 环境: Windows 11、EasyX 25.9.10、ANSI HWND、CP936、DPI-unaware 96 DPI、微软拼音与搜狗人工探测
- 发现人: 用户真实 IME 人工探测
- 关联 Fix ID: Fix-BUG-20261006-0003
## 问题描述
- 现象: 启动微软拼音 `ObserveAndForward` 人工探测后,EasyX 窗口卡顿并进入“未响应”,无法继续输入。
- 影响范围: 0B-3 人工 IME A/B 无法执行;该现象由诊断夹具自身造成,不能作为微软拼音或 Forward 行为异常的证据。
- 期望结果: IMM32 定位不得持有探测状态锁;同步重入的 WndProc 消息仍应被严格有序记录,窗口保持可交互。
## 复现信息
- 前置条件: 启用 0B 人工 Composition 规则和固定 IME caret/line rect。
- 复现步骤:
1. 启动微软拼音 `ObserveAndForward` 人工探针。
2. 激活 EasyX 窗口并尝试中文输入。
3. 窗口出现卡顿,随后无法输入并显示“未响应”。
- 复现概率:本轮稳定复现
- 证据目录: `TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward`
- 日志边界: 已耐久写入 268 条记录,`sequenceStrict=true`、`recordsLost=0`,但没有 `WM_CLOSE/session_end`;最后一批记录完整结束于普通 Space KeyUp。死锁发生点很可能位于下一次定位调用且早于新记录入队,因此日志不能直接给出阻塞栈。
## 根因分析
- `inputProbeWndProcEnter` 持有不可重入的 `ProbeState::lock` 时调用 `ImmGetContext`、`ImmSetCompositionWindow` 和 `ImmSetCandidateWindow`。
- IMM32 定位 setter 可以同步重入同一窗口过程;重入后的探测入口再次请求同一把 SRW 独占锁,当前线程因此自锁。
- 直接把 setter 移到锁外仍不充分:重入消息如果再次满足定位触发条件,会递归发起新的定位调用,需要独立的线程内重入抑制。
- 本问题由 `BUG-20261006-0002` 的固定锚点修复实现引入,不是 EasyX、微软拼音或消息消费策略本身的缺陷。
## 跟踪信息
- 首次发现时间: 2026-10-06 23:16
- 最后更新时间: 2026-10-07
- 修复版本: 待提交
- 验证版本: Debug x64 探测构建、默认单元测试 15/15、UI fixture 1 轮、微软拼音 Forward/Consume 与搜狗 fail-open 真实复验通过
- 关联记录: `BUG-20261006-0002`、`Fix-BUG-20261006-0002`
## 关闭证据
- 有效运行: `TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward`
- 结构结果: `experimentValidity=valid`、`sequenceStrict=true`、`recordsLost=0`、规则唯一命中并 Forward、`imePosition=18/18`、`imePositionOrphanCount=0`、`WM_CLOSE/session_end` 完整。
- 人工结果: 窗口保持响应并可正常输入;组合串和候选 UI 位于固定锚点附近,Escape 流程完成后窗口正常关闭。
- 截图: `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png`
- 截图 SHA-256: `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`
- 补充证据: 搜狗最终 fail-open 运行中窗口保持响应,定位 3/3、无孤立结果、零丢失且关闭完整;见 `TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume`。
- 关闭边界: 本 BUG 只关闭“持锁定位导致重入死锁”;搜狗未暴露 Composition 生命周期所形成的 `MustForward` 结论归 0B 规格,不属于死锁残留。
@@ -0,0 +1,74 @@
# Fix-BUG-20261006-0002
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261006-0002
- 关联 BUG ID: BUG-20261006-0002
- 修复目标: 让阶段 0B 的真实 IME 实验具备可比较的位置基线、可命中的原生键规则和不因状态锁竞争主动丢记录的采集路径
- 状态:验证完成
- 负责人: Codex
- 分支 / 版本: `master`,未提交工作区
## 根因分析
- 根因 1: 0B 复用了只显示说明文字的 UiFixture 场景,没有把 0C 已验证的 caret point/line rect 写入当前 HWND 的 IME context。
- 根因 2: 规则按操作者语义配置为 `VK_ESCAPE`,但 IME 在 WndProc 前已处理组合态 KeyDown,窗口实际收到 `VK_PROCESSKEY`;scan code 1 才保留了本轮 Escape 的物理区分信息。
- 根因 3: 单环形缓冲在 safe-point 持锁复制,WndProc 为避免等待使用 `TryAcquireSRWLockExclusive`;宿主复制期间到达的原生消息会被主动丢弃。
## 修复方案
- 修复思路: 保持 0B 动作枚举只有 Forward/Consume 两值,仅修正诊断输入、位置基线和采集完整性,不新增生产路由行为。
- 关键改动:
- 0B 人工 runner 固定窗口为 `720×420`,传入客户区 caret `(225,282)` 与 line rect `(200,255)-(680,284)`。
- UiFixture 绘制与上述数值一致的 line region 和 caret marker,便于操作者判断候选 UI 是否仍落在窗口外。
- 在 `WM_SETFOCUS`、`WM_IME_SETCONTEXT`、`WM_IME_STARTCOMPOSITION`、`WM_IME_COMPOSITION` 观察点准备定位请求;锁外调用 `CFS_POINT` composition setter 与 `CFS_EXCLUDE` candidate setter,通过独立 `ime_position` 记录保存 context、两个 API 结果和坐标。
- Composition 请求 Consume 时,如果尚无一次两个 setter 同时成功的证据,立即 fail-open 为 `ime_position_unavailable`。
- 规则新增可选 `scanCode`。自动 F24 仍使用 any;人工 Escape 默认使用 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1 + ComposingOnly`,界面指令仍要求操作者按 Escape。
- 分析器在指定 scan code 时同时过滤 WndProc 与 EasyX `EX_KEY`,并输出位置尝试/成功计数和 `imePositionReady`。
- 记录缓冲改为两个固定数组;safe-point 在锁内只交换 active buffer 和重置索引,文件写入在锁外完成。WndProc 使用极短阻塞临界区,不再因 `try-lock` 失败丢弃记录。
- 涉及文件 / 类 / 函数:
- `src/StellarX/SxInputProbe.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-input-consume-probe.ps1`
- `scripts/run-input-consume-probe-manual.ps1`
- `scripts/analyze-input-probe.ps1`
- 影响的 API / 行为: 仅 `STELLARX_ENABLE_INPUT_PROBE` 且显式开启 0B 的诊断路径;公共 API 与默认生产窗口行为不变。
- 回滚点 / 开关: 不设置 `STELLARX_INPUT_PROBE_0B` 时不装载规则、不调用固定位置逻辑;默认构建不包含探测实现。
## 影响评估
- 正确性: scan code 消除同一 `VK_PROCESSKEY` 下字母、方向、Escape 等多类组合键的歧义;固定位置成功门槛防止把夹具定位失败算成消费副作用。
- 并发: 双缓冲消除宿主批量复制对 WndProc 的长时间占锁;文件写锁仍串行化多个 flush,状态锁序保持“文件写锁 → 状态锁”。
- 性能: WndProc 每条被观察消息增加一次短阻塞状态锁;Composition 生命周期增加有限的 IMM32 setter。两者只存在于诊断构建。
- 风险:
- 某些 IME 即使 setter 返回成功也可能自行调整候选 UI,因此仍需人工视觉确认。
- WndProc 阻塞临界区要求 safe-point 严禁在状态锁内做文件 I/O 或整批复制;后续维护若破坏此不变量可能重新引入卡顿。
- 首次人工复验发现持状态锁调用 IMM32 setter 会因同步重入 WndProc 自锁;已拆分为“锁内请求 → 锁外调用 → 锁内结果”并增加线程内递归定位保护,详见 `Fix-BUG-20261006-0003`。
- `VK_PROCESSKEY + scanCode 1` 是当前环境观察到的原生 Escape 形态,不得未经新证据外推到全部 IME/键盘布局。
## 验证结果
- 已完成:
- Debug x64 探测构建完成编译与链接;仅有 EasyX 头文件既有 C4201 警告。
- 失败截图已复制到对应忽略运行目录并记录 SHA-256。
- 默认 Debug x64 单元测试 15/15 通过。
- Windows PowerShell 5.1.22000.2538 与 PowerShell 7.6.5 对三份 0B 脚本均为 0 个 AST 解析错误。
- `git diff --check` 通过,仅输出仓库换行规范提示。
- 新分析器重放既有有效 F24 KeyDown A/B:Forward 仍为 `valid/present_once`,Consume 仍为 `valid/absent`,兼容未携带 `targetScanCode` 的既有 schema v2 日志。
- `BUG-20261006-0003` 修复后的微软拼音 Forward 人工复验为 `valid`:Escape 原生规则唯一命中并转发,18/18 次定位成功、零孤立结果、零丢失,人工截图确认候选 UI 位于固定锚点附近。
- 微软拼音 Consume 有效重跑为 `valid`:Escape 唯一命中并消费,旧过程未调用,对应 EasyX 键消失;7/7 次定位成功、零孤立结果、零丢失且无提交结果。
- 搜狗 Forward 运行确认候选 UI 可见且位置正常,但目标 HWND 未出现 START/COMPOSITION/END;Escape 到达时 tracked state 仍为 Idle,`ComposingOnly` 零命中,定位 6/6、零丢失、收口完整。
- 搜狗最终 Consume 请求按 `ime_condition_not_met` 正确运行时 fail-open:`experimentValidity=runtime_fail_open`、零消费、实际 Forward 一次、EasyX 键出现一次、定位 3/3、零丢失,最终 verdict 为 `MustForward`。
- 2026-10-07 默认 Debug x64 单元测试 15/15、UI fixture 1 轮和 F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。
- 当前工具环境限制: 自动 F24 runner 已完成构建并打开夹具,但 Codex 非交互工具宿主无法把窗口切到前台,`ClickWindowForActivation` 在发键前失败;该运行没有形成新的 A/B 结论,不记为代码失败,也不覆盖既有有效证据。
- 首次微软拼音复验边界: 旧定位实现导致窗口未响应,失败运行保留于 `TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward`;该运行只证明诊断死锁,不用于评价 Forward 或微软拼音。
- 验证边界: 自动 `SendInput` 不能替代真实 IME;搜狗不能被要求“规则唯一命中”,因为缺少可证明 Composition 状态时正确行为就是零命中并 fail-open。该 Fix 以固定定位链、原生键观察、零丢失和明确 `MustForward` 结论关闭,不把运行时 Forward 伪装成 Consume。
## 落地信息
- Commit: 待验证与用户确认后提交
- PR:无
- 发布版本:未发布
- 备注: 本修复不进入阶段 1,不实现 FocusManager、生产 InputCollector 或键盘路由。
@@ -0,0 +1,54 @@
# Fix-BUG-20261006-0003
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261006-0003
- 关联 BUG ID: BUG-20261006-0003
- 修复目标: 消除 0B 固定 IME 定位路径的 WndProc 重入自锁,同时保持日志严格序号和可审计关联
- 状态:验证通过(微软拼音 Forward/Consume 与搜狗运行时 fail-open)
- 负责人: Codex
- 分支 / 版本: `master`,未提交工作区
## 修复方案
- 将定位路径拆成三个阶段:
1. 锁内解析并写入原始 `wndproc_enter`,更新 IME/规则状态,复制不可变的定位请求。
2. 锁外调用 `ImmGetContext`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow` 和 `ImmReleaseContext`。
3. 再次加锁校验探测仍启用、HWND 与 `hwndInstanceId` 未变化,更新 `imePositionReady` 和成功计数,并写入独立 `ime_position` 结果记录。
- `ime_position.parentSequence` 指向触发它的 `wndproc_enter.sequence`;结果记录只在 IMM32 调用返回后分配序号。这样 setter 同步重入产生的消息自然位于父 enter 与结果记录之间,不会因“预留低序号、事后补写”破坏 `sequenceStrict`。
- 使用 WndProc 线程局部的 `imePositionApiCallActive` 抑制定位 API 调用期间的递归定位;重入消息仍正常采集、转发和记录,只是不再次调用定位 setter。
- 分析器同时兼容旧版挂在 `wndproc_enter` 上的位置结果与新的 `ime_position` 记录,并校验新记录的 `parentSequence` 存在且严格早于结果记录。
## 影响评估
- 正确性: 移除持锁 IMM32 setter,避免 SRW 自锁;独立结果记录保留调用结果、坐标、触发消息与父序号。
- 并发: `ProbeState::lock` 只保护状态快照与结果提交,不跨越任何定位 setter;完成阶段会拒绝已经 detach 或 HWND 实例变化的过期结果。
- 顺序: 父 enter 先入缓冲,重入消息随后入缓冲,最终结果记录最后入缓冲;全局序号与实际可观察顺序一致。
- 行为边界: 仅影响 `STELLARX_ENABLE_INPUT_PROBE` 且显式开启 0B 人工定位的诊断路径;默认生产构建、公共 API 与正常消息返回策略不变。
- 风险: 不同 IME 仍可能自行调整候选 UI;setter 成功只证明调用链成立,最终位置仍需人工视觉确认。
## 验证结果
- 已完成:
- Debug x64 探测构建完成编译与链接;仅有 EasyX 头文件既有 C4201 警告。
- 旧失败日志重放仍为 268 条严格递增记录、零丢失,并明确判为缺少关闭收口的无效实验。
- 默认 Debug x64 单元测试 15/15 通过。
- Windows PowerShell 5.1 与 PowerShell 7.6.5 对三份 0B 脚本均为 0 个 AST 解析错误。
- `git diff --check` 通过,仅输出仓库换行规范提示。
- 微软拼音 Forward 人工复验通过:窗口保持响应,组合串/候选 UI 位于固定锚点;结构日志为 1142 条严格递增记录、零丢失、规则唯一命中并 Forward、18/18 次定位成功、0 个孤立定位结果、关闭收口完整。
- 有效运行: `TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward`。
- 人工截图: `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png`,SHA-256 `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`。
- 微软拼音 Consume 同样保持响应,7/7 次定位成功且 Escape 唯一命中并被消费;有效运行位于 `TestResults/InputProbe/0B/20261007-000152-550-4c83bcda-12e8-4f4e-b616-1d1fe1f56a27/consume`。
- 搜狗 Forward 运行保持响应、定位 6/6;最终 Consume 请求因 Composition 条件不可证明而正确 fail-open,运行仍保持响应、定位 3/3、无孤立结果、零丢失并完整关闭。最终有效目录为 `TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume`。
- 2026-10-07 默认 Debug x64 单元测试 15/15、UI fixture 1 轮和当前分析器回放通过。
## 落地信息
- Commit: 待验证与用户确认后提交
- PR:无
- 发布版本:未发布
- 备注: 本修复不进入阶段 1,不实现 FocusManager、生产 InputCollector 或键盘路由。
@@ -7,7 +7,7 @@
- 模块 ID:Module-20261001-0001 - 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统 - 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:设计中(v0.9 IME 位置双时机修订;0C 当前可用环境基线通过,下一步进入 0B,阶段 1 尚未开始) - 状态:设计中(v0.13;0C 当前可用环境基线通过,0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按 `ime_condition_not_met` fail-open 并形成 `MustForward`;阶段 1 尚未开始)
- 类型:新增模块 / 架构演进 - 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox - 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25` - 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
@@ -126,6 +126,14 @@
- 搜狗真实运行证明候选 UI 可能已经出现,但 EasyX HWND 尚未收到探测器原先依赖的 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。因此位置设置不能只挂在 IME 生命周期消息上;必须在焦点/caret/anchor 变化时预设,并在生命周期消息到达时再校准。 - 搜狗真实运行证明候选 UI 可能已经出现,但 EasyX HWND 尚未收到探测器原先依赖的 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。因此位置设置不能只挂在 IME 生命周期消息上;必须在焦点/caret/anchor 变化时预设,并在生命周期消息到达时再校准。
- 修复后的搜狗运行中,F1/F2/F3 的 anchor 预设全部成功,F3 生命周期二次校准成功;F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前 96 DPI 虚拟化视图下,微软拼音与搜狗 0C-3 均通过人工视觉验收。 - 修复后的搜狗运行中,F1/F2/F3 的 anchor 预设全部成功,F3 生命周期二次校准成功;F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前 96 DPI 虚拟化视图下,微软拼音与搜狗 0C-3 均通过人工视觉验收。
2026-10-06 的 0B 首轮 Composition 人工运行进一步确认:
- 操作者按 Escape 时,组合态 WndProc 观察到的是 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,组合结束后的 KeyUp 才是 `VK_ESCAPE(27)`;因此原生采集规则必须允许用 message、VK 与 scan code 三元组精确描述,不能把 WndProc 层的 `VK_PROCESSKEY` 伪装成已恢复的逻辑 Escape。
- `ImmGetVirtualKey` 只允许应用在自身消息循环调用 `TranslateMessage` 前恢复原 VK;当前钩子已经位于 EasyX WndProc,不能依赖该 API 补救时机。
- 0B 夹具必须自己引用 0C 已验证的客户区 caret/line rect,并在焦点/IME context 与组合生命周期双时机设置组合窗和候选窗位置;否则“候选框异常”只证明夹具缺少定位,不能证明消费动作破坏 IME。
- 宿主安全点复制整批记录会与 WndProc 争用状态锁。0B 修正为双固定缓冲、锁内常数时间交换、锁外写盘;诊断采集不再用 `try-lock` 丢弃 WndProc 观察。
- 固定 IME 定位 setter 可能同步重入 WndProc。任何探测或生产输入实现都不得持有状态/输入队列锁调用 IMM32 setter;0B 使用“锁内提交父 enter 和不可变请求 → 锁外 IMM32 → 锁内校验会话并提交 `ime_position` 结果”的三阶段协议,并以线程局部标志阻止递归定位。
### 2. 分层架构 ### 2. 分层架构
```text ```text
@@ -250,7 +258,7 @@ IME 消息:采集 + 转发
- 非组合态 Button 的 Enter/Space。 - 非组合态 Button 的 Enter/Space。
- 框架明确拥有的 Ctrl 快捷键。 - 框架明确拥有的 Ctrl 快捷键。
硬规则:`imeComposingAtCapture == true` 的键不能进入消费白名单。 硬规则:`imeComposingAtCapture == true` 或原始 `virtualKey == VK_PROCESSKEY` 的键不能进入消费白名单,也不得进入 Button、Dialog、快捷键或焦点导航等业务路由。后者不依赖标准 Composition 生命周期,因为搜狗实测已证明候选 UI 可见时目标 HWND 仍可能保持 `imeState=idle`。
### 6. 输入事件模型 ### 6. 输入事件模型
@@ -389,7 +397,7 @@ EasyX HWND/WndProc 线程
6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。 6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。
7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。 7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。
探测器使用的“固定环形缓冲 + 尝试锁 + 允许 `records_lost`”只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。 探测器使用的固定双缓冲和 `records_lost` 失效判定只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。诊断路径同样禁止持锁调用旧窗口过程、IMM32 setter、用户回调或文件 I/O。
### 7. Unicode 和文字来源 ### 7. Unicode 和文字来源
@@ -987,17 +995,18 @@ struct DispatchState
- 覆盖 100%、125%、150% 和跨屏不同 DPI。 - 覆盖 100%、125%、150% 和跨屏不同 DPI。
- 采集 `COMPOSITIONFORM` / `CANDIDATEFORM` 定位证据。 - 采集 `COMPOSITIONFORM` / `CANDIDATEFORM` 定位证据。
- 详细规格见 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。 - 详细规格见 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。
- 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线关闭,100%/125%/跨屏作为实机能力缺口继续记录,下一步进入 0B。 - 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线已关闭,100%/125%/跨屏作为实机能力缺口继续记录;后续 0B 阶段 1 前置基线也已关闭。
#### 阶段 0B:隔离消费实验 #### 阶段 0B:隔离消费实验
使用测试专用规则: 使用测试专用规则;详细规格见 [`Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md`](./Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md):
```cpp ```cpp
struct ProbeConsumeRule struct ProbeConsumeRule
{ {
UINT message; UINT message;
std::optional<UINT> virtualKey; std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume ProbeAction action; // ObserveAndForward / ObserveAndConsume
}; };
@@ -1007,6 +1016,21 @@ struct ProbeConsumeRule
- 日志携带实验 ID 和规则 ID。 - 日志携带实验 ID 和规则 ID。
- 同步窗口协议禁止进入规则表。 - 同步窗口协议禁止进入规则表。
- 正式构建默认没有任何消费规则。 - 正式构建默认没有任何消费规则。
- 动作枚举严格保持 `ObserveAndForward / ObserveAndConsume` 两值;“转发但抑制下游业务路由”属于阶段 1,不是 0B 的第三种 WndProc 动作。
- 实验结论使用 `SafeToConsume / MustForward / Inconclusive`,与动作枚举分离。
- 自动 A/B 必须单独回答:WndProc 消费目标 `WM_KEYDOWN` 后,宿主 `peekmessage(EX_KEY)` 是否仍返回对应虚拟键。
- 配置错误、规则模糊、IME 状态不可靠、出现记录丢失或运行环境与 0C baseline 不一致时一律 fail-open,实际动作退回观察并转发。
- 当前 F24 实测:`WM_KEYDOWN/UP` 的 Forward 对照均产生一次对应 `EX_KEY`,Consume 后对应 `EX_KEY` 均消失;错误 baseline 请求 Consume 时正确退回 Forward。该结论只证明 F24 的两观察面关系,不自动外推为生产消费白名单。
- 自动首键由 F8 改为 F24:当前中文 IME 环境下 F8 的 `SendInput` KeyDown 未到达目标 WndProc,截断层级尚未确认,不能形成有效 A/B 对照。
- 0B 自动证据现已强制要求严格序列、零丢失、真实 fixture PID 退出、`WM_CLOSE` 与 `session_end` 完整;宿主 flush 和 WndProc 关闭收口使用独立文件写锁串行。
- Composition 人工规则按真实原生观察匹配 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,操作者动作仍为 Escape;规则不尝试在 WndProc 中恢复逻辑键。
- 0B 人工夹具使用固定客户区 anchor,并记录 `ImmGetContext` 与两个 setter 的分项结果;位置链未成功时请求 Consume 必须 fail-open。
- 0B 日志缓冲采用双固定缓冲,安全点只在状态锁内交换 buffer;文件序列化在锁外完成,避免把探测器自身锁竞争误报成输入链丢失。
- 0B 固定 IME 定位采用三阶段拆锁协议,定位结果写独立 `ime_position` 记录并以 `parentSequence` 关联父 enter;setter 同步重入的消息允许正常采集,但不得递归发起定位。
- 修复后微软拼音 Forward 人工运行保持响应,Escape 原生规则唯一命中并转发,定位结果 18/18 成功且无孤立父序号;截图确认组合串/候选 UI 位于固定锚点附近。该结论只关闭诊断死锁和 Forward 对照,不代替 Consume 与搜狗矩阵。
- 微软拼音 Consume 重跑中 Escape 原生规则唯一命中并返回 0,旧窗口过程未被调用,对应 EasyX `EX_KEY=absent`;定位 7/7 成功、零丢失且没有产生提交结果。与 Forward 的 `EX_KEY=present_once` 对照表明当前受控键在 WndProc 消费后不会再进入 EasyX 键面,但生产白名单仍需搜狗矩阵和最终 0B 评审。
- 搜狗候选态实测没有向目标 HWND 发送 START/COMPOSITION/END 生命周期;Escape KeyDown 仍为 `VK_PROCESSKEY + scanCode 1`,但 tracked IME state 保持 Idle。最终 Consume 请求按 `ime_condition_not_met` 运行时 fail-open 为 Forward,EasyX 键可见一次,结论为 `MustForward` 而不是真正 Consume。
- 阶段 1 采用默认 capture-and-forward,不建立生产消费白名单;所有 `VK_PROCESSKEY` 和已确认组合态键只供输入法/文本规范化,不进入控件业务路由。微软拼音单点 Consume 成功只作为“WndProc return 0 会阻断 EasyX 键面”的机制证据。
#### 阶段 1:InputCollector #### 阶段 1:InputCollector
@@ -1082,12 +1106,14 @@ struct ProbeConsumeRule
26. EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。 26. EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。
27. 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。 27. 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。
28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。 28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。
29. 原始 `VK_PROCESSKEY` 一律视为 IME 所有:WndProc 层采集并转发,阶段 1 不向 Button、Dialog、快捷键和焦点导航路由;不得依赖 `imeComposingAtCapture` 单独判断搜狗候选态。
30. 0B 没有建立跨 IME 生产消费白名单;阶段 1 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
## 实现与影响 ## 实现与影响
- 关键实现点: - 关键实现点:
- 阶段 0A 探测器已经进入框架;0C 独立视觉探测工程已实现;FocusManager、生产 NativeInputQueue 和 TextBox 改造尚未开始。 - 阶段 0A 探测器已经进入框架;0C 独立视觉探测工程已实现;FocusManager、生产 NativeInputQueue 和 TextBox 改造尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则。 - 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 微软拼音 Consume 与搜狗运行时 fail-open 证据锁定默认转发、`VK_PROCESSKEY` 路由抑制和无生产消费白名单基线。
- 涉及文件 / 类 / 函数: - 涉及文件 / 类 / 函数:
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。 - 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。 - 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
@@ -1106,7 +1132,7 @@ struct ProbeConsumeRule
- 测试范围: - 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。 - 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A 构建、固定消息 smoke、微软拼音/搜狗真实输入链路测试以及当前可用环境的 0C 基线均已完成;0B 消费实验、生产输入队列和焦点系统尚未开始。 - 独立线程归属探测、0A 构建、固定消息 smoke、微软拼音/搜狗真实输入链路测试、当前可用环境的 0C 基线,以及 0B 的 F24 KeyDown/KeyUp 自动 A/B、baseline fail-open、微软拼音 Forward/Consume 和搜狗运行时 fail-open 门槛均已完成;生产输入队列和焦点系统尚未开始。
- 验证步骤: - 验证步骤:
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。 1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
@@ -1129,16 +1155,21 @@ struct ProbeConsumeRule
- 2026-10-05:微软拼音三个 anchor 自动 IMM 调用和人工位置观察通过;第二轮虽然脚本参数为 `Sogou`,操作者确认实际未切换输入法,因此没有把它误记为搜狗证据。 - 2026-10-05:微软拼音三个 anchor 自动 IMM 调用和人工位置观察通过;第二轮虽然脚本参数为 `Sogou`,操作者确认实际未切换输入法,因此没有把它误记为搜狗证据。
- 2026-10-05 23:51:搜狗首次真实运行按预期门槛失败,F1/F2 未发生 IMM 定位调用,F3 为 2/2;截图确认候选框先位于窗口外,之后沿用 F3 旧位置。 - 2026-10-05 23:51:搜狗首次真实运行按预期门槛失败,F1/F2 未发生 IMM 定位调用,F3 为 2/2;截图确认候选框先位于窗口外,之后沿用 F3 旧位置。
- 2026-10-06:v0.9 将 IME 位置刷新改为“焦点/caret/anchor 变化立即预设 + IME 生命周期再次校准”;探测事件新增触发来源与分项计数,并完成三平台构建及真实搜狗回归。 - 2026-10-06:v0.9 将 IME 位置刷新改为“焦点/caret/anchor 变化立即预设 + IME 生命周期再次校准”;探测事件新增触发来源与分项计数,并完成三平台构建及真实搜狗回归。
- 2026-10-06:搜狗修复后运行自动与人工状态均为 `pass`;三个 anchor 均按请求定位,F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前可用环境 0C-3 关闭,下一步进入 0B。 - 2026-10-06:搜狗修复后运行自动与人工状态均为 `pass`;三个 anchor 均按请求定位,F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前可用环境 0C-3 关闭,随后进入 0B。
- 2026-10-06:0B F24 `WM_KEYDOWN/UP` 自动 A/B 均通过;Forward 对应 `EX_KEY=present_once`,Consume 对应 `EX_KEY=absent`。错误 baseline 的请求 Consume 正确 fail-open 为 Forward。真实 IME 人工矩阵未完成,因此阶段 1 仍不启动。
- 2026-10-06:0B Composition 首轮人工运行无效:夹具未设置 IME 位置,Escape 规则错误匹配 `VK_ESCAPE` 而实际 KeyDown 为 `VK_PROCESSKEY + scanCode 1`,且出现 3 条锁竞争丢失。已实现固定 IMM32 锚点、原生三元组规则与双缓冲采集修正;微软拼音/搜狗 A/B 需重新执行。
- 2026-10-07:微软拼音 Forward/Consume 有效复验均通过;Forward 对应 EasyX 键出现一次,Consume 后 EasyX 键消失,位置链分别为 18/18 与 7/7,均零丢失、无孤立定位记录。
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
- 已知限制 / 遗留问题: - 已知限制 / 遗留问题:
- §5.3 的最终消费白名单必须等待阶段 0B 数据。 - §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。 - §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
- 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。 - 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。
## 落地信息 ## 落地信息
- 关联功能变更 ID:无 - 关联功能变更 ID:无
- 关联 BUG / Fix:无 - 关联 BUG / Fix:`BUG-20261006-0001` / `Fix-BUG-20261006-0001`;`BUG-20261006-0002` / `Fix-BUG-20261006-0002`
- Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复);`43cf4ac`(阶段 0C 视觉与 IME 坐标探测) - Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复);`43cf4ac`(阶段 0C 视觉与 IME 坐标探测)
- PR:无 - PR:无
- 发布版本:未发布 - 发布版本:未发布
@@ -1151,6 +1182,7 @@ struct ProbeConsumeRule
- [WM_IME_SETCONTEXT](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-setcontext) - [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) - [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) - [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw)
- [ImmGetVirtualKey](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immgetvirtualkey)
- [ImmNotifyIME](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immnotifyime) - [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) - [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) - [CANDIDATEFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-candidateform)
@@ -0,0 +1,628 @@
# 阶段 0B 原生键盘消息隔离消费实验规格
## 基本信息
- 模块 ID:Module-20261001-0004
- 模块名称:阶段 0B 原生键盘消息隔离消费实验规格
- 状态:0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按 `ime_condition_not_met` 运行时 fail-open 并形成 `MustForward` 结论;未建立生产消费白名单
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / InputProbe / EasyX Event Loop
- 版本 / 分支:`master`,0C 收口基线 `e514a7d`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX 25.9.10
- 负责人:Codex 协作设计
- 阶段定位:阶段 0 的消费行为隔离实验;执行顺序为 0A → 0C → 0B → 阶段 1。
## 背景与目标
### 背景
阶段 0A 已确认:
- EasyX HWND/WndProc 与宿主 `peekmessage` 循环分属两个线程。
- 固定键在 WndProc 与 EasyX `EX_KEY` 两个观察面均可出现。
- 当前 EasyX 窗口为 ANSI,活动代码页为 CP936。
- 0A 只观察并转发,没有验证 WndProc `return 0` 后 EasyX 事件面是否仍然产生相应 `EX_KEY`。
阶段 0C 已确认:
- 宽字符 GDI 绘制链可用。
- 当前 DPI-unaware 96 与 Per-Monitor-V2 144 坐标基线可用。
- 微软拼音与搜狗候选框定位链通过当前环境人工验收。
- 0B 可以引用可哈希的绘制/坐标基线判断环境是否可比。
阶段 1 的 InputCollector 必须决定每类消息采用 `capture-and-forward` 还是 `capture-and-consume`。如果跳过 0B,这个决定只能依赖推测;FocusManager 又依赖阶段 1 的队列和事件模型,因此 0B 是阶段 1 的硬前置,不与阶段 1 或 FocusManager 并行开发。
### 目标
0B 只回答一个问题:
> 对指定原生键盘消息,在现有 WndProc 层观察后不调用旧窗口过程、直接返回 0,是否安全;该动作是否阻断 EasyX 宿主侧对应的 `EX_KEY`,以及是否破坏 EasyX/IME 的既有行为。
具体目标:
1. 对照 `ObserveAndForward` 与 `ObserveAndConsume` 两种且仅两种 WndProc 动作。
2. 单次只改变消息类型、虚拟键、IME 状态和动作中的一个变量。
3. 单独测量消费 `WM_KEYDOWN` 后真实宿主 `peekmessage(EX_KEY)` 是否仍返回对应事件。
4. 分开记录 KeyDown/KeyUp,禁止把成对输入合并成一个模糊结论。
5. 在微软拼音与搜狗的 Idle/Composition 场景中复验候选、提交、取消和定位行为。
6. 输出阶段 1 可引用的消息级结论及证据,不直接实现生产 InputCollector 或下游路由抑制。
### 非目标
- 不实现 InputCollector、NativeInputQueue、FocusManager、KeyboardRouter 或 TextBox。
- 不决定 Button/Dialog 是否接收已转发消息。
- 不把“转发给旧窗口过程但不再路由给控件”作为第三种 WndProc 动作。
- 不在 0B 中新增 `TranslateMessage`、`DispatchMessage` 或额外 `peekmessage`。
- 不修改 EasyX、输入法、系统键盘布局或用户输入配置。
- 不把自动合成输入等同于真实键盘或真实 IME 验收。
## 分层边界
### WndProc 动作:严格两值
```cpp
enum class ProbeAction
{
ObserveAndForward,
ObserveAndConsume
};
```
- `ObserveAndForward`:完成观察后调用旧窗口过程,原样返回旧过程的 `LRESULT`。
- `ObserveAndConsume`:完成观察后不调用旧窗口过程,直接返回当前实验规定的消费值;首期仅允许异步键盘消息,消费值固定为 0。
- 不允许 `ForwardButSuppressRouting`、`ObserveOnly` 等第三种动作进入枚举。
“消息必须转发给 IME,但阶段 1 不应把它路由给 Button/Dialog”属于阶段 1 的队列和路由决策,不属于 0B 的 WndProc 动作。
### 实验结论:三值
```cpp
enum class ExperimentVerdict
{
SafeToConsume,
MustForward,
Inconclusive
};
```
- `SafeToConsume`:同环境对照有效,消费动作确实命中,自动与所需人工门槛均通过,且没有破坏 EasyX/IME 行为。
- `MustForward`:消费导致输入丢失、IME/候选异常、系统行为异常,或无法达到阶段 1 所需语义。
- `Inconclusive`:环境、规则、时序、证据或输入法状态存在任何不确定。
动作枚举和结论枚举不可混用。`Inconclusive` 不是第三种动作;运行时仍必须 fail-open 到 `ObserveAndForward`。
## 不变量
1. 生产默认路径永远没有消费规则。
2. 0B 必须同时满足编译期开关、0A 运行开关和独立 0B 运行开关才可装载规则。
3. 每个进程只允许一条规则;每轮实验只允许一个目标消息/虚拟键/IME 条件组合。
4. 规则未命中时调用旧窗口过程并原样返回。
5. 规则配置、环境、IME 状态或日志完整性存在不确定时调用旧窗口过程并原样返回。
6. 同步窗口管理消息禁止进入消费规则表。
7. WndProc 线程不写文件、不等待宿主线程、不调用新的消息泵 API。
8. 0B 复用 0A 的跨线程序列、缓冲、flush 和隐私边界,不另建不可比较的日志格式。
9. `sequence` 只描述探测器观察顺序,不伪装成 Windows 队列序号。
10. 任何 `records_lost` 都使相关消费结论降级为 `Inconclusive`。
## 规则模型
```cpp
struct ProbeConsumeRule
{
std::string experimentId;
std::string ruleId;
UINT message;
std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume
std::uint32_t maxMatches;
std::string baselineId;
InputSource inputSource; // SendInput / Manual
};
```
首期规则只接受:
```text
WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP
```
原因是首个核心问题是 WndProc 物理键消息与 EasyX `EX_KEY` 的关系。`WM_CHAR/WM_SYSCHAR/WM_IME_CHAR` 涉及 ANSI/DBCS、隐私和 IME 回声状态机,在首期键消息结论稳定后再按同一 schema 扩展,不与物理键首轮混测。
下列消息永远不得加入 0B 消费规则:
```text
WM_GETDLGCODE
WM_IME_SETCONTEXT
WM_UNICHAR + UNICODE_NOCHAR
WM_SETCURSOR
WM_MOUSEACTIVATE
WM_INPUTLANGCHANGEREQUEST
WM_INPUTLANGCHANGE
WM_CLOSE / WM_DESTROY / WM_NCDESTROY
```
### 规则配置
诊断构建通过以下环境字段装载唯一规则:
```text
STELLARX_INPUT_PROBE=1
STELLARX_INPUT_PROBE_0B=1
STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_RULE_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_ACTION=ObserveAndForward|ObserveAndConsume
STELLARX_INPUT_PROBE_0B_MESSAGE=WM_KEYDOWN|WM_KEYUP|WM_SYSKEYDOWN|WM_SYSKEYUP
STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=<十进制或 0x 十六进制>
STELLARX_INPUT_PROBE_0B_SCAN_CODE=<0..255;256 表示任意>
STELLARX_INPUT_PROBE_0B_IME_CONDITION=Any|IdleOnly|ComposingOnly
STELLARX_INPUT_PROBE_0B_MAX_MATCHES=<正整数,小上限>
STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=SendInput|Manual
STELLARX_INPUT_PROBE_0B_BASELINE_ID=<0C combinedBaselineId>
STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=0|1
STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=<枚举整数>
```
Composition 人工实验还使用固定、非文本的客户区锚点配置:
```text
STELLARX_INPUT_PROBE_0B_IME_CARET_X/Y
STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT/TOP/RIGHT/BOTTOM
```
当前夹具固定为 `720×420`,caret point 为 `(225,282)`,排除矩形为
`(200,255)-(680,284)`。探测器在焦点/IME context 建立以及组合生命周期中重复调用
`ImmSetCompositionWindow(CFS_POINT)` 与
`ImmSetCandidateWindow(CFS_EXCLUDE)`;两个 setter 至少一次同时成功,才允许把
Composition 消费实验视为位置基线可用。
`experimentId`、`ruleId` 只允许 ASCII 字母、数字、点、下划线和连字符;不得把用户输入或候选文字写入标识字段。
## 0C 基线引用契约
0B runner 必须读取 0C 的 `baseline-set-v1.json`,并在启动夹具前验证:
1. baseline sidecar SHA-256 与文件一致。
2. `drawingBaselineManifest` 和 `coordinateBaselineManifest` 存在。
3. 两份 manifest 的 SHA-256 与 baseline 内记录一致。
4. 用 `drawingSha256 + ':' + coordinateSha256` 重新计算的组合值等于 `combinedBaselineId`。
5. `automatedStatus=pass`。
6. DPI awareness、DPI、窗口 A/W 属性与本轮期望一致。
当前可用基线为:
```text
drawingBaselineSha256 BCD0E3DD5D90C5D6F892F9127D81FD124C1DC7D619260B236DBCB8E53CBEC9CA
coordinateBaselineSha256 35D15BF2DD54D9A57B1F8470C8415025F1BAE6CE98D4628F13B64AB1EC381000
combinedBaselineId sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5
dpiAwareness unaware
dpiForWindow 96
windowUnicode false
ansiCodePage 936(来自 0A 同环境证据)
```
0C 基线不自动证明实际 TSF 输入配置文件身份。Composition 人工实验还必须记录操作者确认的实际输入法名称和身份来源;脚本参数只能表示预期名称。
## fail-open 硬规则
出现以下任一情况时,规则不得消费,实际动作必须退回 `ObserveAndForward`:
1. 配置缺失、无法解析、超长或出现未知枚举值。
2. 规则消息不在首期允许列表。
3. 虚拟键越界、`maxMatches` 为 0 或超过上限。
4. 同一进程出现多条规则或同一事件存在模糊匹配。
5. baseline ID、DPI、DPI awareness、窗口 A/W 属性或代码页不匹配。
6. Composition 实验的实际输入法身份未经操作者确认。
7. IME 状态条件无法由当前证据可靠确认。
8. Composition 消费实验尚未成功设置固定组合窗和候选窗位置。
9. 已发生缓冲溢出、采集丢失或序列不完整。
10. 规则命中次数已经达到 `maxMatches`。
11. 目标 HWND、窗口实例或线程归属与会话 attach 记录不一致。
12. 任意同步窗口管理消息或未识别消息。
fail-open 必须在 WndProc 决策前完成;不能先消费、事后再把结果标记为 `Inconclusive`。
## 日志 schema
0B 是 0A schema 的可加性 v2,不新建平行日志:
- 被动 0A 会话仍可写 schema v1。
- 启用 0B 时会话写 schema v2。
- v2 保留 v1 全部公共字段和 record type,并新增可选实验字段。
新增 `experiment_config` 记录:
| 字段 | 说明 |
|---|---|
| `experimentId/ruleId` | 本轮实验与唯一规则标识 |
| `requestedAction` | 两值动作之一 |
| `targetMessage/targetVirtualKey/targetScanCode` | 唯一原生目标;scan code 可为 `null` 表示任意 |
| `imeCondition` | `any/idle_only/composing_only` |
| `maxMatches` | 最大命中次数 |
| `inputSource` | `SendInput/manual` |
| `baselineId` | 引用的 0C 组合基线 |
| `configurationValid` | 配置是否合法 |
| `baselineMatched` | 运行环境是否匹配 |
| `ruleEligible` | 是否允许进入匹配 |
| `failOpenReason` | 结构化原因;无问题为 `none` |
| `imePositionConfigured/imeCaretPoint/imeLineRect` | Composition 人工实验的固定 IMM32 客户区锚点 |
目标 `wndproc_enter` 增加:
```text
experimentId
ruleId
ruleMatched
matchOrdinal
requestedAction
actualAction
failOpenApplied
failOpenReason
```
对应 `wndproc_leave` 增加相同关联字段,并继续保留:
```text
wndProcCallId
forwarded
forwardResultHex
```
`easyx_key` 增加 `experimentId/ruleId` 会话标签,但不伪造与 WndProc 的直接 ID 关联。跨线程关联由受控实验协议、目标消息、虚拟键、方向、scan code、QPC 区间和序列窗口共同完成。
IMM32 定位结果使用独立 `ime_position` 记录:
| 字段 | 说明 |
|---|---|
| `parentSequence` | 触发定位请求且已经入队的 `wndproc_enter.sequence` |
| `wndProcCallId` | 触发定位的窗口过程调用 |
| `triggerMessage` | `WM_SETFOCUS/WM_IME_SETCONTEXT/WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` |
| `imePositionContextAvailable` | 是否取得当前 HWND 的 HIMC |
| `compositionPositionSucceeded/candidatePositionSucceeded` | 两个 setter 的独立结果 |
| `imeCaretPoint/imeLineRect` | 本次使用的不可变客户区坐标快照 |
`ime_position` 必须在 IMM32 调用完成后再分配序号,禁止预留低序号后跨锁补写;setter 同步重入产生的消息应自然排在父 enter 与结果记录之间。分析器必须校验 `parentSequence` 存在且小于结果序号。
## WndProc 执行语义
```text
WndProc enter
→ 0A 解析并记录原始消息
→ 0B 校验规则、基线、IME 条件、丢失状态和命中上限
→ 实际动作 ObserveAndForward
→ CallWindowProc(oldWndProc)
→ leave(forwarded=true, 原样 result)
→ 实际动作 ObserveAndConsume
→ 不调用 oldWndProc
→ leave(forwarded=false, result=0)
→ return 0
```
只有成功写入目标 enter 记录且决策证据完整时才允许消费。采集器使用两个固定记录缓冲:安全点在锁内只交换 active buffer、清空新 active 的索引,文件序列化在锁外完成;WndProc 使用极短的阻塞内存临界区,不再因 `try-lock` 失败主动丢弃观察。缓冲覆盖等任何实际丢失仍必须写 `records_lost`,并使实验失效。
固定 IME 定位严格执行三阶段协议:锁内提交父 enter 并复制请求,锁外调用全部定位相关 IMM32 API,再次加锁校验 `enabled + attachedHwnd + hwndInstanceId` 后提交结果。禁止在状态锁内调用 `ImmGetContext`、两个 setter 或 `ImmReleaseContext`。定位 API 调用期间使用 WndProc 线程局部标志阻止重入消息递归发起定位;重入消息本身仍按正常路径观察和转发。
## EX_KEY 核心测量
### 必答问题
> 在 WndProc 对目标 `WM_KEYDOWN` 执行 `ObserveAndConsume` 后,宿主线程现有的 `peekmessage(&msg, EX_KEY, true)` 是否仍返回同一虚拟键的 KeyDown?
该问题必须作为独立结果字段,而不是隐藏在总通过状态中:
```text
easyxVisibility = present_once | present_multiple | absent | inconclusive
```
解释:
- `absent`:WndProc 消费阻断了 EasyX 键盘面;阶段 1可以评估以采集队列作为唯一业务输入源。
- `present_once/present_multiple`:`return 0` 不足以消除 EasyX 事件;阶段 1 必须显式排空或忽略 EasyX 键盘事件,不能依赖消费避免双触发。
- `inconclusive`:对照缺失、额外输入污染、关联不唯一或日志丢失。
### 受控关联协议
每轮自动实验:
1. 启动独立进程和唯一 `experimentId`。
2. 等待 HWND、探测 attach 和空闲窗口稳定。
3. 在启用目标输入前排空历史事件;探针不得为实验新增第二个 `peekmessage`。
4. 只用一次 `SendInput` 产生固定虚拟键的 Down/Up。
5. 规则只匹配其中一个方向,另一个方向作为普通转发事件。
6. 等待宿主安全点 flush 后关闭窗口。
7. 使用目标 message、vk、可选 scan code、方向和受控时间窗关联 WndProc 与 `easyx_key`;指定 scan code 时,WndProc 和 EasyX 两个观察面都必须匹配该值。
8. 对照组和消费组分别使用新进程,禁止在同一进程热切换 action。
自动注入标记为 `inputSource=SendInput`。`PostMessage` 只可用于结构烟测,不能作为物理键/IME 行为结论。
## 实验矩阵
### 0B-1:核心自动 A/B
第一门槛固定为 Idle 状态的 F24:
| 组 | 消息 | VK | 动作 | 目标 |
|---|---|---:|---|---|
| Control | `WM_KEYDOWN` | `VK_F24` | Forward | 证明对照能同时看到 WndProc 和 `EX_KEY` |
| Experiment | `WM_KEYDOWN` | `VK_F24` | Consume | 判断消费后对应 `EX_KEY` 是否仍出现 |
| Control | `WM_KEYUP` | `VK_F24` | Forward | 建立 KeyUp 对照 |
| Experiment | `WM_KEYUP` | `VK_F24` | Consume | 判断 KeyUp 是否独立受影响 |
最初候选 F8 在当前中文 IME 环境中不适合作为自动门槛:`SendInput` 的 F8 KeyDown 没有到达目标 WndProc,仅观察到 KeyUp。当前证据没有确认它在哪一层被截断,也不据此推断 EasyX 消费关系;因此固定键改为当前环境中可稳定通过 WndProc 与 `EX_KEY` 两个观察面的 F24。
#### 0B-1 当前自动结论(2026-10-06)
| 目标 | Forward `EX_KEY` | Consume `EX_KEY` | 自动状态 | 当前结论 |
|---|---|---|---|---|
| `WM_KEYDOWN + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyDown |
| `WM_KEYUP + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyUp |
| 错误 baseline + 请求 Consume | `present_once`(实际 Forward) | 不适用 | `pass` | `baseline_id_mismatch` 正确 fail-open,规则零命中、零消费 |
三轮有效证据均满足:`sequenceStrict=true`、`recordsLost=0`、目标观察唯一、`closeObserved=true`、`sessionEndObserved=true`。证据如下(`TestResults` 为本地忽略目录,不进入源码提交):
```text
KeyDown A/B
TestResults/InputProbe/0B/20261006-103302-713-f22cbc3c-4d54-4da6-a3da-9b32cd0aeb43/comparison-v1.json
SHA-256 55B2160CF84594D653B5BF4B71A8DA8E9E2FF9795EB88BC16BA0F61E94DCD440
KeyUp A/B
TestResults/InputProbe/0B/20261006-103644-778-f4086f15-fbd7-4328-ac4c-fda378ef4594/comparison-v1.json
SHA-256 7A1381E8E6B13B3808754863DCD88B38B07D8E79630614A8ACB75649DE780194
baseline mismatch fail-open
TestResults/InputProbe/0B/20261006-103714-598-d0f4b1f1-dc03-47da-a039-ca2cb7523f7b/comparison-v1.json
SHA-256 A69A3646ECBA1FCACE921B15DB6A82FCD27F4E9B1747767DA500310250D7D520
```
上述证据只回答 F24 的 WndProc/`EX_KEY` 关系,尚不足以把任何生产消息判为 `SafeToConsume`;普通键、导航键、快捷键和真实 IME 人工矩阵完成前,最终 verdict 仍为 `Inconclusive`。
随后再扩展到:
- 普通字母键。
- Tab、方向键、Enter、Escape、Space。
- Ctrl 组合键。
- `WM_SYSKEYDOWN/UP`,但不得用会改变系统状态或关闭窗口的组合。
### 0B-2:自动稳定性
每轮同时验证:
- 进程、HWND 和宿主循环正常存活。
- 鼠标、窗口关闭和 resize 路径未受规则影响。
- enter/leave 配对符合实际动作。
- `sequence` 严格递增。
- 无原始文字泄露。
- 无未记账丢失。
- 对照与消费运行引用相同 0C baseline ID。
- runner 必须通过 `GetWindowThreadProcessId` 跟踪真实 UiFixture 进程;`cmd /c` 仅是环境变量启动包装,不能作为退出或超时判断对象。
- EasyX 旧窗口过程可能在 `WM_CLOSE` 内直接终止进程。探测构建须在转发 `WM_CLOSE` 前完成最后一次耐久 detach,写入 `window_detach/session_end`。
- 宿主 flush 与 WndProc 关闭收口必须由独立文件写锁串行,统一采用“文件写锁 → 状态锁”的锁序,禁止跨线程快照乱序落盘。
- 安全点不得在状态锁内复制或写出整批记录;双固定缓冲的交换必须为常数时间,避免 WndProc 因宿主 flush 占锁而丢失观察。
- 不得在状态锁内调用可能同步重入窗口过程的 IMM32 setter;`ime_position` 父序号关联失败时实验必须判为 `inconclusive_ime_position_link`。
### 0B-3:真实 IME 人工 A/B
微软拼音和搜狗分别测试,Idle 与 Composition 分开;每次只启用一个规则。固定非敏感测试串沿用 `ceshi`。
至少覆盖:
- 组合开始前的普通字母键。
- 候选态方向键和数字选择键。
- Enter 提交。
- Escape 取消。
- Space 选择候选。
- Ctrl 快捷键。
- 候选框位置是否仍符合 0C 三 anchor 基线。
- 提交后切换 anchor 是否出现 stale position。
Composition 条件只在日志确实观察到 composing 状态时匹配;如果某 IME 已显示候选 UI 但没有向目标 HWND 提供足够生命周期证据,规则必须 fail-open,不能按 Idle 误消费。单次证据不足仍为 `Inconclusive`;如果受控人工运行已经同时证明候选 UI 存在、目标键到达且 Composition 条件不可可靠确认,则该消息/IME 组合形成 `MustForward`:生产路径必须转发给旧窗口过程,并在阶段 1 抑制其进入 Button、Dialog 和焦点导航等业务路由。
#### 0B-3 首轮夹具发现(2026-10-06)
首轮微软拼音与搜狗测试不能作为正式 A/B 证据,原因不是两个输入法都定位失败,而是夹具本身存在三项诊断缺陷:
1. `input-consume-probe` 未调用两个 IMM32 定位 setter,候选框落在桌面左下角;这与已通过的 0C 坐标链不一致。
2. 操作者按 Escape 时,组合态 WndProc 实际看到的是
`WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`;`WM_KEYUP` 才恢复为
`VK_ESCAPE(27)`。因此原规则 `WM_KEYDOWN + VK_ESCAPE` 不可能命中。
3. 运行出现 3 条 `wndproc_lock_contention`,`recordsLost=3`,按本规格必须判为
`inconclusive_capture_loss`。
微软文档说明,IME 处理后会把虚拟键改为 `VK_PROCESSKEY`;应用只有在自己的消息循环中、调用 `TranslateMessage` 之前才能用 `ImmGetVirtualKey` 恢复原 VK。StellarX 当前钩子位于 EasyX 的 WndProc,时机已经晚于该边界,因此 0B 不伪造“逻辑 Escape”,而是让规则匹配实际可观察的原生三元组;操作者指令仍写“按 Escape”。参考:<https://learn.microsoft.com/windows/win32/api/immdev/nf-immdev-immgetvirtualkey>。
无效运行保留为夹具缺陷证据:
```text
TestResults/InputProbe/0B/20261006-140720-218-96b7392e-548a-44cf-9343-5d1449bf73fa
manual-evidence/composition-candidate-position-wrong.png
SHA-256 61E4A04CEE329B13BB22C919DC233A07ABF3EF6C298E64FB7B6E70F6D60DE0A3
```
该截图只证明候选框位于夹具窗口之外;运行混入多次组合且存在记录丢失,不用于区分微软拼音与搜狗,也不用于判断消费是否安全。
#### 0B-3 首次修复复验发现(2026-10-06)
首次微软拼音 Forward 复验出现窗口未响应和无法输入。根因是定位实现持有不可重入的 SRW 状态锁时调用两个 IMM32 setter;setter 同步重入 WndProc 后再次获取同一把锁,形成线程自锁。失败运行保留于:
```text
TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward
```
该日志耐久保存 268 条严格递增、零丢失记录,但没有关闭收口;卡死发生在下一次定位进入且早于新记录入队是结合调用链得出的推断,不把它表述为日志直接证明的栈。修复拆成锁内请求、锁外 IMM32、锁内结果,并增加递归定位保护;详见 `BUG-20261006-0003` / `Fix-BUG-20261006-0003`。
修复后的微软拼音 Forward 运行已经通过:
```text
TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward
comparison SHA-256 7CBEA9089E69BE921829BE49FC74B3F75E6BDAB8BB6DD252F4D4F1D6338AA456C
```
- `experimentValidity=valid`、`sequenceStrict=true`、`recordsLost=0`。
- Escape 原生规则唯一命中,`ObserveAndForward` leave 完整,对应 EasyX 键可见一次。
- `imePositionAttemptCount=18`、`imePositionSuccessCount=18`、`imePositionOrphanCount=0`。
- 组合开始/结束各 2 次,GCS 结果 1 次,关闭和 `session_end` 完整。
- 人工截图 `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png` 确认候选 UI 位于固定锚点附近;SHA-256 `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`。
该 Forward 运行关闭 `BUG-20261006-0003`;随后继续执行微软拼音 Consume 与搜狗运行,以形成消息消费 verdict。
微软拼音 Consume 第一次操作只完成了候选提交,没有按 Escape。日志中目标 scan code 1 计数为 0,runner 正确判为 `unexpected_target_count`;该运行只保留位置/响应证据,不计入 Consume 结论:
```text
TestResults/InputProbe/0B/20261006-235044-603-9c996ff5-f85b-4127-aac0-68d9f0b89908/consume
manual-evidence/microsoft-pinyin-consume-position-only-no-escape.png
SHA-256 B7B218BEDD343A8E2023A6FE18FC678E5488CB17B1C857D46A254EC06F9095FB
```
按“候选显示时只按一次 Escape”重跑后的微软拼音 Consume 有效:
```text
TestResults/InputProbe/0B/20261007-000152-550-4c83bcda-12e8-4f4e-b616-1d1fe1f56a27/consume
comparison SHA-256 BDB2E202EE7B050D74E6989208D7A63C2D74766292BC4410F5020213787CB698
```
- `experimentValidity=valid`、915 条记录严格递增、`recordsLost=0`。
- Escape 目标唯一命中,`consumedMatchCount=1`、`forwardedMatchCount=0`。
- 对应 EasyX 键为 `absent`,结构关系为 `easyx_suppressed_by_wndproc_consume`。
- `imePosition=7/7`、`imePositionOrphanCount=0`,关闭与 `session_end` 完整。
- `successfulResultCount=0`,与本轮执行取消而非提交一致;操作者完成候选消失检查后关闭窗口,未报告位置或响应异常。
微软拼音 A/B 当前对照为:Forward 时目标键进入 EasyX 一次,Consume 时目标键在 EasyX 面消失;两轮 IME 定位与窗口响应均正常。该结论仍限定于微软拼音和当前 ANSI/CP936、96 DPI 基线。
#### 0B-3 搜狗生命周期缺失与 MustForward 结论(2026-10-07)
搜狗 Forward 受控运行中,候选 UI 实际可见且位置正常,但目标 HWND 没有收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION/WM_IME_ENDCOMPOSITION`。操作者按 Escape 时仍观察到唯一的 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,探测状态为 `idle`,因此 `ComposingOnly` 规则没有命中:
```text
TestResults/InputProbe/0B/20261007-000604-592-4ddb4314-769e-4c4a-b3e8-696bd0489956/forward
JSONL SHA-256 539BE9BC378EB86825A1040D2A39BF7A1FD3C7F7C07966B67BBC753EB7F83608
```
- 1181 条记录严格递增、`recordsLost=0`、定位 6/6、无孤立定位结果、关闭收口完整。
- `experimentValidity=unexpected_match_count` 准确表示“请求的 ComposingOnly 条件不可证明”,不表示候选 UI 或 Forward 行为异常。
- 对应 EasyX 键为 `present_once`;该轮不得改写为 `IdleOnly` 后强行消费。
最终轮显式请求 `ObserveAndConsume`,并把预期运行时退让原因固定为 `ime_condition_not_met`:
```text
TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume
comparison SHA-256 0FD8E46493A6D84FD8C0BAD55D22CDB55B48F13BE94EB2831D39E14B7993FBEC
```
- `experimentValidity=runtime_fail_open`,目标观察唯一、规则命中 0 次、消费 0 次。
- enter/leave 的唯一原因均为 `ime_condition_not_met`,实际动作退回 `ObserveAndForward`,旧窗口过程被调用。
- `failOpenForwardedCount=1`,对应 EasyX 键为 `present_once`,结构关系为 `easyx_visible_after_fail_open_forward`。
- 582 条记录严格递增、`recordsLost=0`、定位 3/3、`imePositionOrphanCount=0`、关闭与 `session_end` 完整。
- 最终 verdict 为 `MustForward`。这里的“有效”是指 fail-open 契约得到验证,不是搜狗真实执行过 Consume。
阶段 1 因此采用保守且跨 IME 一致的基线:原生键默认采集并转发;`VK_PROCESSKEY` 和已确认组合态键不得路由到 Button、Dialog、快捷键或焦点导航。微软拼音的单点 Consume 成功只证明 WndProc 消费会阻断对应 EasyX 键面,不足以建立生产消费白名单。
### 0B-4:结论回写
每个消息/虚拟键/IME 状态组合必须输出:
```text
controlSessionId
consumeSessionId
baselineId
easyxVisibility
imeBehavior
windowBehavior
verdict
evidenceHashes
knownScope
```
只有完成所需自动和人工矩阵的组合才可判定 `SafeToConsume`;其余保持 `Inconclusive` 或因明确破坏判定 `MustForward`。
## 验收门槛
自动运行有效必须同时满足:
1. `experiment_config.ruleEligible=true`。
2. 正常 A/B 的目标规则恰好命中预期次数;预期运行时 fail-open 的目标观察次数必须符合 `maxMatches`、规则命中数必须为 0。
3. Forward 组对应 leave 为 `forwarded=true`。
4. 真正 Consume 组对应 leave 为 `forwarded=false` 且返回 0;预期 fail-open 组必须为 `actualAction=ObserveAndForward`、`forwarded=true`。
5. Forward 对照组至少出现一次对应 `easyx_key`。
6. Consume 或运行时 fail-open 组明确得到 `present_once/present_multiple/absent` 之一。
7. `sequence` 严格递增,enter/leave 可配对。
8. `records_lost=0`,无缓冲溢出或锁竞争丢失。
9. 原生关闭与 `session_end` 完整。
10. 两组 baseline ID 和关键环境字段一致。
11. 运行时 fail-open 的 enter/leave 原因必须唯一且一致,分析结果必须为 `runtime_fail_open`;原因不明或半边错配时仍为 `Inconclusive`。
人工 IME 运行有效还要求:
1. 操作者明确确认实际输入法身份。
2. 使用固定非敏感输入。
3. 记录候选、提交、取消是否正常。
4. 位置判断引用相同环境的 0C 基线。
5. 日志至少有一次 `ImmGetContext`、composition setter 与 candidate setter 同时成功,且 caret/line rect 等于固定夹具锚点。
6. 自动失败不能被人工通过覆盖。
## 隐私与安全
- 沿用 0A:文字只记来源、长度、结构和会话摘要,不记原文。
- 0B 配置不允许携带用户输入文本。
- 自动测试使用 F24 等非文字键作为第一门槛;当前环境不使用 KeyDown 未到达目标 WndProc 的 F8。
- 人工 IME 只使用约定的固定非敏感测试串。
- 截图只包含探测窗口和候选 UI,不包含其他应用。
- 无论探测错误、日志错误还是环境错误,默认行为都是转发。
## 实现与影响
- 已复用并实现:
- `src/StellarX/SxInputProbe.cpp`
- `src/StellarX/internal/SxInputProbeInternal.h`
- `src/StellarX/Window.cpp`
- `tests/UiFixture.cpp`
- `scripts/analyze-input-probe.ps1`
- 已新增:
- `scripts/run-input-consume-probe.ps1`:自动 A/B、单规则人工模式、0C baseline 校验、真实进程跟踪和 comparison 输出。
- `scripts/run-input-consume-probe-manual.ps1`:真实 IME 人工单规则入口和固定 `ceshi` checklist。
- 分析摘要中的实验有效性、`easyxVisibility`、关闭完整性和 fail-open 证据字段。
- 可选 scan code 精确规则、固定 IME caret/line rect、IMM32 定位调用及位置成功计数。
- 双固定记录缓冲;安全点常数时间交换、锁外文件写入,取消 WndProc `try-lock` 丢记录路径。
- IMM32 定位三阶段拆锁、线程内递归定位保护、独立 `ime_position` 结果记录与 `parentSequence` 校验。
- 不修改公共 API。
- 不新增生产焦点或键盘路由行为。
- 探测关闭时不产生文件、线程、定时器或消息返回差异。
## 测试与验证
- F24 `WM_KEYDOWN/UP` 的 Forward/Consume 自动 A/B 已完成,均直接报告消费后 `EX_KEY=absent`。
- 错误 baseline 的请求 Consume 负向实验已完成,实际动作正确 fail-open 为 Forward。
- 默认 Debug x64 构建与现有单元测试通过(15/15);探测开关构建已由三轮最终自动实验覆盖。
- 三份 PowerShell 脚本在 Windows PowerShell 5.1.22000.2538 与 PowerShell 7.6.5 下均为零解析错误;`git diff --check` 无空白错误,仅有仓库换行规范提示。
- 0B-3 重入死锁修复后的微软拼音 Forward/Consume 已通过;搜狗候选态未暴露可证明的 Composition 生命周期,最终 Consume 请求按 `ime_condition_not_met` 正确 fail-open 为 Forward,形成 `MustForward` 结论。
- 2026-10-07 最终回归:Debug x64 单元测试 15/15、UI fixture 1 轮通过;F24 KeyDown A/B 新运行通过(comparison SHA-256 `1C1E4EDA1251A460AC250AE57F4F8A5FC8AFE4A9C0EDA87C731BA3C6301FB6FF`),既有 KeyUp 与 baseline mismatch 证据使用当前分析器回放仍分别为 `valid` 与 `ineligible_fail_open`。
- 阶段 1 可以按“默认采集并转发、EasyX `EX_KEY` 只排空、`VK_PROCESSKEY` 与已确认组合态键不进入业务路由”的基线开始;任何生产消费白名单仍需新的逐键证据。
- 构建至少覆盖 Debug x64 探测开关开启、默认关闭构建和现有 15 个单元测试。
- PowerShell 脚本必须同时通过 Windows PowerShell 5.1 与可用的 PowerShell 7 解析检查。
## 已知限制
- 自动 `SendInput` 不等同于物理键盘。
- 当前中文 IME 环境下,自动 F8 KeyDown 未到达目标 WndProc;截断层级尚未确认,因此 F8 运行只作为“门槛键选择失败”记录,不作为消费行为证据。
- 相同中文 HKL 下的 TSF 输入配置文件不能只靠 HKL 自动区分。
- 当前 0C 基线没有覆盖 100%、125% 和跨屏;不同环境的 0B IME 位置结论必须为 `Inconclusive`。
- 首期不消费文字和 IME 消息,只先回答物理键与 EasyX `EX_KEY` 的核心关系。
- WndProc 层只能可靠记录 `VK_PROCESSKEY` 与 scan code,不能在 EasyX 已调用 `TranslateMessage` 后再依赖 `ImmGetVirtualKey` 恢复原始逻辑键。
- 搜狗候选 UI 可见时可能不向目标 HWND 发送标准 Composition 生命周期;不得把 `imeState=idle` 推断为“当前键不属于 IME”。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:`BUG-20261006-0002` / `Fix-BUG-20261006-0002`;`BUG-20261006-0003` / `Fix-BUG-20261006-0003`
- Commit:待用户确认后提交
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)