feat(diagnostic): 完成阶段 0C 视觉与 IME 坐标探测
This commit is contained in:
@@ -13,6 +13,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.UiFixture", "tests
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.ThreadProbe", "tests\EasyX.ThreadProbe.vcxproj", "{6D338118-A536-4E4F-A4F5-3C73C5CA107A}"
|
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.ThreadProbe", "tests\EasyX.ThreadProbe.vcxproj", "{6D338118-A536-4E4F-A4F5-3C73C5CA107A}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.VisualProbe", "tests\EasyX.VisualProbe.vcxproj", "{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|x64 = Debug|x64
|
Debug|x64 = Debug|x64
|
||||||
@@ -61,6 +63,14 @@ Global
|
|||||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.Build.0 = Release|x64
|
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.Build.0 = Release|x64
|
||||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.ActiveCfg = Release|Win32
|
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.ActiveCfg = Release|Win32
|
||||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.Build.0 = Release|Win32
|
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.Build.0 = Release|Win32
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.ActiveCfg = Debug|x64
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.Build.0 = Debug|x64
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.ActiveCfg = Debug|Win32
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.Build.0 = Debug|Win32
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.ActiveCfg = Release|x64
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.Build.0 = Release|x64
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.ActiveCfg = Release|Win32
|
||||||
|
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.Build.0 = Release|Win32
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SessionId,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$StartedAtUtc,
|
||||||
|
[int]$ProbeExitCode = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Get-FailureCategory {
|
||||||
|
param([object]$Case)
|
||||||
|
if (-not $Case.evidenceComplete) { return 'evidence_missing' }
|
||||||
|
if (-not $Case.targetChanged) { return 'surface_unreachable' }
|
||||||
|
if (-not $Case.nonTargetUnchanged) { return 'surface_cross_write' }
|
||||||
|
if (-not $Case.gdiStateRestored) { return 'gdi_state_not_restored' }
|
||||||
|
if (-not $Case.wideContentPreservedAfterEasyXFollowUp) {
|
||||||
|
return 'measurement_relation_invalid'
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-WideTextCases {
|
||||||
|
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||||
|
$items = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($case in @($Raw.result.cases)) {
|
||||||
|
$caseFailure = Get-FailureCategory -Case $case
|
||||||
|
$caseEvidence = @($EvidenceFiles | Where-Object {
|
||||||
|
$_.path -like "evidence/$($case.testCaseId)_*"
|
||||||
|
})
|
||||||
|
$phases = @('after_draw')
|
||||||
|
if ($case.batchState -eq 'inside') { $phases += @('after_flush', 'after_end') }
|
||||||
|
foreach ($text in @($case.textTests)) {
|
||||||
|
$textFailure = $caseFailure
|
||||||
|
if (-not $textFailure -and (-not $text.isValidUtf16 -or
|
||||||
|
-not $text.getTextExtentPoint32W -or -not $text.textOutW -or
|
||||||
|
[int]$text.drawTextWHeight -le 0)) {
|
||||||
|
$textFailure = 'measurement_relation_invalid'
|
||||||
|
}
|
||||||
|
foreach ($phase in $phases) {
|
||||||
|
$fingerprint = switch ($phase) {
|
||||||
|
'after_draw' { $case.observations.afterDraw; break }
|
||||||
|
'after_flush' { $case.observations.afterFlush; break }
|
||||||
|
'after_end' { $case.observations.afterEnd; break }
|
||||||
|
}
|
||||||
|
$status = if ($textFailure) { 'fail' } else { 'pass' }
|
||||||
|
$items.Add([pscustomobject][ordered]@{
|
||||||
|
testCaseId = "$($case.testCaseId)/$($text.testStringId)/$phase"
|
||||||
|
testStringId = $text.testStringId
|
||||||
|
codePoints = @($text.codePoints)
|
||||||
|
utf16Units = @($text.utf16Units)
|
||||||
|
surfaceId = $case.surfaceId
|
||||||
|
batchState = $case.batchState
|
||||||
|
observationPhase = $phase
|
||||||
|
hdc = $fingerprint.hdc
|
||||||
|
hbitmap = $fingerprint.hbitmap
|
||||||
|
hfont = $fingerprint.hfont
|
||||||
|
bitmapWidth = $fingerprint.bitmapWidth
|
||||||
|
bitmapHeight = $fingerprint.bitmapHeight
|
||||||
|
planes = $fingerprint.planes
|
||||||
|
bitsPerPixel = $fingerprint.bitsPerPixel
|
||||||
|
stride = $fingerprint.stride
|
||||||
|
dibSection = $fingerprint.dibSection
|
||||||
|
logFont = $fingerprint.logFont
|
||||||
|
gdiStateBefore = $case.before.gdiState
|
||||||
|
gdiStateAfter = $fingerprint.gdiState
|
||||||
|
apiResults = [pscustomobject][ordered]@{
|
||||||
|
getTextExtentPoint32W = $text.getTextExtentPoint32W
|
||||||
|
textOutW = $text.textOutW
|
||||||
|
drawTextWHeight = $text.drawTextWHeight
|
||||||
|
}
|
||||||
|
logicalExtent = $text.logicalExtent
|
||||||
|
textOutInkBounds = $text.textOutInkBounds
|
||||||
|
drawTextInkBounds = $text.drawTextInkBounds
|
||||||
|
targetPixelsBefore = $case.before.pixelDigest
|
||||||
|
targetPixelsAfter = $fingerprint.pixelDigest
|
||||||
|
controlPixelsBefore = $case.nonTargetBefore.pixelDigest
|
||||||
|
controlPixelsAfter = $case.nonTargetAfterDraw.pixelDigest
|
||||||
|
easyXFollowUp = [pscustomobject][ordered]@{
|
||||||
|
wideContentPreserved = $case.wideContentPreservedAfterEasyXFollowUp
|
||||||
|
surfaceFingerprint = $case.observations.afterEasyXFollowUp
|
||||||
|
}
|
||||||
|
automatedStatus = $status
|
||||||
|
manualStatus = 'not_reviewed'
|
||||||
|
failureCategory = $textFailure
|
||||||
|
evidenceFiles = $caseEvidence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-DpiCoordinateCases {
|
||||||
|
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||||
|
$failure = $null
|
||||||
|
if ($EvidenceFiles.Count -lt @($Raw.evidenceFiles).Count) { $failure = 'evidence_missing' }
|
||||||
|
elseif (-not $Raw.result.gdiStateRestored) { $failure = 'gdi_state_not_restored' }
|
||||||
|
elseif (-not $Raw.result.roundTripsExact) { $failure = 'coordinate_transform_invalid' }
|
||||||
|
elseif (-not $Raw.result.gdiExtentSuccess -or -not $Raw.result.gdiTextSuccess) {
|
||||||
|
$failure = 'measurement_relation_invalid'
|
||||||
|
}
|
||||||
|
$status = if ($failure) { 'fail' } else { 'pass' }
|
||||||
|
$decision = if ($Raw.result.implicitScalingObserved) {
|
||||||
|
'requires_explicit_transform_validation'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'direct_hwnd_relative_coordinates_supported_at_observed_dpi'
|
||||||
|
}
|
||||||
|
return @([pscustomobject][ordered]@{
|
||||||
|
testCaseId = 'dpi-coordinate-current-environment'
|
||||||
|
surfaceId = 'main'
|
||||||
|
batchState = $Raw.result.batchState
|
||||||
|
observationPhase = 'after_end'
|
||||||
|
dpiAwareness = [pscustomobject][ordered]@{
|
||||||
|
process = $Raw.environment.processAwareness
|
||||||
|
thread = $Raw.environment.threadAwareness
|
||||||
|
window = $Raw.environment.windowAwareness
|
||||||
|
}
|
||||||
|
windowDpi = $Raw.environment.dpiForWindow
|
||||||
|
clientRect = $Raw.environment.clientRect
|
||||||
|
windowScreenRect = $Raw.environment.windowRect
|
||||||
|
surfaceSize = [pscustomobject]@{ width = 960; height = 720 }
|
||||||
|
surfaceFingerprint = $Raw.result.surfaceFingerprintAfterDraw
|
||||||
|
surfaceBounds = $Raw.result.surfaceBounds
|
||||||
|
clientBounds = $Raw.result.clientBounds
|
||||||
|
coordinateRoundTrips = @($Raw.result.coordinateRoundTrips)
|
||||||
|
surfaceMarkerSizesAgree = $Raw.result.surfaceMarkerSizesAgree
|
||||||
|
clientMatchesSurface = $Raw.result.clientMatchesSurface
|
||||||
|
implicitScalingObserved = $Raw.result.implicitScalingObserved
|
||||||
|
coordinateDecision = $decision
|
||||||
|
automatedStatus = $status
|
||||||
|
manualStatus = 'not_reviewed'
|
||||||
|
failureCategory = $failure
|
||||||
|
evidenceFiles = $EvidenceFiles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-ImePositionCases {
|
||||||
|
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||||
|
$items = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($anchor in @($Raw.result.anchors)) {
|
||||||
|
$events = @($Raw.result.imeEvents | Where-Object {
|
||||||
|
$_.syntheticAnchorId -eq $anchor.syntheticAnchorId -and $_.apiAttempted
|
||||||
|
})
|
||||||
|
$successful = @($events | Where-Object {
|
||||||
|
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||||
|
})
|
||||||
|
$preconfigureEvents = @($events | Where-Object {
|
||||||
|
$_.positionTrigger -eq 'anchor_change' -or $_.stage -eq 'anchor_preconfigure'
|
||||||
|
})
|
||||||
|
$successfulPreconfigure = @($preconfigureEvents | Where-Object {
|
||||||
|
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||||
|
})
|
||||||
|
$lifecycleEvents = @($events | Where-Object {
|
||||||
|
$_.positionTrigger -eq 'ime_lifecycle' -or
|
||||||
|
$_.stage -eq 'ime_start' -or $_.stage -eq 'ime_composition'
|
||||||
|
})
|
||||||
|
$successfulLifecycle = @($lifecycleEvents | Where-Object {
|
||||||
|
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||||
|
})
|
||||||
|
$latest = $events | Select-Object -Last 1
|
||||||
|
$failure = if ($successful.Count -gt 0) { $null } else { 'ime_position_invalid' }
|
||||||
|
$status = if ($failure) { 'fail' } else { 'pass' }
|
||||||
|
$anchorEvidence = @($EvidenceFiles | Where-Object {
|
||||||
|
$_.path -like "evidence/$($anchor.syntheticAnchorId)_*" -or
|
||||||
|
$_.path -like "manual-evidence/$($anchor.syntheticAnchorId)_*"
|
||||||
|
})
|
||||||
|
$items.Add([pscustomobject][ordered]@{
|
||||||
|
testCaseId = "ime-position/$($anchor.syntheticAnchorId)"
|
||||||
|
syntheticAnchorId = $anchor.syntheticAnchorId
|
||||||
|
anchorCoordinates = [pscustomobject][ordered]@{
|
||||||
|
controlOrigin = $anchor.controlOrigin
|
||||||
|
controlLocalCaretTopLeft = $anchor.controlLocalCaretTopLeft
|
||||||
|
controlLocalCaretBottom = $anchor.controlLocalCaretBottom
|
||||||
|
controlLocalLineRect = $anchor.controlLocalLineRect
|
||||||
|
easyxSurfaceCaretTopLeft = $anchor.easyxSurfaceCaretTopLeft
|
||||||
|
easyxSurfaceCaretBottom = $anchor.easyxSurfaceCaretBottom
|
||||||
|
easyxSurfaceLineRect = $anchor.easyxSurfaceLineRect
|
||||||
|
targetHwndRelativePoint = $anchor.targetHwndRelativePoint
|
||||||
|
diagnosticScreenPoint = if ($latest) { $latest.diagnosticScreenPoint } else { $null }
|
||||||
|
}
|
||||||
|
compositionForm = if ($latest) { $latest.compositionForm } else { $null }
|
||||||
|
candidateForm = if ($latest) { $latest.candidateForm } else { $null }
|
||||||
|
imeId = if ($latest) { $latest.imeDescriptionUtf16 } else { @() }
|
||||||
|
imeFileName = if ($latest) { $latest.imeFileNameUtf16 } else { @() }
|
||||||
|
hkl = if ($latest) { $latest.hkl } else { $null }
|
||||||
|
apiAttemptCount = $events.Count
|
||||||
|
successfulApiAttemptCount = $successful.Count
|
||||||
|
preconfigureApiAttemptCount = $preconfigureEvents.Count
|
||||||
|
successfulPreconfigureApiAttemptCount = $successfulPreconfigure.Count
|
||||||
|
lifecycleApiAttemptCount = $lifecycleEvents.Count
|
||||||
|
successfulLifecycleApiAttemptCount = $successfulLifecycle.Count
|
||||||
|
positionDisposition = 'not_observable'
|
||||||
|
automatedStatus = $status
|
||||||
|
manualStatus = 'not_reviewed'
|
||||||
|
failureCategory = $failure
|
||||||
|
evidenceFiles = $anchorEvidence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return $items.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = Resolve-Path -LiteralPath $Path
|
||||||
|
$item = Get-Item -LiteralPath $resolved
|
||||||
|
$directory = if ($item.PSIsContainer) { $item.FullName } else { $item.DirectoryName }
|
||||||
|
$rawPath = if ($item.PSIsContainer) { Join-Path $directory 'raw-result.json' } else { $item.FullName }
|
||||||
|
if (-not (Test-Path -LiteralPath $rawPath)) { throw "Raw result not found: $rawPath" }
|
||||||
|
|
||||||
|
$raw = Get-Content -Raw -LiteralPath $rawPath | ConvertFrom-Json
|
||||||
|
if ([int]$raw.rawSchemaVersion -ne 1) { throw "Unsupported raw schema: $($raw.rawSchemaVersion)" }
|
||||||
|
if ($raw.mode -notin @('wide-text', 'dpi-coordinate', 'ime-position')) {
|
||||||
|
throw "Unsupported probe mode: $($raw.mode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($entry in @($raw.evidenceFiles)) {
|
||||||
|
$relativePath = [string]$entry.path
|
||||||
|
$nativeRelative = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
$fullPath = Join-Path $directory $nativeRelative
|
||||||
|
if (-not (Test-Path -LiteralPath $fullPath)) { continue }
|
||||||
|
$file = Get-Item -LiteralPath $fullPath
|
||||||
|
$evidence.Add([pscustomobject][ordered]@{
|
||||||
|
path = $relativePath
|
||||||
|
purpose = $entry.purpose
|
||||||
|
captureBackend = $entry.captureBackend
|
||||||
|
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
|
||||||
|
byteLength = $file.Length
|
||||||
|
crop = $null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$rawEvidencePresent = $evidence.Count
|
||||||
|
$manualEvidenceDirectory = Join-Path $directory 'manual-evidence'
|
||||||
|
if (Test-Path -LiteralPath $manualEvidenceDirectory) {
|
||||||
|
Get-ChildItem -LiteralPath $manualEvidenceDirectory -Filter '*.png' -File | ForEach-Object {
|
||||||
|
$evidence.Add([pscustomobject][ordered]@{
|
||||||
|
path = 'manual-evidence/' + $_.Name
|
||||||
|
purpose = 'manually cropped IME position evidence'
|
||||||
|
captureBackend = 'manual_crop'
|
||||||
|
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash
|
||||||
|
byteLength = $_.Length
|
||||||
|
crop = 'user_supplied'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidenceArray = $evidence.ToArray()
|
||||||
|
$testCases = if ($raw.mode -eq 'wide-text') {
|
||||||
|
New-WideTextCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||||
|
}
|
||||||
|
elseif ($raw.mode -eq 'dpi-coordinate') {
|
||||||
|
New-DpiCoordinateCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-ImePositionCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||||
|
}
|
||||||
|
|
||||||
|
$overallManualStatus = 'not_reviewed'
|
||||||
|
$sourceManualReview = $null
|
||||||
|
$manualReviewSummary = $null
|
||||||
|
$manualReviewPath = Join-Path $directory 'manual-review-v1.json'
|
||||||
|
if (Test-Path -LiteralPath $manualReviewPath) {
|
||||||
|
$manualReview = Get-Content -Raw -LiteralPath $manualReviewPath | ConvertFrom-Json
|
||||||
|
if ([int]$manualReview.manualReviewSchemaVersion -ne 1) {
|
||||||
|
throw "Unsupported manual review schema: $($manualReview.manualReviewSchemaVersion)"
|
||||||
|
}
|
||||||
|
if ([string]$manualReview.mode -ne [string]$raw.mode) {
|
||||||
|
throw "Manual review mode does not match raw result: $($manualReview.mode)"
|
||||||
|
}
|
||||||
|
$allowedManualStatuses = @('pass', 'fail', 'inconclusive', 'not_reviewed', 'not_available')
|
||||||
|
$allowedPositionDispositions = @('as_requested', 'system_adjusted', 'not_observable')
|
||||||
|
$reviewedIds = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($reviewCase in @($manualReview.testCases)) {
|
||||||
|
$testCaseId = [string]$reviewCase.testCaseId
|
||||||
|
if ($reviewedIds.Contains($testCaseId)) { throw "Duplicate manual review case: $testCaseId" }
|
||||||
|
$reviewedIds.Add($testCaseId)
|
||||||
|
$matchingCases = @($testCases | Where-Object testCaseId -eq $testCaseId)
|
||||||
|
if ($matchingCases.Count -ne 1) { throw "Unknown manual review case: $testCaseId" }
|
||||||
|
$manualStatus = [string]$reviewCase.manualStatus
|
||||||
|
$positionDisposition = [string]$reviewCase.positionDisposition
|
||||||
|
if ($manualStatus -notin $allowedManualStatuses) {
|
||||||
|
throw "Unsupported manual status for ${testCaseId}: $manualStatus"
|
||||||
|
}
|
||||||
|
if ($positionDisposition -notin $allowedPositionDispositions) {
|
||||||
|
throw "Unsupported position disposition for ${testCaseId}: $positionDisposition"
|
||||||
|
}
|
||||||
|
$targetCase = $matchingCases[0]
|
||||||
|
if ($manualStatus -eq 'pass' -and $targetCase.automatedStatus -ne 'pass') {
|
||||||
|
throw "Manual pass cannot override automated failure: $testCaseId"
|
||||||
|
}
|
||||||
|
$reviewEvidence = @($reviewCase.evidenceFiles)
|
||||||
|
if ($manualStatus -eq 'pass' -and $reviewEvidence.Count -eq 0) {
|
||||||
|
throw "Manual pass requires explicit evidence files: $testCaseId"
|
||||||
|
}
|
||||||
|
foreach ($relativePath in $reviewEvidence) {
|
||||||
|
$relative = [string]$relativePath
|
||||||
|
if ($relative -notlike 'manual-evidence/*') {
|
||||||
|
throw "Manual review evidence must be under manual-evidence: $relative"
|
||||||
|
}
|
||||||
|
$matchingEvidence = @($targetCase.evidenceFiles | Where-Object path -eq $relative)
|
||||||
|
if ($matchingEvidence.Count -ne 1) {
|
||||||
|
throw "Manual review evidence is missing or belongs to another case: $relative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($manualStatus -eq 'pass' -and $positionDisposition -eq 'not_observable') {
|
||||||
|
throw "Manual pass requires an observable position disposition: $testCaseId"
|
||||||
|
}
|
||||||
|
$targetCase.manualStatus = $manualStatus
|
||||||
|
$targetCase.positionDisposition = $positionDisposition
|
||||||
|
$targetCase | Add-Member -NotePropertyName manualReviewNotes `
|
||||||
|
-NotePropertyValue ([string]$reviewCase.notes) -Force
|
||||||
|
}
|
||||||
|
if ($reviewedIds.Count -ne @($testCases).Count) {
|
||||||
|
throw 'Manual review must cover every manifest test case'
|
||||||
|
}
|
||||||
|
$caseManualStatuses = @($testCases | ForEach-Object { $_.manualStatus })
|
||||||
|
$derivedManualStatus = if ($caseManualStatuses -contains 'fail') {
|
||||||
|
'fail'
|
||||||
|
}
|
||||||
|
elseif ($caseManualStatuses -contains 'inconclusive') {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
elseif ($caseManualStatuses -contains 'not_reviewed') {
|
||||||
|
'not_reviewed'
|
||||||
|
}
|
||||||
|
elseif ($caseManualStatuses -contains 'not_available') {
|
||||||
|
if (@($caseManualStatuses | Where-Object { $_ -ne 'not_available' }).Count -eq 0) {
|
||||||
|
'not_available'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'pass'
|
||||||
|
}
|
||||||
|
if ([string]$manualReview.overallStatus -ne $derivedManualStatus) {
|
||||||
|
throw "Manual review overall status mismatch: expected $derivedManualStatus"
|
||||||
|
}
|
||||||
|
$overallManualStatus = $derivedManualStatus
|
||||||
|
$sourceManualReview = [pscustomobject][ordered]@{
|
||||||
|
path = 'manual-review-v1.json'
|
||||||
|
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $manualReviewPath).Hash
|
||||||
|
}
|
||||||
|
$manualReviewSummary = [pscustomobject][ordered]@{
|
||||||
|
reviewedAtUtc = $manualReview.reviewedAtUtc
|
||||||
|
reviewer = $manualReview.reviewer
|
||||||
|
expectedImeName = $manualReview.expectedImeName
|
||||||
|
actualImeName = $manualReview.actualImeName
|
||||||
|
identitySource = $manualReview.identitySource
|
||||||
|
notes = $manualReview.notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$failedCases = @($testCases | Where-Object automatedStatus -eq 'fail')
|
||||||
|
$allEvidencePresent = $rawEvidencePresent -eq @($raw.evidenceFiles).Count
|
||||||
|
$overallFailure = $null
|
||||||
|
if (-not $allEvidencePresent) { $overallFailure = 'evidence_missing' }
|
||||||
|
elseif ($failedCases.Count -gt 0) { $overallFailure = $failedCases[0].failureCategory }
|
||||||
|
elseif (-not $raw.completed -or $ProbeExitCode -ne 0) { $overallFailure = 'inconclusive_probe_exit' }
|
||||||
|
$overallStatus = if ($overallFailure) { 'fail' } else { 'pass' }
|
||||||
|
|
||||||
|
$manifest = [pscustomobject][ordered]@{
|
||||||
|
visualProbeSchemaVersion = 1
|
||||||
|
sessionId = $SessionId
|
||||||
|
startedAtUtc = $StartedAtUtc
|
||||||
|
completedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||||
|
mode = $raw.mode
|
||||||
|
processId = $raw.environment.processId
|
||||||
|
threadId = $raw.environment.threadId
|
||||||
|
osBuild = $raw.environment.osBuild
|
||||||
|
easyxVersion = $raw.environment.easyxVersion
|
||||||
|
probeExecutableExitCode = $ProbeExitCode
|
||||||
|
dpiConfiguration = $raw.dpiConfiguration
|
||||||
|
environment = $raw.environment
|
||||||
|
automatedStatus = $overallStatus
|
||||||
|
manualStatus = $overallManualStatus
|
||||||
|
failureCategory = $overallFailure
|
||||||
|
sourceRawResult = [pscustomobject][ordered]@{
|
||||||
|
path = 'raw-result.json'
|
||||||
|
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $rawPath).Hash
|
||||||
|
}
|
||||||
|
sourceManualReview = $sourceManualReview
|
||||||
|
manualReview = $manualReviewSummary
|
||||||
|
evidenceFiles = $evidenceArray
|
||||||
|
testCases = @($testCases)
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $directory 'manifest-v1.json'
|
||||||
|
$manifestJson = $manifest | ConvertTo-Json -Depth 100
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
[IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8Bom)
|
||||||
|
$manifestHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash
|
||||||
|
$sidecarPath = $manifestPath + '.sha256'
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
[IO.File]::WriteAllText($sidecarPath, "$manifestHash *manifest-v1.json`r`n", $utf8NoBom)
|
||||||
|
|
||||||
|
[pscustomobject][ordered]@{
|
||||||
|
mode = $raw.mode
|
||||||
|
manifestPath = $manifestPath
|
||||||
|
manifestSha256 = $manifestHash
|
||||||
|
automatedStatus = $overallStatus
|
||||||
|
manualStatus = $overallManualStatus
|
||||||
|
testCaseCount = @($testCases).Count
|
||||||
|
evidenceCount = $evidence.Count
|
||||||
|
dpiForWindow = $raw.environment.dpiForWindow
|
||||||
|
windowAwareness = $raw.environment.windowAwareness
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
param(
|
||||||
|
[ValidateSet('Debug', 'Release')]
|
||||||
|
[string]$Configuration = 'Debug',
|
||||||
|
[ValidateSet('x64', 'Win32')]
|
||||||
|
[string]$Platform = 'x64',
|
||||||
|
[ValidateSet('All', 'wide-text', 'dpi-coordinate', 'ime-position')]
|
||||||
|
[string]$Mode = 'All',
|
||||||
|
[ValidateSet('inherit', 'unaware', 'system', 'per-monitor', 'per-monitor-v2')]
|
||||||
|
[string]$DpiAwareness = 'inherit',
|
||||||
|
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
|
||||||
|
[string]$ImeName = 'unspecified'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||||
|
$project = Join-Path $root 'tests\EasyX.VisualProbe.vcxproj'
|
||||||
|
$probe = Join-Path $root "build\$Platform\$Configuration\EasyX.VisualProbe.exe"
|
||||||
|
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||||
|
|
||||||
|
$pathKeyCount = @(
|
||||||
|
[Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
|
||||||
|
Where-Object { [string]$_ -ieq 'Path' }
|
||||||
|
).Count
|
||||||
|
if ($pathKeyCount -gt 1) {
|
||||||
|
$buildCommand = 'set "PATH=" & "' + $msbuild + '" "' + $project +
|
||||||
|
'" /m /t:Build /p:Configuration=' + $Configuration + ' /p:Platform=' + $Platform + ' /v:minimal'
|
||||||
|
& $env:ComSpec /d /c $buildCommand
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& $msbuild $project /m /t:Build /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal
|
||||||
|
}
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Visual probe build failed' }
|
||||||
|
if (-not (Test-Path -LiteralPath $probe)) { throw "Visual probe executable not found: $probe" }
|
||||||
|
|
||||||
|
$sessionId = [guid]::NewGuid().ToString()
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||||
|
$sessionRoot = Join-Path $root "TestResults\VisualProbe\$stamp-$sessionId"
|
||||||
|
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
|
||||||
|
$modes = if ($Mode -eq 'All') { @('wide-text', 'dpi-coordinate') } else { @($Mode) }
|
||||||
|
$summaries = New-Object System.Collections.Generic.List[object]
|
||||||
|
$probeFailed = $false
|
||||||
|
|
||||||
|
foreach ($probeMode in $modes) {
|
||||||
|
$startedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||||
|
$runDirectory = Join-Path $sessionRoot $probeMode
|
||||||
|
New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null
|
||||||
|
if ($probeMode -eq 'ime-position') {
|
||||||
|
$manualEvidence = Join-Path $runDirectory 'manual-evidence'
|
||||||
|
New-Item -ItemType Directory -Force -Path $manualEvidence | Out-Null
|
||||||
|
Write-Host "Manual IME position probe: $ImeName"
|
||||||
|
Write-Host ' 1. Select the requested IME and click the EasyX window.'
|
||||||
|
Write-Host ' 2. Press F1, type ceshi, inspect the candidate position, then press Escape.'
|
||||||
|
Write-Host ' 3. Repeat with F2 and F3. Use only the fixed non-sensitive input ceshi.'
|
||||||
|
Write-Host ' 4. At F3, commit once with Space; switch back to F1, type ceshi, and verify the old F3 position is not reused.'
|
||||||
|
Write-Host ' 5. To close 0C-3, save tightly cropped screenshots with anchor-id prefixes under:'
|
||||||
|
Write-Host " $manualEvidence"
|
||||||
|
Write-Host ' 6. Close the EasyX window when all three anchors and the post-commit F1 regression have been exercised.'
|
||||||
|
}
|
||||||
|
Write-Host "Running visual probe mode: $probeMode"
|
||||||
|
& $probe --mode $probeMode --output $runDirectory --dpi-awareness $DpiAwareness --ime-name $ImeName
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
$rawPath = Join-Path $runDirectory 'raw-result.json'
|
||||||
|
if (-not (Test-Path -LiteralPath $rawPath)) {
|
||||||
|
throw "Visual probe did not produce raw-result.json (exit $exitCode): $runDirectory"
|
||||||
|
}
|
||||||
|
$summary = & (Join-Path $PSScriptRoot 'analyze-visual-probe.ps1') `
|
||||||
|
-Path $runDirectory -SessionId $sessionId -StartedAtUtc $startedAtUtc -ProbeExitCode $exitCode
|
||||||
|
$summaries.Add($summary)
|
||||||
|
if ($exitCode -ne 0 -or $summary.automatedStatus -ne 'pass') { $probeFailed = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($summaries.Count -eq 2) {
|
||||||
|
$drawing = $summaries | Where-Object mode -eq 'wide-text' | Select-Object -First 1
|
||||||
|
$coordinate = $summaries | Where-Object mode -eq 'dpi-coordinate' | Select-Object -First 1
|
||||||
|
$combinedInput = "$($drawing.manifestSha256):$($coordinate.manifestSha256)"
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$combinedBytes = [Text.Encoding]::UTF8.GetBytes($combinedInput)
|
||||||
|
$combinedHash = ([BitConverter]::ToString($sha.ComputeHash($combinedBytes))).Replace('-', '')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha.Dispose()
|
||||||
|
}
|
||||||
|
$baseline = [pscustomobject][ordered]@{
|
||||||
|
visualProbeBaselineSchemaVersion = 1
|
||||||
|
sessionId = $sessionId
|
||||||
|
createdAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||||
|
drawingBaselineManifest = 'wide-text/manifest-v1.json'
|
||||||
|
coordinateBaselineManifest = 'dpi-coordinate/manifest-v1.json'
|
||||||
|
drawingBaselineSha256 = $drawing.manifestSha256
|
||||||
|
coordinateBaselineSha256 = $coordinate.manifestSha256
|
||||||
|
combinedBaselineId = "sha256:$combinedHash"
|
||||||
|
dpiAwareness = $coordinate.windowAwareness
|
||||||
|
dpiForWindow = $coordinate.dpiForWindow
|
||||||
|
imeIdentity = $null
|
||||||
|
hkl = $null
|
||||||
|
automatedStatus = if ($probeFailed) { 'fail' } else { 'pass' }
|
||||||
|
manualStatus = 'not_reviewed'
|
||||||
|
}
|
||||||
|
$baselinePath = Join-Path $sessionRoot 'baseline-set-v1.json'
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
[IO.File]::WriteAllText($baselinePath,
|
||||||
|
($baseline | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
|
||||||
|
$baselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $baselinePath).Hash
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
[IO.File]::WriteAllText($baselinePath + '.sha256',
|
||||||
|
"$baselineHash *baseline-set-v1.json`r`n", $utf8NoBom)
|
||||||
|
Write-Host "Combined baseline: $baselinePath"
|
||||||
|
Write-Host "Combined baseline ID: $($baseline.combinedBaselineId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaries | Format-Table mode, automatedStatus, manualStatus, testCaseCount, evidenceCount, dpiForWindow
|
||||||
|
Write-Host "Result directory: $sessionRoot"
|
||||||
|
if ($probeFailed) { throw "One or more visual probe modes did not pass; evidence was preserved in $sessionRoot" }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ItemGroup Label="ProjectConfigurations">
|
||||||
|
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||||
|
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||||
|
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||||
|
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||||
|
</ItemGroup>
|
||||||
|
<PropertyGroup Label="Globals">
|
||||||
|
<VCProjectVersion>17.0</VCProjectVersion>
|
||||||
|
<Keyword>Win32Proj</Keyword>
|
||||||
|
<ProjectGuid>{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}</ProjectGuid>
|
||||||
|
<RootNamespace>EasyXVisualProbe</RootNamespace>
|
||||||
|
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||||
|
<PropertyGroup Label="Configuration">
|
||||||
|
<ConfigurationType>Application</ConfigurationType>
|
||||||
|
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||||
|
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||||
|
<PlatformToolset>v143</PlatformToolset>
|
||||||
|
<CharacterSet>MultiByte</CharacterSet>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||||
|
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||||
|
<TargetName>EasyX.VisualProbe</TargetName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemDefinitionGroup>
|
||||||
|
<ClCompile>
|
||||||
|
<WarningLevel>Level4</WarningLevel>
|
||||||
|
<SDLCheck>true</SDLCheck>
|
||||||
|
<ConformanceMode>true</ConformanceMode>
|
||||||
|
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||||
|
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||||
|
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;_WIN32_WINNT=0x0A00;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<SubSystem>Console</SubSystem>
|
||||||
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClCompile Include="visual-probe\DpiCoordinateProbe.cpp" />
|
||||||
|
<ClCompile Include="visual-probe\ImePositionProbe.cpp" />
|
||||||
|
<ClCompile Include="visual-probe\VisualProbeCommon.cpp" />
|
||||||
|
<ClCompile Include="visual-probe\VisualProbeMain.cpp" />
|
||||||
|
<ClCompile Include="visual-probe\WideTextProbe.cpp" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClInclude Include="visual-probe\VisualProbeCommon.h" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
#include "VisualProbeCommon.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace VisualProbe
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr COLORREF Background = RGB(238, 241, 246);
|
||||||
|
constexpr COLORREF EasyXColor = RGB(220, 45, 70);
|
||||||
|
constexpr COLORREF GdiColor = RGB(20, 150, 70);
|
||||||
|
|
||||||
|
struct RoundTripResult
|
||||||
|
{
|
||||||
|
POINT client{};
|
||||||
|
POINT screen{};
|
||||||
|
POINT clientRoundTrip{};
|
||||||
|
int errorX = 0;
|
||||||
|
int errorY = 0;
|
||||||
|
bool converted = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string normalizedRelative(const std::filesystem::path& path)
|
||||||
|
{
|
||||||
|
std::string value = path.generic_string();
|
||||||
|
std::replace(value.begin(), value.end(), '\\', '/');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addSurfaceEvidence(
|
||||||
|
const Options& options,
|
||||||
|
HDC hdc,
|
||||||
|
const std::string& fileName,
|
||||||
|
const std::string& purpose,
|
||||||
|
std::vector<EvidenceFile>& evidence)
|
||||||
|
{
|
||||||
|
const auto relative = std::filesystem::path("evidence") / fileName;
|
||||||
|
const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
|
||||||
|
options.outputDirectory / relative);
|
||||||
|
if (saved) evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addClientEvidence(
|
||||||
|
const Options& options,
|
||||||
|
HWND hwnd,
|
||||||
|
const std::string& fileName,
|
||||||
|
const std::string& purpose,
|
||||||
|
std::vector<EvidenceFile>& evidence)
|
||||||
|
{
|
||||||
|
const auto relative = std::filesystem::path("evidence") / fileName;
|
||||||
|
Sleep(30);
|
||||||
|
std::string backend;
|
||||||
|
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||||
|
if (saved) evidence.push_back({ normalizedRelative(relative), purpose, backend });
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
RoundTripResult roundTrip(HWND hwnd, POINT client)
|
||||||
|
{
|
||||||
|
RoundTripResult result;
|
||||||
|
result.client = client;
|
||||||
|
result.screen = client;
|
||||||
|
if (!ClientToScreen(hwnd, &result.screen)) return result;
|
||||||
|
result.clientRoundTrip = result.screen;
|
||||||
|
if (!ScreenToClient(hwnd, &result.clientRoundTrip)) return result;
|
||||||
|
result.converted = true;
|
||||||
|
result.errorX = result.clientRoundTrip.x - result.client.x;
|
||||||
|
result.errorY = result.clientRoundTrip.y - result.client.y;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boundsWidth(const PixelBounds& bounds)
|
||||||
|
{
|
||||||
|
return bounds.present ? bounds.right - bounds.left + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boundsHeight(const PixelBounds& bounds)
|
||||||
|
{
|
||||||
|
return bounds.present ? bounds.bottom - bounds.top + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeRoundTrip(std::ostream& output, const RoundTripResult& result)
|
||||||
|
{
|
||||||
|
output << "{\"converted\":";
|
||||||
|
writeBoolean(output, result.converted);
|
||||||
|
output << ",\"client\":";
|
||||||
|
writePointJson(output, result.client);
|
||||||
|
output << ",\"screen\":";
|
||||||
|
writePointJson(output, result.screen);
|
||||||
|
output << ",\"clientRoundTrip\":";
|
||||||
|
writePointJson(output, result.clientRoundTrip);
|
||||||
|
output << ",\"error\":{\"x\":" << result.errorX
|
||||||
|
<< ",\"y\":" << result.errorY << "}}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||||
|
{
|
||||||
|
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||||
|
if (!hwnd)
|
||||||
|
{
|
||||||
|
std::cerr << "initgraph failed\n";
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
SetWindowTextA(hwnd, "StellarX 0C DPI Coordinate Probe");
|
||||||
|
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||||
|
SetForegroundWindow(hwnd);
|
||||||
|
Sleep(80);
|
||||||
|
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
HDC hdc = GetImageHDC(nullptr);
|
||||||
|
std::vector<EvidenceFile> evidence;
|
||||||
|
bool evidenceComplete = true;
|
||||||
|
|
||||||
|
BeginBatchDraw();
|
||||||
|
setbkcolor(Background);
|
||||||
|
cleardevice();
|
||||||
|
setlinestyle(PS_SOLID, 1);
|
||||||
|
setlinecolor(EasyXColor);
|
||||||
|
rectangle(100, 100, 240, 180);
|
||||||
|
line(100, 140, 240, 140);
|
||||||
|
line(170, 100, 170, 180);
|
||||||
|
settextcolor(EasyXColor);
|
||||||
|
setbkmode(TRANSPARENT);
|
||||||
|
settextstyle(28, 0, "Microsoft YaHei UI");
|
||||||
|
const char* easyXText = "StellarX DPI";
|
||||||
|
outtextxy(100, 250, easyXText);
|
||||||
|
const SIZE easyXLogicalExtent{ textwidth(easyXText), textheight(easyXText) };
|
||||||
|
|
||||||
|
const GdiState stateBefore = captureGdiState(hdc);
|
||||||
|
const int saved = SaveDC(hdc);
|
||||||
|
HPEN pen = CreatePen(PS_SOLID, 1, GdiColor);
|
||||||
|
HGDIOBJ previousPen = pen ? SelectObject(hdc, pen) : nullptr;
|
||||||
|
HBRUSH previousBrush = static_cast<HBRUSH>(SelectObject(hdc, GetStockObject(NULL_BRUSH)));
|
||||||
|
SetBkMode(hdc, TRANSPARENT);
|
||||||
|
SetTextColor(hdc, GdiColor);
|
||||||
|
Rectangle(hdc, 500, 100, 641, 181);
|
||||||
|
MoveToEx(hdc, 500, 140, nullptr);
|
||||||
|
LineTo(hdc, 641, 140);
|
||||||
|
MoveToEx(hdc, 570, 100, nullptr);
|
||||||
|
LineTo(hdc, 570, 181);
|
||||||
|
const std::wstring gdiText = L"StellarX DPI";
|
||||||
|
SIZE gdiLogicalExtent{};
|
||||||
|
const bool gdiExtentSuccess = GetTextExtentPoint32W(hdc, gdiText.data(),
|
||||||
|
static_cast<int>(gdiText.size()), &gdiLogicalExtent) != FALSE;
|
||||||
|
const bool gdiTextSuccess = TextOutW(hdc, 500, 250, gdiText.data(),
|
||||||
|
static_cast<int>(gdiText.size())) != FALSE;
|
||||||
|
if (previousBrush) SelectObject(hdc, previousBrush);
|
||||||
|
if (previousPen) SelectObject(hdc, previousPen);
|
||||||
|
if (saved) RestoreDC(hdc, saved);
|
||||||
|
if (pen) DeleteObject(pen);
|
||||||
|
const GdiState stateAfter = captureGdiState(hdc);
|
||||||
|
const bool gdiStateRestored = saved != 0 && sameGdiState(stateBefore, stateAfter);
|
||||||
|
|
||||||
|
const SurfaceFingerprint afterDraw = captureSurfaceFingerprint(hdc, CanvasWidth, CanvasHeight);
|
||||||
|
const RECT easyXMarkerRegion{ 90, 90, 250, 190 };
|
||||||
|
const RECT gdiMarkerRegion{ 490, 90, 650, 190 };
|
||||||
|
const RECT easyXTextRegion{ 90, 240, 400, 310 };
|
||||||
|
const RECT gdiTextRegion{ 490, 240, 800, 310 };
|
||||||
|
const PixelBounds easyXMarkerSurface = findColorBounds(hdc, easyXMarkerRegion, EasyXColor);
|
||||||
|
const PixelBounds gdiMarkerSurface = findColorBounds(hdc, gdiMarkerRegion, GdiColor);
|
||||||
|
const PixelBounds easyXTextSurface = findChangedBounds(hdc, easyXTextRegion, Background);
|
||||||
|
const PixelBounds gdiTextSurface = findChangedBounds(hdc, gdiTextRegion, Background);
|
||||||
|
evidenceComplete &= addSurfaceEvidence(options, hdc, "dpi_after_draw_surface.png",
|
||||||
|
"DPI surface after EasyX and raw GDI drawing", evidence);
|
||||||
|
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_before_flush_client.png",
|
||||||
|
"DPI client before FlushBatchDraw", evidence);
|
||||||
|
|
||||||
|
FlushBatchDraw();
|
||||||
|
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_flush_client.png",
|
||||||
|
"DPI client after FlushBatchDraw", evidence);
|
||||||
|
EndBatchDraw();
|
||||||
|
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_end_client.png",
|
||||||
|
"DPI client after EndBatchDraw", evidence);
|
||||||
|
|
||||||
|
PixelBounds easyXMarkerClient;
|
||||||
|
PixelBounds gdiMarkerClient;
|
||||||
|
HDC clientDc = GetDC(hwnd);
|
||||||
|
if (clientDc)
|
||||||
|
{
|
||||||
|
easyXMarkerClient = findColorBounds(clientDc, easyXMarkerRegion, EasyXColor);
|
||||||
|
gdiMarkerClient = findColorBounds(clientDc, gdiMarkerRegion, GdiColor);
|
||||||
|
ReleaseDC(hwnd, clientDc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::array<POINT, 4> anchorPoints{
|
||||||
|
POINT{ 0, 0 }, POINT{ 100, 100 }, POINT{ 500, 250 },
|
||||||
|
POINT{ CanvasWidth - 1, CanvasHeight - 1 }
|
||||||
|
};
|
||||||
|
std::vector<RoundTripResult> roundTrips;
|
||||||
|
bool roundTripsExact = true;
|
||||||
|
for (const POINT point : anchorPoints)
|
||||||
|
{
|
||||||
|
roundTrips.push_back(roundTrip(hwnd, point));
|
||||||
|
const auto& result = roundTrips.back();
|
||||||
|
roundTripsExact = roundTripsExact && result.converted &&
|
||||||
|
result.errorX == 0 && result.errorY == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool surfaceMarkerSizesAgree = easyXMarkerSurface.present && gdiMarkerSurface.present &&
|
||||||
|
std::abs(boundsWidth(easyXMarkerSurface) - boundsWidth(gdiMarkerSurface)) <= 2 &&
|
||||||
|
std::abs(boundsHeight(easyXMarkerSurface) - boundsHeight(gdiMarkerSurface)) <= 2;
|
||||||
|
const bool clientMatchesSurface = easyXMarkerClient.present && gdiMarkerClient.present &&
|
||||||
|
std::abs(boundsWidth(easyXMarkerClient) - boundsWidth(easyXMarkerSurface)) <= 1 &&
|
||||||
|
std::abs(boundsHeight(easyXMarkerClient) - boundsHeight(easyXMarkerSurface)) <= 1 &&
|
||||||
|
std::abs(boundsWidth(gdiMarkerClient) - boundsWidth(gdiMarkerSurface)) <= 1 &&
|
||||||
|
std::abs(boundsHeight(gdiMarkerClient) - boundsHeight(gdiMarkerSurface)) <= 1;
|
||||||
|
const bool implicitScalingObserved = !surfaceMarkerSizesAgree || !clientMatchesSurface;
|
||||||
|
const bool completed = evidenceComplete && gdiStateRestored && gdiExtentSuccess &&
|
||||||
|
gdiTextSuccess && easyXMarkerSurface.present && gdiMarkerSurface.present &&
|
||||||
|
easyXTextSurface.present && gdiTextSurface.present && roundTripsExact;
|
||||||
|
|
||||||
|
std::ostringstream result;
|
||||||
|
result << "{\"batchState\":\"inside\",\"surfaceFingerprintAfterDraw\":";
|
||||||
|
writeSurfaceFingerprintJson(result, afterDraw);
|
||||||
|
result << ",\"gdiStateRestored\":";
|
||||||
|
writeBoolean(result, gdiStateRestored);
|
||||||
|
result << ",\"easyXLogicalExtent\":";
|
||||||
|
writeSizeJson(result, easyXLogicalExtent);
|
||||||
|
result << ",\"gdiLogicalExtent\":";
|
||||||
|
writeSizeJson(result, gdiLogicalExtent);
|
||||||
|
result << ",\"gdiExtentSuccess\":";
|
||||||
|
writeBoolean(result, gdiExtentSuccess);
|
||||||
|
result << ",\"gdiTextSuccess\":";
|
||||||
|
writeBoolean(result, gdiTextSuccess);
|
||||||
|
result << ",\"surfaceBounds\":{\"easyXMarker\":";
|
||||||
|
writeBoundsJson(result, easyXMarkerSurface);
|
||||||
|
result << ",\"gdiMarker\":";
|
||||||
|
writeBoundsJson(result, gdiMarkerSurface);
|
||||||
|
result << ",\"easyXText\":";
|
||||||
|
writeBoundsJson(result, easyXTextSurface);
|
||||||
|
result << ",\"gdiText\":";
|
||||||
|
writeBoundsJson(result, gdiTextSurface);
|
||||||
|
result << "},\"clientBounds\":{\"easyXMarker\":";
|
||||||
|
writeBoundsJson(result, easyXMarkerClient);
|
||||||
|
result << ",\"gdiMarker\":";
|
||||||
|
writeBoundsJson(result, gdiMarkerClient);
|
||||||
|
result << "},\"surfaceMarkerSizesAgree\":";
|
||||||
|
writeBoolean(result, surfaceMarkerSizesAgree);
|
||||||
|
result << ",\"clientMatchesSurface\":";
|
||||||
|
writeBoolean(result, clientMatchesSurface);
|
||||||
|
result << ",\"implicitScalingObserved\":";
|
||||||
|
writeBoolean(result, implicitScalingObserved);
|
||||||
|
result << ",\"roundTripsExact\":";
|
||||||
|
writeBoolean(result, roundTripsExact);
|
||||||
|
result << ",\"coordinateRoundTrips\":[";
|
||||||
|
for (std::size_t index = 0; index < roundTrips.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) result << ',';
|
||||||
|
writeRoundTrip(result, roundTrips[index]);
|
||||||
|
}
|
||||||
|
result << "]}";
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
closegraph();
|
||||||
|
|
||||||
|
std::string error;
|
||||||
|
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||||
|
evidence, result.str(), error))
|
||||||
|
{
|
||||||
|
std::cerr << error << '\n';
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return completed ? 0 : 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#include "VisualProbeCommon.h"
|
||||||
|
|
||||||
|
#include <imm.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <iostream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#pragma comment(lib, "Imm32.lib")
|
||||||
|
|
||||||
|
namespace VisualProbe
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct Anchor
|
||||||
|
{
|
||||||
|
const char* id;
|
||||||
|
POINT controlOrigin;
|
||||||
|
POINT localCaretTopLeft;
|
||||||
|
POINT localCaretBottom;
|
||||||
|
RECT localLineRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ImePositionEvent
|
||||||
|
{
|
||||||
|
std::uint64_t sequence = 0;
|
||||||
|
std::string stage;
|
||||||
|
std::string positionTrigger = "none";
|
||||||
|
std::string anchorId;
|
||||||
|
DWORD threadId = 0;
|
||||||
|
std::uintptr_t hkl = 0;
|
||||||
|
std::wstring imeDescription;
|
||||||
|
std::wstring imeFileName;
|
||||||
|
bool apiAttempted = false;
|
||||||
|
bool compositionActiveAtAttempt = false;
|
||||||
|
bool contextAvailable = false;
|
||||||
|
bool compositionSucceeded = false;
|
||||||
|
bool candidateSucceeded = false;
|
||||||
|
POINT hwndRelativePoint{};
|
||||||
|
RECT hwndRelativeLineRect{};
|
||||||
|
POINT screenPoint{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ImePositionState
|
||||||
|
{
|
||||||
|
HWND hwnd = nullptr;
|
||||||
|
WNDPROC previousWndProc = nullptr;
|
||||||
|
std::atomic<int> activeAnchor{ 1 };
|
||||||
|
std::atomic<bool> compositionActive{ false };
|
||||||
|
std::atomic<bool> closeRequested{ false };
|
||||||
|
std::atomic<std::uint64_t> nextSequence{ 0 };
|
||||||
|
std::mutex eventsMutex;
|
||||||
|
std::vector<ImePositionEvent> events;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr std::array<Anchor, 3> Anchors{
|
||||||
|
Anchor{ "anchor_top_left_safe", POINT{ 80, 125 }, POINT{ 30, 35 }, POINT{ 30, 61 }, RECT{ 10, 30, 250, 66 } },
|
||||||
|
Anchor{ "anchor_center", POINT{ 350, 305 }, POINT{ 130, 55 }, POINT{ 130, 81 }, RECT{ 10, 50, 260, 86 } },
|
||||||
|
Anchor{ "anchor_bottom_right_safe", POINT{ 650, 520 }, POINT{ 180, 45 }, POINT{ 180, 71 }, RECT{ 10, 40, 230, 76 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
ImePositionState* activeState = nullptr;
|
||||||
|
|
||||||
|
POINT surfaceCaret(const Anchor& anchor) noexcept
|
||||||
|
{
|
||||||
|
return POINT{
|
||||||
|
anchor.controlOrigin.x + anchor.localCaretBottom.x,
|
||||||
|
anchor.controlOrigin.y + anchor.localCaretBottom.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring imeDescription(HKL hkl)
|
||||||
|
{
|
||||||
|
const UINT length = ImmGetDescriptionW(hkl, nullptr, 0);
|
||||||
|
if (!length) return {};
|
||||||
|
std::wstring value(length + 1, L'\0');
|
||||||
|
const UINT written = ImmGetDescriptionW(hkl, value.data(), static_cast<UINT>(value.size()));
|
||||||
|
value.resize(written);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring imeFileName(HKL hkl)
|
||||||
|
{
|
||||||
|
const UINT length = ImmGetIMEFileNameW(hkl, nullptr, 0);
|
||||||
|
if (!length) return {};
|
||||||
|
std::wstring value(length + 1, L'\0');
|
||||||
|
const UINT written = ImmGetIMEFileNameW(hkl, value.data(), static_cast<UINT>(value.size()));
|
||||||
|
value.resize(written);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendEvent(ImePositionState& state, ImePositionEvent event)
|
||||||
|
{
|
||||||
|
event.sequence = state.nextSequence.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
std::lock_guard<std::mutex> lock(state.eventsMutex);
|
||||||
|
state.events.push_back(std::move(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordLifecycle(ImePositionState& state, const char* stage)
|
||||||
|
{
|
||||||
|
const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
|
||||||
|
ImePositionEvent event;
|
||||||
|
event.stage = stage;
|
||||||
|
event.anchorId = Anchors[static_cast<std::size_t>(anchorIndex)].id;
|
||||||
|
event.threadId = GetCurrentThreadId();
|
||||||
|
const HKL hkl = GetKeyboardLayout(0);
|
||||||
|
event.hkl = reinterpret_cast<std::uintptr_t>(hkl);
|
||||||
|
event.imeDescription = imeDescription(hkl);
|
||||||
|
event.imeFileName = imeFileName(hkl);
|
||||||
|
appendEvent(state, std::move(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
void configureImePosition(ImePositionState& state, const char* stage, const char* positionTrigger)
|
||||||
|
{
|
||||||
|
const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
|
||||||
|
const Anchor& anchor = Anchors[static_cast<std::size_t>(anchorIndex)];
|
||||||
|
ImePositionEvent event;
|
||||||
|
event.stage = stage;
|
||||||
|
event.positionTrigger = positionTrigger;
|
||||||
|
event.anchorId = anchor.id;
|
||||||
|
event.threadId = GetCurrentThreadId();
|
||||||
|
const HKL hkl = GetKeyboardLayout(0);
|
||||||
|
event.hkl = reinterpret_cast<std::uintptr_t>(hkl);
|
||||||
|
event.imeDescription = imeDescription(hkl);
|
||||||
|
event.imeFileName = imeFileName(hkl);
|
||||||
|
event.apiAttempted = true;
|
||||||
|
event.compositionActiveAtAttempt = state.compositionActive.load(std::memory_order_acquire);
|
||||||
|
event.hwndRelativePoint = surfaceCaret(anchor);
|
||||||
|
event.hwndRelativeLineRect = RECT{
|
||||||
|
anchor.controlOrigin.x + anchor.localLineRect.left,
|
||||||
|
anchor.controlOrigin.y + anchor.localLineRect.top,
|
||||||
|
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||||
|
anchor.controlOrigin.y + anchor.localLineRect.bottom
|
||||||
|
};
|
||||||
|
event.screenPoint = event.hwndRelativePoint;
|
||||||
|
ClientToScreen(state.hwnd, &event.screenPoint);
|
||||||
|
|
||||||
|
HIMC context = ImmGetContext(state.hwnd);
|
||||||
|
event.contextAvailable = context != 0;
|
||||||
|
if (context)
|
||||||
|
{
|
||||||
|
COMPOSITIONFORM composition{};
|
||||||
|
composition.dwStyle = CFS_POINT;
|
||||||
|
composition.ptCurrentPos = event.hwndRelativePoint;
|
||||||
|
event.compositionSucceeded = ImmSetCompositionWindow(context, &composition) != FALSE;
|
||||||
|
|
||||||
|
CANDIDATEFORM candidate{};
|
||||||
|
candidate.dwIndex = 0;
|
||||||
|
candidate.dwStyle = CFS_EXCLUDE;
|
||||||
|
candidate.ptCurrentPos = event.hwndRelativePoint;
|
||||||
|
candidate.rcArea = event.hwndRelativeLineRect;
|
||||||
|
event.candidateSucceeded = ImmSetCandidateWindow(context, &candidate) != FALSE;
|
||||||
|
ImmReleaseContext(state.hwnd, context);
|
||||||
|
}
|
||||||
|
appendEvent(state, std::move(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||||||
|
{
|
||||||
|
ImePositionState* state = activeState;
|
||||||
|
if (!state)
|
||||||
|
return DefWindowProcA(hwnd, message, wParam, lParam);
|
||||||
|
|
||||||
|
if (message == WM_KEYDOWN && wParam >= VK_F1 && wParam <= VK_F3)
|
||||||
|
{
|
||||||
|
state->activeAnchor.store(static_cast<int>(wParam - VK_F1), std::memory_order_release);
|
||||||
|
configureImePosition(*state, "anchor_preconfigure", "anchor_change");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
else if (message == WM_IME_STARTCOMPOSITION)
|
||||||
|
{
|
||||||
|
state->compositionActive.store(true, std::memory_order_release);
|
||||||
|
configureImePosition(*state, "ime_start", "ime_lifecycle");
|
||||||
|
}
|
||||||
|
else if (message == WM_IME_COMPOSITION)
|
||||||
|
{
|
||||||
|
configureImePosition(*state, "ime_composition", "ime_lifecycle");
|
||||||
|
}
|
||||||
|
else if (message == WM_IME_ENDCOMPOSITION)
|
||||||
|
{
|
||||||
|
recordLifecycle(*state, "ime_end");
|
||||||
|
state->compositionActive.store(false, std::memory_order_release);
|
||||||
|
}
|
||||||
|
else if (message == WM_INPUTLANGCHANGE)
|
||||||
|
{
|
||||||
|
recordLifecycle(*state, "input_language_change");
|
||||||
|
}
|
||||||
|
else if (message == WM_CLOSE)
|
||||||
|
{
|
||||||
|
recordLifecycle(*state, "close_requested");
|
||||||
|
state->closeRequested.store(true, std::memory_order_release);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state->previousWndProc
|
||||||
|
? CallWindowProcA(state->previousWndProc, hwnd, message, wParam, lParam)
|
||||||
|
: DefWindowProcA(hwnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawAnchorScreen(int activeAnchorIndex)
|
||||||
|
{
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
BeginBatchDraw();
|
||||||
|
setbkcolor(RGB(238, 241, 246));
|
||||||
|
cleardevice();
|
||||||
|
setbkmode(TRANSPARENT);
|
||||||
|
settextstyle(22, 0, "Microsoft YaHei UI");
|
||||||
|
settextcolor(RGB(30, 45, 70));
|
||||||
|
outtextxy(30, 25, "IME position probe: F1/F2/F3 select anchor; close window when done");
|
||||||
|
outtextxy(30, 58, "Use only fixed non-sensitive input: ceshi; inspect candidate position; Esc cancels");
|
||||||
|
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||||
|
{
|
||||||
|
const Anchor& anchor = Anchors[index];
|
||||||
|
const bool selected = static_cast<int>(index) == activeAnchorIndex;
|
||||||
|
const COLORREF color = selected ? RGB(220, 60, 45) : RGB(45, 125, 190);
|
||||||
|
const POINT caret = surfaceCaret(anchor);
|
||||||
|
setlinecolor(color);
|
||||||
|
setlinestyle(PS_SOLID, selected ? 3 : 1);
|
||||||
|
rectangle(anchor.controlOrigin.x, anchor.controlOrigin.y,
|
||||||
|
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||||
|
anchor.controlOrigin.y + anchor.localLineRect.bottom + 12);
|
||||||
|
line(caret.x, caret.y - 26, caret.x, caret.y + 4);
|
||||||
|
line(caret.x - 5, caret.y, caret.x + 5, caret.y);
|
||||||
|
settextcolor(color);
|
||||||
|
const std::string label = std::string("F") + std::to_string(index + 1) + " " + anchor.id;
|
||||||
|
outtextxy(anchor.controlOrigin.x + 8, anchor.controlOrigin.y + 5, label.c_str());
|
||||||
|
}
|
||||||
|
EndBatchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string normalizedRelative(const std::filesystem::path& path)
|
||||||
|
{
|
||||||
|
std::string value = path.generic_string();
|
||||||
|
std::replace(value.begin(), value.end(), '\\', '/');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeAnchor(std::ostream& output, const Anchor& anchor)
|
||||||
|
{
|
||||||
|
const POINT surfaceTopLeft{
|
||||||
|
anchor.controlOrigin.x + anchor.localCaretTopLeft.x,
|
||||||
|
anchor.controlOrigin.y + anchor.localCaretTopLeft.y
|
||||||
|
};
|
||||||
|
const POINT surfaceBottom = surfaceCaret(anchor);
|
||||||
|
RECT surfaceLine{
|
||||||
|
anchor.controlOrigin.x + anchor.localLineRect.left,
|
||||||
|
anchor.controlOrigin.y + anchor.localLineRect.top,
|
||||||
|
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||||
|
anchor.controlOrigin.y + anchor.localLineRect.bottom
|
||||||
|
};
|
||||||
|
output << "{\"syntheticAnchorId\":" << jsonString(anchor.id)
|
||||||
|
<< ",\"controlOrigin\":";
|
||||||
|
writePointJson(output, anchor.controlOrigin);
|
||||||
|
output << ",\"controlLocalCaretTopLeft\":";
|
||||||
|
writePointJson(output, anchor.localCaretTopLeft);
|
||||||
|
output << ",\"controlLocalCaretBottom\":";
|
||||||
|
writePointJson(output, anchor.localCaretBottom);
|
||||||
|
output << ",\"controlLocalLineRect\":";
|
||||||
|
writeRectJson(output, anchor.localLineRect);
|
||||||
|
output << ",\"easyxSurfaceCaretTopLeft\":";
|
||||||
|
writePointJson(output, surfaceTopLeft);
|
||||||
|
output << ",\"easyxSurfaceCaretBottom\":";
|
||||||
|
writePointJson(output, surfaceBottom);
|
||||||
|
output << ",\"easyxSurfaceLineRect\":";
|
||||||
|
writeRectJson(output, surfaceLine);
|
||||||
|
output << ",\"targetHwndRelativePoint\":";
|
||||||
|
writePointJson(output, surfaceBottom);
|
||||||
|
output << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeEvent(std::ostream& output, const ImePositionEvent& event)
|
||||||
|
{
|
||||||
|
output << "{\"sequence\":" << event.sequence
|
||||||
|
<< ",\"stage\":" << jsonString(event.stage)
|
||||||
|
<< ",\"positionTrigger\":" << jsonString(event.positionTrigger)
|
||||||
|
<< ",\"syntheticAnchorId\":" << jsonString(event.anchorId)
|
||||||
|
<< ",\"threadId\":" << event.threadId
|
||||||
|
<< ",\"hkl\":" << jsonString(hexValue(event.hkl))
|
||||||
|
<< ",\"imeDescriptionUtf16\":" << utf16UnitsJson(event.imeDescription)
|
||||||
|
<< ",\"imeFileNameUtf16\":" << utf16UnitsJson(event.imeFileName)
|
||||||
|
<< ",\"apiAttempted\":";
|
||||||
|
writeBoolean(output, event.apiAttempted);
|
||||||
|
output << ",\"compositionActiveAtAttempt\":";
|
||||||
|
writeBoolean(output, event.compositionActiveAtAttempt);
|
||||||
|
output << ",\"contextAvailable\":";
|
||||||
|
writeBoolean(output, event.contextAvailable);
|
||||||
|
output << ",\"compositionForm\":{\"style\":" << CFS_POINT
|
||||||
|
<< ",\"apiResult\":";
|
||||||
|
writeBoolean(output, event.compositionSucceeded);
|
||||||
|
output << "},\"candidateForm\":{\"style\":" << CFS_EXCLUDE
|
||||||
|
<< ",\"index\":0,\"apiResult\":";
|
||||||
|
writeBoolean(output, event.candidateSucceeded);
|
||||||
|
output << ",\"excludeRect\":";
|
||||||
|
writeRectJson(output, event.hwndRelativeLineRect);
|
||||||
|
output << "},\"targetHwndRelativePoint\":";
|
||||||
|
writePointJson(output, event.hwndRelativePoint);
|
||||||
|
output << ",\"diagnosticScreenPoint\":";
|
||||||
|
writePointJson(output, event.screenPoint);
|
||||||
|
output << '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||||
|
{
|
||||||
|
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||||
|
if (!hwnd)
|
||||||
|
{
|
||||||
|
std::cerr << "initgraph failed\n";
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
SetWindowTextA(hwnd, "StellarX 0C IME Position Probe");
|
||||||
|
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||||
|
SetForegroundWindow(hwnd);
|
||||||
|
Sleep(80);
|
||||||
|
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||||
|
DWORD windowProcessId = 0;
|
||||||
|
const DWORD windowOwnerThread = GetWindowThreadProcessId(hwnd, &windowProcessId);
|
||||||
|
|
||||||
|
ImePositionState state;
|
||||||
|
state.hwnd = hwnd;
|
||||||
|
activeState = &state;
|
||||||
|
SetLastError(ERROR_SUCCESS);
|
||||||
|
state.previousWndProc = reinterpret_cast<WNDPROC>(SetWindowLongPtrA(
|
||||||
|
hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&ProbeWndProc)));
|
||||||
|
const DWORD subclassError = state.previousWndProc ? ERROR_SUCCESS : GetLastError();
|
||||||
|
|
||||||
|
std::vector<EvidenceFile> evidence;
|
||||||
|
bool evidenceComplete = state.previousWndProc != nullptr;
|
||||||
|
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||||
|
{
|
||||||
|
drawAnchorScreen(static_cast<int>(index));
|
||||||
|
Sleep(40);
|
||||||
|
const auto relative = std::filesystem::path("evidence") /
|
||||||
|
(std::string(Anchors[index].id) + "_client.png");
|
||||||
|
std::string backend;
|
||||||
|
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||||
|
evidenceComplete = evidenceComplete && saved;
|
||||||
|
if (saved)
|
||||||
|
evidence.push_back({ normalizedRelative(relative), "synthetic anchor client evidence", backend });
|
||||||
|
}
|
||||||
|
state.activeAnchor.store(1, std::memory_order_release);
|
||||||
|
drawAnchorScreen(1);
|
||||||
|
|
||||||
|
int lastDrawnAnchor = 1;
|
||||||
|
const ULONGLONG loopStarted = GetTickCount64();
|
||||||
|
while (!state.closeRequested.load(std::memory_order_acquire) && IsWindow(hwnd))
|
||||||
|
{
|
||||||
|
if (options.autoCloseMilliseconds > 0 &&
|
||||||
|
GetTickCount64() - loopStarted >= options.autoCloseMilliseconds)
|
||||||
|
{
|
||||||
|
recordLifecycle(state, "auto_close");
|
||||||
|
state.closeRequested.store(true, std::memory_order_release);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const int currentAnchor = state.activeAnchor.load(std::memory_order_acquire);
|
||||||
|
if (currentAnchor != lastDrawnAnchor)
|
||||||
|
{
|
||||||
|
drawAnchorScreen(currentAnchor);
|
||||||
|
lastDrawnAnchor = currentAnchor;
|
||||||
|
}
|
||||||
|
Sleep(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.previousWndProc && IsWindow(hwnd))
|
||||||
|
SetWindowLongPtrA(hwnd, GWLP_WNDPROC,
|
||||||
|
reinterpret_cast<LONG_PTR>(state.previousWndProc));
|
||||||
|
activeState = nullptr;
|
||||||
|
if (IsWindow(hwnd)) closegraph();
|
||||||
|
|
||||||
|
std::vector<ImePositionEvent> events;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(state.eventsMutex);
|
||||||
|
events = state.events;
|
||||||
|
}
|
||||||
|
std::array<bool, Anchors.size()> anchorSucceeded{};
|
||||||
|
bool imeLifecycleObserved = false;
|
||||||
|
bool imeLifecycleSucceeded = false;
|
||||||
|
for (const auto& event : events)
|
||||||
|
{
|
||||||
|
if (event.positionTrigger == "ime_lifecycle") imeLifecycleObserved = true;
|
||||||
|
if (event.positionTrigger == "ime_lifecycle" && event.contextAvailable &&
|
||||||
|
event.compositionSucceeded && event.candidateSucceeded)
|
||||||
|
{
|
||||||
|
imeLifecycleSucceeded = true;
|
||||||
|
}
|
||||||
|
if (!event.apiAttempted || !event.compositionSucceeded || !event.candidateSucceeded)
|
||||||
|
continue;
|
||||||
|
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||||
|
{
|
||||||
|
if (event.anchorId == Anchors[index].id) anchorSucceeded[index] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool allAnchorsSucceeded = true;
|
||||||
|
for (const bool value : anchorSucceeded) allAnchorsSucceeded = allAnchorsSucceeded && value;
|
||||||
|
const bool completed = evidenceComplete && allAnchorsSucceeded && imeLifecycleSucceeded;
|
||||||
|
|
||||||
|
std::ostringstream result;
|
||||||
|
result << "{\"expectedImeName\":" << jsonString(options.expectedImeName)
|
||||||
|
<< ",\"windowOwnerThreadId\":" << windowOwnerThread
|
||||||
|
<< ",\"windowProcessId\":" << windowProcessId
|
||||||
|
<< ",\"subclassInstalled\":";
|
||||||
|
writeBoolean(result, state.previousWndProc != nullptr);
|
||||||
|
result << ",\"subclassError\":" << subclassError
|
||||||
|
<< ",\"allAnchorsApiSucceeded\":";
|
||||||
|
writeBoolean(result, allAnchorsSucceeded);
|
||||||
|
result << ",\"imeLifecycleObserved\":";
|
||||||
|
writeBoolean(result, imeLifecycleObserved);
|
||||||
|
result << ",\"imeLifecycleSucceeded\":";
|
||||||
|
writeBoolean(result, imeLifecycleSucceeded);
|
||||||
|
result << ",\"anchors\":[";
|
||||||
|
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) result << ',';
|
||||||
|
writeAnchor(result, Anchors[index]);
|
||||||
|
}
|
||||||
|
result << "],\"imeEvents\":[";
|
||||||
|
for (std::size_t index = 0; index < events.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) result << ',';
|
||||||
|
writeEvent(result, events[index]);
|
||||||
|
}
|
||||||
|
result << "]}";
|
||||||
|
|
||||||
|
std::string error;
|
||||||
|
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||||
|
evidence, result.str(), error))
|
||||||
|
{
|
||||||
|
std::cerr << error << '\n';
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return completed ? 0 : 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
#include "VisualProbeCommon.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <limits>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace VisualProbe
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||||
|
using GetDpiForWindowFn = UINT(WINAPI*)(HWND);
|
||||||
|
using GetThreadDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)();
|
||||||
|
using GetWindowDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)(HWND);
|
||||||
|
using GetAwarenessFromDpiAwarenessContextFn = DPI_AWARENESS(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||||
|
using GetProcessDpiAwarenessFn = HRESULT(WINAPI*)(HANDLE, int*);
|
||||||
|
|
||||||
|
std::string awarenessName(int awareness)
|
||||||
|
{
|
||||||
|
switch (awareness)
|
||||||
|
{
|
||||||
|
case 0: return "unaware";
|
||||||
|
case 1: return "system";
|
||||||
|
case 2: return "per-monitor";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string contextAwarenessName(DPI_AWARENESS_CONTEXT context) noexcept
|
||||||
|
{
|
||||||
|
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||||
|
const auto getAwareness = user32 ? reinterpret_cast<GetAwarenessFromDpiAwarenessContextFn>(
|
||||||
|
GetProcAddress(user32, "GetAwarenessFromDpiAwarenessContext")) : nullptr;
|
||||||
|
if (!getAwareness || !context)
|
||||||
|
return "not_available";
|
||||||
|
return awarenessName(static_cast<int>(getAwareness(context)));
|
||||||
|
}
|
||||||
|
|
||||||
|
DPI_AWARENESS_CONTEXT requestedContext(const std::string& requested) noexcept
|
||||||
|
{
|
||||||
|
if (requested == "unaware") return DPI_AWARENESS_CONTEXT_UNAWARE;
|
||||||
|
if (requested == "system") return DPI_AWARENESS_CONTEXT_SYSTEM_AWARE;
|
||||||
|
if (requested == "per-monitor") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE;
|
||||||
|
if (requested == "per-monitor-v2") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t fnv1a(const std::vector<std::uint32_t>& pixels) noexcept
|
||||||
|
{
|
||||||
|
std::uint64_t value = 1469598103934665603ull;
|
||||||
|
for (const std::uint32_t pixel : pixels)
|
||||||
|
{
|
||||||
|
for (unsigned int shift = 0; shift < 32; shift += 8)
|
||||||
|
{
|
||||||
|
value ^= static_cast<unsigned char>((pixel >> shift) & 0xFFu);
|
||||||
|
value *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint32_t> readPixels(
|
||||||
|
HDC hdc,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
int sourceX = 0,
|
||||||
|
int sourceY = 0) noexcept
|
||||||
|
{
|
||||||
|
std::vector<std::uint32_t> pixels;
|
||||||
|
if (!hdc || width <= 0 || height <= 0)
|
||||||
|
return pixels;
|
||||||
|
BITMAPINFO info{};
|
||||||
|
info.bmiHeader.biSize = sizeof(info.bmiHeader);
|
||||||
|
info.bmiHeader.biWidth = width;
|
||||||
|
info.bmiHeader.biHeight = -height;
|
||||||
|
info.bmiHeader.biPlanes = 1;
|
||||||
|
info.bmiHeader.biBitCount = 32;
|
||||||
|
info.bmiHeader.biCompression = BI_RGB;
|
||||||
|
void* bits = nullptr;
|
||||||
|
HDC memoryDc = CreateCompatibleDC(hdc);
|
||||||
|
if (!memoryDc)
|
||||||
|
return pixels;
|
||||||
|
HBITMAP bitmap = CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||||
|
if (!bitmap || !bits)
|
||||||
|
{
|
||||||
|
if (bitmap) DeleteObject(bitmap);
|
||||||
|
DeleteDC(memoryDc);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
HGDIOBJ previous = SelectObject(memoryDc, bitmap);
|
||||||
|
const bool copied = previous && BitBlt(memoryDc, 0, 0, width, height,
|
||||||
|
hdc, sourceX, sourceY, SRCCOPY) != FALSE;
|
||||||
|
if (copied)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto* first = static_cast<const std::uint32_t*>(bits);
|
||||||
|
pixels.assign(first, first + static_cast<std::size_t>(width) * height);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
pixels.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previous) SelectObject(memoryDc, previous);
|
||||||
|
DeleteObject(bitmap);
|
||||||
|
DeleteDC(memoryDc);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool xformEqual(const XFORM& left, const XFORM& right) noexcept
|
||||||
|
{
|
||||||
|
constexpr float Epsilon = 0.0001f;
|
||||||
|
return std::fabs(left.eM11 - right.eM11) < Epsilon &&
|
||||||
|
std::fabs(left.eM12 - right.eM12) < Epsilon &&
|
||||||
|
std::fabs(left.eM21 - right.eM21) < Epsilon &&
|
||||||
|
std::fabs(left.eM22 - right.eM22) < Epsilon &&
|
||||||
|
std::fabs(left.eDx - right.eDx) < Epsilon &&
|
||||||
|
std::fabs(left.eDy - right.eDy) < Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string captureOsBuild() noexcept
|
||||||
|
{
|
||||||
|
using RtlGetVersionFn = LONG(WINAPI*)(OSVERSIONINFOW*);
|
||||||
|
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||||
|
const auto rtlGetVersion = ntdll ? reinterpret_cast<RtlGetVersionFn>(
|
||||||
|
GetProcAddress(ntdll, "RtlGetVersion")) : nullptr;
|
||||||
|
OSVERSIONINFOW version{};
|
||||||
|
version.dwOSVersionInfoSize = sizeof(version);
|
||||||
|
if (!rtlGetVersion || rtlGetVersion(&version) != 0)
|
||||||
|
return "not_available";
|
||||||
|
std::ostringstream output;
|
||||||
|
output << version.dwMajorVersion << '.' << version.dwMinorVersion
|
||||||
|
<< '.' << version.dwBuildNumber;
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseOptions(int argc, char** argv, Options& options, std::string& error)
|
||||||
|
{
|
||||||
|
for (int index = 1; index < argc; ++index)
|
||||||
|
{
|
||||||
|
const std::string argument = argv[index];
|
||||||
|
if ((argument == "--mode" || argument == "--output" || argument == "--dpi-awareness" ||
|
||||||
|
argument == "--ime-name" || argument == "--auto-close-ms") &&
|
||||||
|
index + 1 >= argc)
|
||||||
|
{
|
||||||
|
error = "Missing value for " + argument;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (argument == "--mode") options.mode = argv[++index];
|
||||||
|
else if (argument == "--output") options.outputDirectory = argv[++index];
|
||||||
|
else if (argument == "--dpi-awareness") options.requestedDpiAwareness = argv[++index];
|
||||||
|
else if (argument == "--ime-name") options.expectedImeName = argv[++index];
|
||||||
|
else if (argument == "--auto-close-ms")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options.autoCloseMilliseconds = static_cast<unsigned int>(std::stoul(argv[++index]));
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
error = "Invalid auto-close duration";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
error = "Unknown argument: " + argument;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.mode != "wide-text" && options.mode != "dpi-coordinate" &&
|
||||||
|
options.mode != "ime-position")
|
||||||
|
{
|
||||||
|
error = "Mode must be wide-text, dpi-coordinate, or ime-position";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (options.outputDirectory.empty())
|
||||||
|
{
|
||||||
|
error = "Output directory is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> awarenessValues{
|
||||||
|
"inherit", "unaware", "system", "per-monitor", "per-monitor-v2"
|
||||||
|
};
|
||||||
|
if (std::find(awarenessValues.begin(), awarenessValues.end(), options.requestedDpiAwareness) ==
|
||||||
|
awarenessValues.end())
|
||||||
|
{
|
||||||
|
error = "Unsupported DPI awareness: " + options.requestedDpiAwareness;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> imeNames{ "unspecified", "MicrosoftPinyin", "Sogou" };
|
||||||
|
if (std::find(imeNames.begin(), imeNames.end(), options.expectedImeName) == imeNames.end())
|
||||||
|
{
|
||||||
|
error = "Unsupported IME name: " + options.expectedImeName;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::error_code fileError;
|
||||||
|
std::filesystem::create_directories(options.outputDirectory / "evidence", fileError);
|
||||||
|
if (fileError)
|
||||||
|
{
|
||||||
|
error = "Cannot create output directory: " + fileError.message();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept
|
||||||
|
{
|
||||||
|
DpiConfiguration result;
|
||||||
|
result.requested = requested;
|
||||||
|
if (requested == "inherit")
|
||||||
|
{
|
||||||
|
result.applied = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.attempted = true;
|
||||||
|
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||||
|
const auto setContext = user32 ? reinterpret_cast<SetProcessDpiAwarenessContextFn>(
|
||||||
|
GetProcAddress(user32, "SetProcessDpiAwarenessContext")) : nullptr;
|
||||||
|
if (!setContext)
|
||||||
|
{
|
||||||
|
result.error = ERROR_PROC_NOT_FOUND;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
SetLastError(ERROR_SUCCESS);
|
||||||
|
result.applied = setContext(requestedContext(requested)) != FALSE;
|
||||||
|
result.error = result.applied ? ERROR_SUCCESS : GetLastError();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept
|
||||||
|
{
|
||||||
|
EnvironmentSnapshot snapshot;
|
||||||
|
snapshot.processId = GetCurrentProcessId();
|
||||||
|
snapshot.threadId = GetCurrentThreadId();
|
||||||
|
snapshot.windowUnicode = hwnd && IsWindowUnicode(hwnd) != FALSE;
|
||||||
|
snapshot.osBuild = captureOsBuild();
|
||||||
|
const TCHAR* easyxVersion = GetEasyXVer();
|
||||||
|
snapshot.easyxVersion = easyxVersion ? easyxVersion : "not_available";
|
||||||
|
if (hwnd)
|
||||||
|
{
|
||||||
|
GetClientRect(hwnd, &snapshot.clientRect);
|
||||||
|
GetWindowRect(hwnd, &snapshot.windowRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||||
|
const auto getDpi = user32 ? reinterpret_cast<GetDpiForWindowFn>(
|
||||||
|
GetProcAddress(user32, "GetDpiForWindow")) : nullptr;
|
||||||
|
const auto getThreadContext = user32 ? reinterpret_cast<GetThreadDpiAwarenessContextFn>(
|
||||||
|
GetProcAddress(user32, "GetThreadDpiAwarenessContext")) : nullptr;
|
||||||
|
const auto getWindowContext = user32 ? reinterpret_cast<GetWindowDpiAwarenessContextFn>(
|
||||||
|
GetProcAddress(user32, "GetWindowDpiAwarenessContext")) : nullptr;
|
||||||
|
snapshot.dpiForWindow = getDpi && hwnd ? getDpi(hwnd) : 0;
|
||||||
|
snapshot.threadAwareness = contextAwarenessName(getThreadContext ? getThreadContext() : nullptr);
|
||||||
|
snapshot.windowAwareness = contextAwarenessName(
|
||||||
|
getWindowContext && hwnd ? getWindowContext(hwnd) : nullptr);
|
||||||
|
|
||||||
|
HMODULE shcore = LoadLibraryW(L"shcore.dll");
|
||||||
|
const auto getProcessAwareness = shcore ? reinterpret_cast<GetProcessDpiAwarenessFn>(
|
||||||
|
GetProcAddress(shcore, "GetProcessDpiAwareness")) : nullptr;
|
||||||
|
int processAwareness = -1;
|
||||||
|
if (getProcessAwareness && SUCCEEDED(getProcessAwareness(nullptr, &processAwareness)))
|
||||||
|
snapshot.processAwareness = awarenessName(processAwareness);
|
||||||
|
else
|
||||||
|
snapshot.processAwareness = "not_available";
|
||||||
|
if (shcore) FreeLibrary(shcore);
|
||||||
|
|
||||||
|
HDC screen = GetDC(nullptr);
|
||||||
|
if (screen)
|
||||||
|
{
|
||||||
|
snapshot.deviceDpiX = GetDeviceCaps(screen, LOGPIXELSX);
|
||||||
|
snapshot.deviceDpiY = GetDeviceCaps(screen, LOGPIXELSY);
|
||||||
|
ReleaseDC(nullptr, screen);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
GdiState captureGdiState(HDC hdc) noexcept
|
||||||
|
{
|
||||||
|
GdiState state;
|
||||||
|
if (!hdc) return state;
|
||||||
|
state.mapMode = GetMapMode(hdc);
|
||||||
|
GetViewportOrgEx(hdc, &state.viewportOrigin);
|
||||||
|
GetWindowOrgEx(hdc, &state.windowOrigin);
|
||||||
|
GetViewportExtEx(hdc, &state.viewportExtent);
|
||||||
|
GetWindowExtEx(hdc, &state.windowExtent);
|
||||||
|
state.graphicsMode = GetGraphicsMode(hdc);
|
||||||
|
state.worldTransformAvailable = GetWorldTransform(hdc, &state.worldTransform) != FALSE;
|
||||||
|
state.backgroundMode = GetBkMode(hdc);
|
||||||
|
state.textColor = GetTextColor(hdc);
|
||||||
|
state.backgroundColor = GetBkColor(hdc);
|
||||||
|
state.textAlign = GetTextAlign(hdc);
|
||||||
|
state.textCharacterExtra = GetTextCharacterExtra(hdc);
|
||||||
|
state.clipType = GetClipBox(hdc, &state.clipBox);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sameGdiState(const GdiState& left, const GdiState& right) noexcept
|
||||||
|
{
|
||||||
|
return left.mapMode == right.mapMode &&
|
||||||
|
left.viewportOrigin.x == right.viewportOrigin.x &&
|
||||||
|
left.viewportOrigin.y == right.viewportOrigin.y &&
|
||||||
|
left.windowOrigin.x == right.windowOrigin.x &&
|
||||||
|
left.windowOrigin.y == right.windowOrigin.y &&
|
||||||
|
left.viewportExtent.cx == right.viewportExtent.cx &&
|
||||||
|
left.viewportExtent.cy == right.viewportExtent.cy &&
|
||||||
|
left.windowExtent.cx == right.windowExtent.cx &&
|
||||||
|
left.windowExtent.cy == right.windowExtent.cy &&
|
||||||
|
left.graphicsMode == right.graphicsMode &&
|
||||||
|
left.worldTransformAvailable == right.worldTransformAvailable &&
|
||||||
|
(!left.worldTransformAvailable || xformEqual(left.worldTransform, right.worldTransform)) &&
|
||||||
|
left.backgroundMode == right.backgroundMode &&
|
||||||
|
left.textColor == right.textColor &&
|
||||||
|
left.backgroundColor == right.backgroundColor &&
|
||||||
|
left.textAlign == right.textAlign &&
|
||||||
|
left.textCharacterExtra == right.textCharacterExtra &&
|
||||||
|
left.clipType == right.clipType &&
|
||||||
|
EqualRect(&left.clipBox, &right.clipBox) != FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept
|
||||||
|
{
|
||||||
|
SurfaceFingerprint fingerprint;
|
||||||
|
fingerprint.hdc = reinterpret_cast<std::uintptr_t>(hdc);
|
||||||
|
fingerprint.hbitmap = reinterpret_cast<std::uintptr_t>(
|
||||||
|
hdc ? GetCurrentObject(hdc, OBJ_BITMAP) : nullptr);
|
||||||
|
fingerprint.hfont = reinterpret_cast<std::uintptr_t>(
|
||||||
|
hdc ? GetCurrentObject(hdc, OBJ_FONT) : nullptr);
|
||||||
|
if (fingerprint.hfont)
|
||||||
|
{
|
||||||
|
fingerprint.logFontAvailable = GetObjectW(
|
||||||
|
reinterpret_cast<HFONT>(fingerprint.hfont),
|
||||||
|
sizeof(fingerprint.logFont), &fingerprint.logFont) == sizeof(fingerprint.logFont);
|
||||||
|
}
|
||||||
|
if (fingerprint.hbitmap)
|
||||||
|
{
|
||||||
|
BITMAP bitmap{};
|
||||||
|
if (GetObject(reinterpret_cast<HBITMAP>(fingerprint.hbitmap), sizeof(bitmap), &bitmap) ==
|
||||||
|
sizeof(bitmap))
|
||||||
|
{
|
||||||
|
fingerprint.bitmapInfoAvailable = true;
|
||||||
|
fingerprint.bitmapWidth = bitmap.bmWidth;
|
||||||
|
fingerprint.bitmapHeight = bitmap.bmHeight;
|
||||||
|
fingerprint.planes = bitmap.bmPlanes;
|
||||||
|
fingerprint.bitsPerPixel = bitmap.bmBitsPixel;
|
||||||
|
fingerprint.stride = bitmap.bmWidthBytes;
|
||||||
|
}
|
||||||
|
DIBSECTION section{};
|
||||||
|
fingerprint.dibSection = GetObject(reinterpret_cast<HBITMAP>(fingerprint.hbitmap),
|
||||||
|
sizeof(section), §ion) == sizeof(section);
|
||||||
|
}
|
||||||
|
const auto pixels = readPixels(hdc, width, height);
|
||||||
|
fingerprint.pixelDigest = fnv1a(pixels);
|
||||||
|
fingerprint.gdiState = captureGdiState(hdc);
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept
|
||||||
|
{
|
||||||
|
const int width = region.right - region.left;
|
||||||
|
const int height = region.bottom - region.top;
|
||||||
|
return fnv1a(readPixels(hdc, width, height, region.left, region.top));
|
||||||
|
}
|
||||||
|
|
||||||
|
PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept
|
||||||
|
{
|
||||||
|
PixelBounds bounds;
|
||||||
|
for (int y = region.top; y < region.bottom; ++y)
|
||||||
|
{
|
||||||
|
for (int x = region.left; x < region.right; ++x)
|
||||||
|
{
|
||||||
|
if (GetPixel(hdc, x, y) != color) continue;
|
||||||
|
if (!bounds.present)
|
||||||
|
{
|
||||||
|
bounds.present = true;
|
||||||
|
bounds.left = bounds.right = x;
|
||||||
|
bounds.top = bounds.bottom = y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bounds.left = (std::min)(bounds.left, x);
|
||||||
|
bounds.top = (std::min)(bounds.top, y);
|
||||||
|
bounds.right = (std::max)(bounds.right, x);
|
||||||
|
bounds.bottom = (std::max)(bounds.bottom, y);
|
||||||
|
}
|
||||||
|
++bounds.pixelCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept
|
||||||
|
{
|
||||||
|
PixelBounds bounds;
|
||||||
|
for (int y = region.top; y < region.bottom; ++y)
|
||||||
|
{
|
||||||
|
for (int x = region.left; x < region.right; ++x)
|
||||||
|
{
|
||||||
|
const COLORREF pixel = GetPixel(hdc, x, y);
|
||||||
|
if (pixel == CLR_INVALID || pixel == background) continue;
|
||||||
|
if (!bounds.present)
|
||||||
|
{
|
||||||
|
bounds.present = true;
|
||||||
|
bounds.left = bounds.right = x;
|
||||||
|
bounds.top = bounds.bottom = y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bounds.left = (std::min)(bounds.left, x);
|
||||||
|
bounds.top = (std::min)(bounds.top, y);
|
||||||
|
bounds.right = (std::max)(bounds.right, x);
|
||||||
|
bounds.bottom = (std::max)(bounds.bottom, y);
|
||||||
|
}
|
||||||
|
++bounds.pixelCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept
|
||||||
|
{
|
||||||
|
if (!source || width <= 0 || height <= 0) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IMAGE image(width, height);
|
||||||
|
HDC destination = GetImageHDC(&image);
|
||||||
|
if (!destination || BitBlt(destination, 0, 0, width, height, source, 0, 0, SRCCOPY) == FALSE)
|
||||||
|
return false;
|
||||||
|
saveimage(path.string().c_str(), &image);
|
||||||
|
return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool captureClientImage(
|
||||||
|
HWND hwnd,
|
||||||
|
const std::filesystem::path& path,
|
||||||
|
std::string* captureBackend) noexcept
|
||||||
|
{
|
||||||
|
if (!hwnd) return false;
|
||||||
|
RECT client{};
|
||||||
|
if (!GetClientRect(hwnd, &client)) return false;
|
||||||
|
const int width = client.right - client.left;
|
||||||
|
const int height = client.bottom - client.top;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IMAGE image(width, height);
|
||||||
|
HDC destination = GetImageHDC(&image);
|
||||||
|
if (!destination) return false;
|
||||||
|
PatBlt(destination, 0, 0, width, height, BLACKNESS);
|
||||||
|
bool captured = false;
|
||||||
|
HDC clientDc = GetDC(hwnd);
|
||||||
|
if (clientDc)
|
||||||
|
{
|
||||||
|
captured = BitBlt(destination, 0, 0, width, height,
|
||||||
|
clientDc, 0, 0, SRCCOPY) != FALSE;
|
||||||
|
ReleaseDC(hwnd, clientDc);
|
||||||
|
}
|
||||||
|
if (captured)
|
||||||
|
{
|
||||||
|
if (captureBackend) *captureBackend = "client_getdc_bitblt";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
captured = PrintWindow(hwnd, destination, PW_CLIENTONLY) != FALSE;
|
||||||
|
if (captured && captureBackend) *captureBackend = "printwindow_fallback";
|
||||||
|
}
|
||||||
|
if (!captured) return false;
|
||||||
|
saveimage(path.string().c_str(), &image);
|
||||||
|
return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring makeUtf16(const std::vector<std::uint32_t>& codePoints, bool& valid)
|
||||||
|
{
|
||||||
|
std::wstring text;
|
||||||
|
valid = true;
|
||||||
|
for (const std::uint32_t codePoint : codePoints)
|
||||||
|
{
|
||||||
|
if (codePoint > 0x10FFFFu || (codePoint >= 0xD800u && codePoint <= 0xDFFFu))
|
||||||
|
{
|
||||||
|
valid = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (codePoint <= 0xFFFFu)
|
||||||
|
text.push_back(static_cast<wchar_t>(codePoint));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const std::uint32_t adjusted = codePoint - 0x10000u;
|
||||||
|
text.push_back(static_cast<wchar_t>(0xD800u + (adjusted >> 10)));
|
||||||
|
text.push_back(static_cast<wchar_t>(0xDC00u + (adjusted & 0x3FFu)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string utf16UnitsJson(const std::wstring& text)
|
||||||
|
{
|
||||||
|
std::ostringstream output;
|
||||||
|
output << '[';
|
||||||
|
for (std::size_t index = 0; index < text.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) output << ',';
|
||||||
|
output << static_cast<unsigned int>(static_cast<std::uint16_t>(text[index]));
|
||||||
|
}
|
||||||
|
output << ']';
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string codePointsJson(const std::vector<std::uint32_t>& codePoints)
|
||||||
|
{
|
||||||
|
std::ostringstream output;
|
||||||
|
output << '[';
|
||||||
|
for (std::size_t index = 0; index < codePoints.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) output << ',';
|
||||||
|
output << codePoints[index];
|
||||||
|
}
|
||||||
|
output << ']';
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string jsonString(const std::string& value)
|
||||||
|
{
|
||||||
|
std::ostringstream output;
|
||||||
|
output << '"';
|
||||||
|
for (const unsigned char character : value)
|
||||||
|
{
|
||||||
|
switch (character)
|
||||||
|
{
|
||||||
|
case '"': output << "\\\""; break;
|
||||||
|
case '\\': output << "\\\\"; break;
|
||||||
|
case '\b': output << "\\b"; break;
|
||||||
|
case '\f': output << "\\f"; break;
|
||||||
|
case '\n': output << "\\n"; break;
|
||||||
|
case '\r': output << "\\r"; break;
|
||||||
|
case '\t': output << "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (character < 0x20u)
|
||||||
|
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||||
|
<< static_cast<unsigned int>(character) << std::dec;
|
||||||
|
else
|
||||||
|
output << static_cast<char>(character);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output << '"';
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string hexValue(std::uint64_t value)
|
||||||
|
{
|
||||||
|
std::ostringstream output;
|
||||||
|
output << "0x" << std::hex << std::uppercase << value;
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeBoolean(std::ostream& output, bool value)
|
||||||
|
{
|
||||||
|
output << (value ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeRectJson(std::ostream& output, const RECT& rect)
|
||||||
|
{
|
||||||
|
output << "{\"left\":" << rect.left << ",\"top\":" << rect.top
|
||||||
|
<< ",\"right\":" << rect.right << ",\"bottom\":" << rect.bottom << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writePointJson(std::ostream& output, const POINT& point)
|
||||||
|
{
|
||||||
|
output << "{\"x\":" << point.x << ",\"y\":" << point.y << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeSizeJson(std::ostream& output, const SIZE& size)
|
||||||
|
{
|
||||||
|
output << "{\"cx\":" << size.cx << ",\"cy\":" << size.cy << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeBoundsJson(std::ostream& output, const PixelBounds& bounds)
|
||||||
|
{
|
||||||
|
output << "{\"present\":";
|
||||||
|
writeBoolean(output, bounds.present);
|
||||||
|
output << ",\"left\":" << bounds.left << ",\"top\":" << bounds.top
|
||||||
|
<< ",\"right\":" << bounds.right << ",\"bottom\":" << bounds.bottom
|
||||||
|
<< ",\"pixelCount\":" << bounds.pixelCount << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeGdiStateJson(std::ostream& output, const GdiState& state)
|
||||||
|
{
|
||||||
|
output << "{\"mapMode\":" << state.mapMode << ",\"viewportOrigin\":";
|
||||||
|
writePointJson(output, state.viewportOrigin);
|
||||||
|
output << ",\"windowOrigin\":";
|
||||||
|
writePointJson(output, state.windowOrigin);
|
||||||
|
output << ",\"viewportExtent\":";
|
||||||
|
writeSizeJson(output, state.viewportExtent);
|
||||||
|
output << ",\"windowExtent\":";
|
||||||
|
writeSizeJson(output, state.windowExtent);
|
||||||
|
output << ",\"graphicsMode\":" << state.graphicsMode
|
||||||
|
<< ",\"worldTransformAvailable\":";
|
||||||
|
writeBoolean(output, state.worldTransformAvailable);
|
||||||
|
output << ",\"worldTransform\":{\"m11\":" << state.worldTransform.eM11
|
||||||
|
<< ",\"m12\":" << state.worldTransform.eM12
|
||||||
|
<< ",\"m21\":" << state.worldTransform.eM21
|
||||||
|
<< ",\"m22\":" << state.worldTransform.eM22
|
||||||
|
<< ",\"dx\":" << state.worldTransform.eDx
|
||||||
|
<< ",\"dy\":" << state.worldTransform.eDy << "}"
|
||||||
|
<< ",\"backgroundMode\":" << state.backgroundMode
|
||||||
|
<< ",\"textColor\":" << state.textColor
|
||||||
|
<< ",\"backgroundColor\":" << state.backgroundColor
|
||||||
|
<< ",\"textAlign\":" << state.textAlign
|
||||||
|
<< ",\"textCharacterExtra\":" << state.textCharacterExtra
|
||||||
|
<< ",\"clipType\":" << state.clipType << ",\"clipBox\":";
|
||||||
|
writeRectJson(output, state.clipBox);
|
||||||
|
output << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint)
|
||||||
|
{
|
||||||
|
output << "{\"hdc\":" << jsonString(hexValue(fingerprint.hdc))
|
||||||
|
<< ",\"hbitmap\":" << jsonString(hexValue(fingerprint.hbitmap))
|
||||||
|
<< ",\"hfont\":" << jsonString(hexValue(fingerprint.hfont))
|
||||||
|
<< ",\"bitmapInfoAvailable\":";
|
||||||
|
writeBoolean(output, fingerprint.bitmapInfoAvailable);
|
||||||
|
output << ",\"bitmapWidth\":" << fingerprint.bitmapWidth
|
||||||
|
<< ",\"bitmapHeight\":" << fingerprint.bitmapHeight
|
||||||
|
<< ",\"planes\":" << fingerprint.planes
|
||||||
|
<< ",\"bitsPerPixel\":" << fingerprint.bitsPerPixel
|
||||||
|
<< ",\"stride\":" << fingerprint.stride
|
||||||
|
<< ",\"dibSection\":";
|
||||||
|
writeBoolean(output, fingerprint.dibSection);
|
||||||
|
output << ",\"logFontAvailable\":";
|
||||||
|
writeBoolean(output, fingerprint.logFontAvailable);
|
||||||
|
output << ",\"logFont\":{"
|
||||||
|
<< "\"height\":" << fingerprint.logFont.lfHeight
|
||||||
|
<< ",\"width\":" << fingerprint.logFont.lfWidth
|
||||||
|
<< ",\"weight\":" << fingerprint.logFont.lfWeight
|
||||||
|
<< ",\"italic\":" << static_cast<unsigned int>(fingerprint.logFont.lfItalic)
|
||||||
|
<< ",\"charset\":" << static_cast<unsigned int>(fingerprint.logFont.lfCharSet)
|
||||||
|
<< ",\"faceNameUtf16\":[";
|
||||||
|
for (std::size_t index = 0;
|
||||||
|
index < LF_FACESIZE && fingerprint.logFont.lfFaceName[index]; ++index)
|
||||||
|
{
|
||||||
|
if (index) output << ',';
|
||||||
|
output << static_cast<unsigned int>(fingerprint.logFont.lfFaceName[index]);
|
||||||
|
}
|
||||||
|
output << "]}";
|
||||||
|
output << ",\"pixelDigest\":" << jsonString(hexValue(fingerprint.pixelDigest))
|
||||||
|
<< ",\"gdiState\":";
|
||||||
|
writeGdiStateJson(output, fingerprint.gdiState);
|
||||||
|
output << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeRawResult(
|
||||||
|
const Options& options,
|
||||||
|
const DpiConfiguration& dpiConfiguration,
|
||||||
|
const EnvironmentSnapshot& environment,
|
||||||
|
bool completed,
|
||||||
|
const std::vector<EvidenceFile>& evidence,
|
||||||
|
const std::string& resultJson,
|
||||||
|
std::string& error)
|
||||||
|
{
|
||||||
|
const auto path = options.outputDirectory / "raw-result.json";
|
||||||
|
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!output)
|
||||||
|
{
|
||||||
|
error = "Cannot open raw result: " + path.string();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
output << "{\n \"rawSchemaVersion\":1,\n \"mode\":" << jsonString(options.mode)
|
||||||
|
<< ",\n \"completed\":";
|
||||||
|
writeBoolean(output, completed);
|
||||||
|
output << ",\n \"dpiConfiguration\":{\"requested\":"
|
||||||
|
<< jsonString(dpiConfiguration.requested) << ",\"attempted\":";
|
||||||
|
writeBoolean(output, dpiConfiguration.attempted);
|
||||||
|
output << ",\"applied\":";
|
||||||
|
writeBoolean(output, dpiConfiguration.applied);
|
||||||
|
output << ",\"error\":" << dpiConfiguration.error << "},\n \"environment\":{"
|
||||||
|
<< "\"processId\":" << environment.processId
|
||||||
|
<< ",\"threadId\":" << environment.threadId
|
||||||
|
<< ",\"windowUnicode\":";
|
||||||
|
writeBoolean(output, environment.windowUnicode);
|
||||||
|
output << ",\"dpiForWindow\":" << environment.dpiForWindow
|
||||||
|
<< ",\"deviceDpiX\":" << environment.deviceDpiX
|
||||||
|
<< ",\"deviceDpiY\":" << environment.deviceDpiY
|
||||||
|
<< ",\"processAwareness\":" << jsonString(environment.processAwareness)
|
||||||
|
<< ",\"threadAwareness\":" << jsonString(environment.threadAwareness)
|
||||||
|
<< ",\"windowAwareness\":" << jsonString(environment.windowAwareness)
|
||||||
|
<< ",\"osBuild\":" << jsonString(environment.osBuild)
|
||||||
|
<< ",\"easyxVersion\":" << jsonString(environment.easyxVersion)
|
||||||
|
<< ",\"clientRect\":";
|
||||||
|
writeRectJson(output, environment.clientRect);
|
||||||
|
output << ",\"windowRect\":";
|
||||||
|
writeRectJson(output, environment.windowRect);
|
||||||
|
output << "},\n \"evidenceFiles\":[";
|
||||||
|
for (std::size_t index = 0; index < evidence.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) output << ',';
|
||||||
|
output << "{\"path\":" << jsonString(evidence[index].relativePath)
|
||||||
|
<< ",\"purpose\":" << jsonString(evidence[index].purpose)
|
||||||
|
<< ",\"captureBackend\":" << jsonString(evidence[index].captureBackend) << '}';
|
||||||
|
}
|
||||||
|
output << "],\n \"result\":" << resultJson << "\n}\n";
|
||||||
|
if (!output.good())
|
||||||
|
{
|
||||||
|
error = "Failed writing raw result: " + path.string();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <easyx.h>
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <ostream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace VisualProbe
|
||||||
|
{
|
||||||
|
constexpr int CanvasWidth = 960;
|
||||||
|
constexpr int CanvasHeight = 720;
|
||||||
|
|
||||||
|
struct Options
|
||||||
|
{
|
||||||
|
std::string mode;
|
||||||
|
std::filesystem::path outputDirectory;
|
||||||
|
std::string requestedDpiAwareness = "inherit";
|
||||||
|
std::string expectedImeName = "unspecified";
|
||||||
|
unsigned int autoCloseMilliseconds = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DpiConfiguration
|
||||||
|
{
|
||||||
|
std::string requested = "inherit";
|
||||||
|
bool attempted = false;
|
||||||
|
bool applied = false;
|
||||||
|
DWORD error = ERROR_SUCCESS;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EvidenceFile
|
||||||
|
{
|
||||||
|
std::string relativePath;
|
||||||
|
std::string purpose;
|
||||||
|
std::string captureBackend;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PixelBounds
|
||||||
|
{
|
||||||
|
bool present = false;
|
||||||
|
int left = 0;
|
||||||
|
int top = 0;
|
||||||
|
int right = 0;
|
||||||
|
int bottom = 0;
|
||||||
|
std::uint64_t pixelCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GdiState
|
||||||
|
{
|
||||||
|
int mapMode = 0;
|
||||||
|
POINT viewportOrigin{};
|
||||||
|
POINT windowOrigin{};
|
||||||
|
SIZE viewportExtent{};
|
||||||
|
SIZE windowExtent{};
|
||||||
|
int graphicsMode = 0;
|
||||||
|
bool worldTransformAvailable = false;
|
||||||
|
XFORM worldTransform{};
|
||||||
|
int backgroundMode = 0;
|
||||||
|
COLORREF textColor = CLR_INVALID;
|
||||||
|
COLORREF backgroundColor = CLR_INVALID;
|
||||||
|
UINT textAlign = GDI_ERROR;
|
||||||
|
int textCharacterExtra = 0;
|
||||||
|
RECT clipBox{};
|
||||||
|
int clipType = ERROR;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SurfaceFingerprint
|
||||||
|
{
|
||||||
|
std::uintptr_t hdc = 0;
|
||||||
|
std::uintptr_t hbitmap = 0;
|
||||||
|
std::uintptr_t hfont = 0;
|
||||||
|
int bitmapWidth = 0;
|
||||||
|
int bitmapHeight = 0;
|
||||||
|
int planes = 0;
|
||||||
|
int bitsPerPixel = 0;
|
||||||
|
int stride = 0;
|
||||||
|
bool bitmapInfoAvailable = false;
|
||||||
|
bool dibSection = false;
|
||||||
|
bool logFontAvailable = false;
|
||||||
|
LOGFONTW logFont{};
|
||||||
|
std::uint64_t pixelDigest = 0;
|
||||||
|
GdiState gdiState{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EnvironmentSnapshot
|
||||||
|
{
|
||||||
|
DWORD processId = 0;
|
||||||
|
DWORD threadId = 0;
|
||||||
|
bool windowUnicode = false;
|
||||||
|
UINT dpiForWindow = 0;
|
||||||
|
int deviceDpiX = 0;
|
||||||
|
int deviceDpiY = 0;
|
||||||
|
std::string processAwareness;
|
||||||
|
std::string threadAwareness;
|
||||||
|
std::string windowAwareness;
|
||||||
|
std::string osBuild;
|
||||||
|
std::string easyxVersion;
|
||||||
|
RECT clientRect{};
|
||||||
|
RECT windowRect{};
|
||||||
|
};
|
||||||
|
|
||||||
|
bool parseOptions(int argc, char** argv, Options& options, std::string& error);
|
||||||
|
DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept;
|
||||||
|
EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept;
|
||||||
|
GdiState captureGdiState(HDC hdc) noexcept;
|
||||||
|
bool sameGdiState(const GdiState& left, const GdiState& right) noexcept;
|
||||||
|
SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept;
|
||||||
|
std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept;
|
||||||
|
PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept;
|
||||||
|
PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept;
|
||||||
|
bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept;
|
||||||
|
bool captureClientImage(
|
||||||
|
HWND hwnd,
|
||||||
|
const std::filesystem::path& path,
|
||||||
|
std::string* captureBackend = nullptr) noexcept;
|
||||||
|
std::wstring makeUtf16(const std::vector<std::uint32_t>& codePoints, bool& valid);
|
||||||
|
std::string utf16UnitsJson(const std::wstring& text);
|
||||||
|
std::string codePointsJson(const std::vector<std::uint32_t>& codePoints);
|
||||||
|
std::string jsonString(const std::string& value);
|
||||||
|
std::string hexValue(std::uint64_t value);
|
||||||
|
void writeBoolean(std::ostream& output, bool value);
|
||||||
|
void writeRectJson(std::ostream& output, const RECT& rect);
|
||||||
|
void writePointJson(std::ostream& output, const POINT& point);
|
||||||
|
void writeSizeJson(std::ostream& output, const SIZE& size);
|
||||||
|
void writeBoundsJson(std::ostream& output, const PixelBounds& bounds);
|
||||||
|
void writeGdiStateJson(std::ostream& output, const GdiState& state);
|
||||||
|
void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint);
|
||||||
|
bool writeRawResult(
|
||||||
|
const Options& options,
|
||||||
|
const DpiConfiguration& dpiConfiguration,
|
||||||
|
const EnvironmentSnapshot& environment,
|
||||||
|
bool completed,
|
||||||
|
const std::vector<EvidenceFile>& evidence,
|
||||||
|
const std::string& resultJson,
|
||||||
|
std::string& error);
|
||||||
|
|
||||||
|
int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||||
|
int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||||
|
int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#include "VisualProbeCommon.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
VisualProbe::Options options;
|
||||||
|
std::string error;
|
||||||
|
if (!VisualProbe::parseOptions(argc, argv, options, error))
|
||||||
|
{
|
||||||
|
std::cerr << error << "\n"
|
||||||
|
<< "Usage: EasyX.VisualProbe.exe --mode wide-text|dpi-coordinate|ime-position "
|
||||||
|
<< "--output <directory> [--dpi-awareness inherit|unaware|system|per-monitor|per-monitor-v2] "
|
||||||
|
<< "[--ime-name unspecified|MicrosoftPinyin|Sogou] [--auto-close-ms N]\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VisualProbe::DpiConfiguration dpiConfiguration =
|
||||||
|
VisualProbe::applyRequestedDpiAwareness(options.requestedDpiAwareness);
|
||||||
|
if (options.mode == "wide-text")
|
||||||
|
return VisualProbe::runWideTextProbe(options, dpiConfiguration);
|
||||||
|
if (options.mode == "dpi-coordinate")
|
||||||
|
return VisualProbe::runDpiCoordinateProbe(options, dpiConfiguration);
|
||||||
|
if (options.mode == "ime-position")
|
||||||
|
return VisualProbe::runImePositionProbe(options, dpiConfiguration);
|
||||||
|
|
||||||
|
std::cerr << "Unsupported mode: " << options.mode << '\n';
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
#include "VisualProbeCommon.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace VisualProbe
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr COLORREF MainBackground = RGB(22, 34, 56);
|
||||||
|
constexpr COLORREF WorkingBackground = RGB(64, 28, 46);
|
||||||
|
constexpr COLORREF EasyXMarker = RGB(30, 210, 235);
|
||||||
|
constexpr COLORREF WideTextColor = RGB(246, 224, 64);
|
||||||
|
|
||||||
|
struct TextTest
|
||||||
|
{
|
||||||
|
const char* id;
|
||||||
|
std::vector<std::uint32_t> codePoints;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TextApiResult
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
std::vector<std::uint32_t> codePoints;
|
||||||
|
std::wstring text;
|
||||||
|
bool validUtf16 = false;
|
||||||
|
bool extentSuccess = false;
|
||||||
|
SIZE extent{};
|
||||||
|
bool textOutSuccess = false;
|
||||||
|
int drawTextHeight = 0;
|
||||||
|
PixelBounds textOutBounds{};
|
||||||
|
PixelBounds drawTextBounds{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WideCaseResult
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
std::string surfaceId;
|
||||||
|
std::string batchState;
|
||||||
|
SurfaceFingerprint before{};
|
||||||
|
SurfaceFingerprint afterDraw{};
|
||||||
|
SurfaceFingerprint afterFlush{};
|
||||||
|
SurfaceFingerprint afterEnd{};
|
||||||
|
SurfaceFingerprint afterEasyXFollowUp{};
|
||||||
|
SurfaceFingerprint nonTargetBefore{};
|
||||||
|
SurfaceFingerprint nonTargetAfterDraw{};
|
||||||
|
bool targetChanged = false;
|
||||||
|
bool nonTargetUnchanged = false;
|
||||||
|
bool gdiStateRestored = false;
|
||||||
|
bool flushApplicable = false;
|
||||||
|
bool endApplicable = false;
|
||||||
|
bool explicitWorkingComposition = false;
|
||||||
|
bool wideContentPreservedAfterEasyXFollowUp = false;
|
||||||
|
bool evidenceComplete = true;
|
||||||
|
std::vector<TextApiResult> textResults;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<TextTest> fixedTests()
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{ "ascii", { 0x0053, 0x0074, 0x0065, 0x006C, 0x006C, 0x0061, 0x0072,
|
||||||
|
0x0058, 0x0020, 0x0030, 0x0043, 0x0020, 0x0050, 0x0072, 0x006F, 0x0062, 0x0065 } },
|
||||||
|
{ "cjk_bmp", { 0x661F, 0x57A3, 0x7ED8, 0x5236, 0x6D4B, 0x8BD5 } },
|
||||||
|
{ "supplementary", { 0x1F600 } },
|
||||||
|
{ "combining_nfd", { 0x0065, 0x0301 } },
|
||||||
|
{ "mixed", { 0x661F, 0x57A3, 0x0020, 0x0041, 0x1F600, 0x0042,
|
||||||
|
0x0020, 0x0065, 0x0301 } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkpoint(const Options& options, const std::string& value) noexcept
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::ofstream output(options.outputDirectory / "progress.log",
|
||||||
|
std::ios::binary | std::ios::app);
|
||||||
|
output << GetTickCount64() << ' ' << value << '\n';
|
||||||
|
output.flush();
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSurface(IMAGE* image, COLORREF background)
|
||||||
|
{
|
||||||
|
SetWorkingImage(image);
|
||||||
|
setbkcolor(background);
|
||||||
|
cleardevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string normalizedRelative(const std::filesystem::path& path)
|
||||||
|
{
|
||||||
|
std::string value = path.generic_string();
|
||||||
|
std::replace(value.begin(), value.end(), '\\', '/');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addSurfaceEvidence(
|
||||||
|
const Options& options,
|
||||||
|
HDC hdc,
|
||||||
|
const std::string& fileName,
|
||||||
|
const std::string& purpose,
|
||||||
|
std::vector<EvidenceFile>& evidence)
|
||||||
|
{
|
||||||
|
const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
|
||||||
|
const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
|
||||||
|
options.outputDirectory / relative);
|
||||||
|
if (saved)
|
||||||
|
evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addClientEvidence(
|
||||||
|
const Options& options,
|
||||||
|
HWND hwnd,
|
||||||
|
const std::string& fileName,
|
||||||
|
const std::string& purpose,
|
||||||
|
std::vector<EvidenceFile>& evidence)
|
||||||
|
{
|
||||||
|
const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
|
||||||
|
Sleep(30);
|
||||||
|
std::string backend;
|
||||||
|
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||||
|
if (saved)
|
||||||
|
evidence.push_back({ normalizedRelative(relative), purpose, backend });
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawEasyXBaseline()
|
||||||
|
{
|
||||||
|
setfillcolor(EasyXMarker);
|
||||||
|
solidrectangle(20, 20, 170, 58);
|
||||||
|
settextcolor(EasyXMarker);
|
||||||
|
setbkmode(TRANSPARENT);
|
||||||
|
settextstyle(22, 0, "Microsoft YaHei UI");
|
||||||
|
outtextxy(20, 68, "EasyX baseline");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<TextApiResult> drawWideText(HDC hdc, COLORREF background, bool& restored)
|
||||||
|
{
|
||||||
|
std::vector<TextApiResult> results;
|
||||||
|
const GdiState before = captureGdiState(hdc);
|
||||||
|
const int saved = SaveDC(hdc);
|
||||||
|
HFONT font = CreateFontW(-28, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
|
||||||
|
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||||
|
DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI");
|
||||||
|
HGDIOBJ previousFont = font ? SelectObject(hdc, font) : nullptr;
|
||||||
|
SetBkMode(hdc, TRANSPARENT);
|
||||||
|
SetTextColor(hdc, WideTextColor);
|
||||||
|
|
||||||
|
const auto tests = fixedTests();
|
||||||
|
for (std::size_t index = 0; index < tests.size(); ++index)
|
||||||
|
{
|
||||||
|
TextApiResult result;
|
||||||
|
result.id = tests[index].id;
|
||||||
|
result.codePoints = tests[index].codePoints;
|
||||||
|
result.text = makeUtf16(result.codePoints, result.validUtf16);
|
||||||
|
const int y = 120 + static_cast<int>(index) * 105;
|
||||||
|
result.extentSuccess = GetTextExtentPoint32W(hdc, result.text.data(),
|
||||||
|
static_cast<int>(result.text.size()), &result.extent) != FALSE;
|
||||||
|
result.textOutSuccess = TextOutW(hdc, 20, y, result.text.data(),
|
||||||
|
static_cast<int>(result.text.size())) != FALSE;
|
||||||
|
RECT drawRect{ 430, y, 930, y + 70 };
|
||||||
|
result.drawTextHeight = DrawTextW(hdc, result.text.data(),
|
||||||
|
static_cast<int>(result.text.size()), &drawRect,
|
||||||
|
DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX);
|
||||||
|
const RECT textOutRegion{ 15, y - 3, 410, y + 70 };
|
||||||
|
const RECT drawTextRegion{ 425, y - 3, 940, y + 70 };
|
||||||
|
result.textOutBounds = findChangedBounds(hdc, textOutRegion, background);
|
||||||
|
result.drawTextBounds = findChangedBounds(hdc, drawTextRegion, background);
|
||||||
|
results.push_back(std::move(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousFont) SelectObject(hdc, previousFont);
|
||||||
|
if (saved) RestoreDC(hdc, saved);
|
||||||
|
if (font) DeleteObject(font);
|
||||||
|
const GdiState after = captureGdiState(hdc);
|
||||||
|
restored = saved != 0 && sameGdiState(before, after);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
WideCaseResult runCase(
|
||||||
|
const Options& options,
|
||||||
|
HWND hwnd,
|
||||||
|
IMAGE& working,
|
||||||
|
bool targetMain,
|
||||||
|
bool insideBatch,
|
||||||
|
std::vector<EvidenceFile>& evidence)
|
||||||
|
{
|
||||||
|
WideCaseResult result;
|
||||||
|
result.surfaceId = targetMain ? "main" : "working";
|
||||||
|
result.batchState = insideBatch ? "inside" : "outside";
|
||||||
|
result.id = result.surfaceId + "_" + result.batchState;
|
||||||
|
result.flushApplicable = insideBatch;
|
||||||
|
result.endApplicable = insideBatch;
|
||||||
|
checkpoint(options, result.id + ":begin");
|
||||||
|
|
||||||
|
clearSurface(nullptr, MainBackground);
|
||||||
|
clearSurface(&working, WorkingBackground);
|
||||||
|
checkpoint(options, result.id + ":surfaces_cleared");
|
||||||
|
IMAGE* targetImage = targetMain ? nullptr : &working;
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
HDC mainDc = GetImageHDC(nullptr);
|
||||||
|
HDC workingDc = GetImageHDC(&working);
|
||||||
|
HDC targetDc = targetMain ? mainDc : workingDc;
|
||||||
|
HDC nonTargetDc = targetMain ? workingDc : mainDc;
|
||||||
|
const COLORREF targetBackground = targetMain ? MainBackground : WorkingBackground;
|
||||||
|
|
||||||
|
result.before = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||||
|
result.nonTargetBefore = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
|
||||||
|
checkpoint(options, result.id + ":before_captured");
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
if (insideBatch) BeginBatchDraw();
|
||||||
|
SetWorkingImage(targetImage);
|
||||||
|
drawEasyXBaseline();
|
||||||
|
result.textResults = drawWideText(targetDc, targetBackground, result.gdiStateRestored);
|
||||||
|
checkpoint(options, result.id + ":draw_complete");
|
||||||
|
result.afterDraw = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||||
|
result.nonTargetAfterDraw = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
|
||||||
|
checkpoint(options, result.id + ":after_draw_captured");
|
||||||
|
result.targetChanged = result.before.pixelDigest != result.afterDraw.pixelDigest;
|
||||||
|
result.nonTargetUnchanged =
|
||||||
|
result.nonTargetBefore.pixelDigest == result.nonTargetAfterDraw.pixelDigest;
|
||||||
|
|
||||||
|
result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
|
||||||
|
result.id + "_after_draw_target.png", result.id + " target after draw", evidence);
|
||||||
|
result.evidenceComplete &= addSurfaceEvidence(options, nonTargetDc,
|
||||||
|
result.id + "_after_draw_non_target.png", result.id + " non-target after draw", evidence);
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_after_draw_client.png", result.id + " client after draw", evidence);
|
||||||
|
checkpoint(options, result.id + ":after_draw_evidence");
|
||||||
|
|
||||||
|
if (insideBatch)
|
||||||
|
{
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
FlushBatchDraw();
|
||||||
|
result.afterFlush = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_after_flush_client.png", result.id + " client after flush", evidence);
|
||||||
|
EndBatchDraw();
|
||||||
|
checkpoint(options, result.id + ":batch_ended");
|
||||||
|
result.afterEnd = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_after_end_client.png", result.id + " client after end", evidence);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.afterFlush = result.afterDraw;
|
||||||
|
result.afterEnd = result.afterDraw;
|
||||||
|
}
|
||||||
|
std::vector<std::uint64_t> protectedRegionDigests;
|
||||||
|
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||||
|
{
|
||||||
|
const int y = 120 + static_cast<int>(index) * 105;
|
||||||
|
const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
|
||||||
|
protectedRegionDigests.push_back(
|
||||||
|
capturePixelRegionDigest(targetDc, protectedRegion));
|
||||||
|
}
|
||||||
|
|
||||||
|
SetWorkingImage(targetImage);
|
||||||
|
setfillcolor(EasyXMarker);
|
||||||
|
solidrectangle(760, 20, 900, 55);
|
||||||
|
result.afterEasyXFollowUp = captureSurfaceFingerprint(
|
||||||
|
targetDc, CanvasWidth, CanvasHeight);
|
||||||
|
result.wideContentPreservedAfterEasyXFollowUp = true;
|
||||||
|
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||||
|
{
|
||||||
|
const int y = 120 + static_cast<int>(index) * 105;
|
||||||
|
const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
|
||||||
|
result.wideContentPreservedAfterEasyXFollowUp =
|
||||||
|
result.wideContentPreservedAfterEasyXFollowUp &&
|
||||||
|
protectedRegionDigests[index] ==
|
||||||
|
capturePixelRegionDigest(targetDc, protectedRegion);
|
||||||
|
}
|
||||||
|
result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
|
||||||
|
result.id + "_after_easyx_follow_up_target.png",
|
||||||
|
result.id + " target after EasyX follow-up drawing", evidence);
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_after_easyx_follow_up_client.png",
|
||||||
|
result.id + " client after EasyX follow-up drawing", evidence);
|
||||||
|
checkpoint(options, result.id + ":easyx_follow_up_complete");
|
||||||
|
|
||||||
|
if (!targetMain)
|
||||||
|
{
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
BeginBatchDraw();
|
||||||
|
putimage(0, 0, &working);
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_composition_before_flush.png",
|
||||||
|
result.id + " explicit putimage before flush", evidence);
|
||||||
|
FlushBatchDraw();
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_composition_after_flush.png",
|
||||||
|
result.id + " explicit putimage after flush", evidence);
|
||||||
|
EndBatchDraw();
|
||||||
|
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||||
|
result.id + "_composition_after_end.png",
|
||||||
|
result.id + " explicit putimage after end", evidence);
|
||||||
|
result.explicitWorkingComposition = true;
|
||||||
|
}
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
checkpoint(options, result.id + ":end");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeTextResult(std::ostream& output, const TextApiResult& result)
|
||||||
|
{
|
||||||
|
output << "{\"testStringId\":" << jsonString(result.id)
|
||||||
|
<< ",\"codePoints\":" << codePointsJson(result.codePoints)
|
||||||
|
<< ",\"utf16Units\":" << utf16UnitsJson(result.text)
|
||||||
|
<< ",\"isValidUtf16\":";
|
||||||
|
writeBoolean(output, result.validUtf16);
|
||||||
|
output << ",\"getTextExtentPoint32W\":";
|
||||||
|
writeBoolean(output, result.extentSuccess);
|
||||||
|
output << ",\"logicalExtent\":";
|
||||||
|
writeSizeJson(output, result.extent);
|
||||||
|
output << ",\"textOutW\":";
|
||||||
|
writeBoolean(output, result.textOutSuccess);
|
||||||
|
output << ",\"drawTextWHeight\":" << result.drawTextHeight
|
||||||
|
<< ",\"textOutInkBounds\":";
|
||||||
|
writeBoundsJson(output, result.textOutBounds);
|
||||||
|
output << ",\"drawTextInkBounds\":";
|
||||||
|
writeBoundsJson(output, result.drawTextBounds);
|
||||||
|
output << '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeCaseResult(std::ostream& output, const WideCaseResult& result)
|
||||||
|
{
|
||||||
|
output << "{\"testCaseId\":" << jsonString(result.id)
|
||||||
|
<< ",\"surfaceId\":" << jsonString(result.surfaceId)
|
||||||
|
<< ",\"batchState\":" << jsonString(result.batchState)
|
||||||
|
<< ",\"targetChanged\":";
|
||||||
|
writeBoolean(output, result.targetChanged);
|
||||||
|
output << ",\"nonTargetUnchanged\":";
|
||||||
|
writeBoolean(output, result.nonTargetUnchanged);
|
||||||
|
output << ",\"gdiStateRestored\":";
|
||||||
|
writeBoolean(output, result.gdiStateRestored);
|
||||||
|
output << ",\"flushApplicable\":";
|
||||||
|
writeBoolean(output, result.flushApplicable);
|
||||||
|
output << ",\"endApplicable\":";
|
||||||
|
writeBoolean(output, result.endApplicable);
|
||||||
|
output << ",\"explicitWorkingComposition\":";
|
||||||
|
writeBoolean(output, result.explicitWorkingComposition);
|
||||||
|
output << ",\"wideContentPreservedAfterEasyXFollowUp\":";
|
||||||
|
writeBoolean(output, result.wideContentPreservedAfterEasyXFollowUp);
|
||||||
|
output << ",\"evidenceComplete\":";
|
||||||
|
writeBoolean(output, result.evidenceComplete);
|
||||||
|
output << ",\"observations\":{\"afterDraw\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.afterDraw);
|
||||||
|
output << ",\"afterFlush\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.afterFlush);
|
||||||
|
output << ",\"afterEnd\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.afterEnd);
|
||||||
|
output << ",\"afterEasyXFollowUp\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.afterEasyXFollowUp);
|
||||||
|
output << "},\"before\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.before);
|
||||||
|
output << ",\"nonTargetBefore\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.nonTargetBefore);
|
||||||
|
output << ",\"nonTargetAfterDraw\":";
|
||||||
|
writeSurfaceFingerprintJson(output, result.nonTargetAfterDraw);
|
||||||
|
output << ",\"textTests\":[";
|
||||||
|
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) output << ',';
|
||||||
|
writeTextResult(output, result.textResults[index]);
|
||||||
|
}
|
||||||
|
output << "]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||||
|
{
|
||||||
|
checkpoint(options, "probe:before_initgraph");
|
||||||
|
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||||
|
if (!hwnd)
|
||||||
|
{
|
||||||
|
std::cerr << "initgraph failed\n";
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
checkpoint(options, "probe:after_initgraph");
|
||||||
|
SetWindowTextA(hwnd, "StellarX 0C Wide Text Probe");
|
||||||
|
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||||
|
SetForegroundWindow(hwnd);
|
||||||
|
Sleep(80);
|
||||||
|
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||||
|
IMAGE working(CanvasWidth, CanvasHeight);
|
||||||
|
checkpoint(options, "probe:environment_and_working_ready");
|
||||||
|
std::vector<EvidenceFile> evidence;
|
||||||
|
std::vector<WideCaseResult> cases;
|
||||||
|
cases.push_back(runCase(options, hwnd, working, true, false, evidence));
|
||||||
|
cases.push_back(runCase(options, hwnd, working, true, true, evidence));
|
||||||
|
cases.push_back(runCase(options, hwnd, working, false, false, evidence));
|
||||||
|
cases.push_back(runCase(options, hwnd, working, false, true, evidence));
|
||||||
|
checkpoint(options, "probe:all_cases_complete");
|
||||||
|
|
||||||
|
bool completed = true;
|
||||||
|
for (const auto& item : cases)
|
||||||
|
{
|
||||||
|
completed = completed && item.targetChanged && item.nonTargetUnchanged &&
|
||||||
|
item.gdiStateRestored && item.wideContentPreservedAfterEasyXFollowUp &&
|
||||||
|
item.evidenceComplete;
|
||||||
|
for (const auto& text : item.textResults)
|
||||||
|
{
|
||||||
|
completed = completed && text.validUtf16 && text.extentSuccess &&
|
||||||
|
text.textOutSuccess && text.drawTextHeight > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream result;
|
||||||
|
result << "{\"caseCount\":" << cases.size() << ",\"cases\":[";
|
||||||
|
for (std::size_t index = 0; index < cases.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index) result << ',';
|
||||||
|
writeCaseResult(result, cases[index]);
|
||||||
|
}
|
||||||
|
result << "]}";
|
||||||
|
SetWorkingImage(nullptr);
|
||||||
|
closegraph();
|
||||||
|
|
||||||
|
std::string error;
|
||||||
|
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||||
|
evidence, result.str(), error))
|
||||||
|
{
|
||||||
|
std::cerr << error << '\n';
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return completed ? 0 : 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user