4b 光标覆盖层验收(V-D1/D3/D6/D7)+ 4a 建议 2 的公共库沉淀:
- proof_common.ps1:抽 landscape/rel_mouse 已验证的观测面 helper
(唤醒只从证明过的隐藏态、正向配对判隐藏、N25 唯一临时文件、按文案取按钮、
静置门控 + SetCursorPos 读回自校验、内存预检、R-7 新鲜守卫)。
老 proof 本轮**不迁移**(改 4 个已验证脚本而不全重跑=假绿风险,登记下轮卫生项)
- cursor_proof.ps1:真机判据
V-D1 截图青簇质心 vs CursorMarker 映射 ±6px(含近原点 (48,48),Some(0,0)≠None 由单测+golden 锁)
V-D3 静止桌面跳转后 900ms 内标记跟随(覆盖 500ms 保活重发带新鲜 cursor)
V-D7 指针进左侧副屏负 X → agent 折叠 None → 无青簇(本机双屏白送的真实判据)
reject 阶段 V-D6:旧 v4 APK 连 v5 agent → 屏幕出「协议版本不一致」+ detail client=4,server=5
实测全绿:4 点偏差 ≤0.6px、跟随 0.5px、副屏无簇、拒绝判据命中
- rel_mouse_phone_proof.ps1:适配 v5 + 修两处 harness 缺陷(都是本轮回归实测逼出的)
① HUD v4 断言→v5(PROTO_LABEL 配对);② V-B2 R7 期望值从写死 1920 派生
改为按实时桌面 / 动态算(桌面已漂到 2560x1440,旧写死值假报 575px,
违反 AGENTS.md 坐标期望按当前分辨率重算)
③ 模式守卫改为无条件 Set-Mode relative(旧 HUD.Contains(绝对) 在 HUD 读空时
静默跳过,上一轮 throw 残留的 relative_mode=false 污染了整个 step2——
绝对模式 DOWN 发绝对移动是正确产品行为,被 2c 误读成 teleport)
④ Set-Mode 唤醒 miss 并入 try 预算(原来单次重读即 throw,烧掉整脚本)+ 失败截图证据
实测:R7 误差 1px、2c maxJump=0、长按右键 0→1、全链路绿
350 lines
15 KiB
PowerShell
350 lines
15 KiB
PowerShell
# proof_common.ps1 - shared device-side proof helpers (4b, personal docs loop-4b S6).
|
|
#
|
|
# ASCII-only file (PS 5.1 reads BOM-less .ps1 as GBK; repo lessons).
|
|
# Dot-source it: . "$PSScriptRoot\proof_common.ps1"
|
|
#
|
|
# Provenance: these are the battle-tested helpers from landscape_proof.ps1 (4a:
|
|
# wake-only-from-proven-hidden, positive-pair hide check, N25-style unique temp
|
|
# files, uiautomator empty-dump retry) plus rel_mouse_phone_proof's focus-assert
|
|
# field clearing. Older proofs are NOT migrated to this file in loop 4b (changing
|
|
# four validated scripts without re-running all four is itself a false-green
|
|
# risk) - migration is registered as a harness hygiene item for the next loop.
|
|
|
|
# ---- adb wrappers (never let stderr trip ErrorActionPreference=Stop) ----
|
|
if (-not (Get-Variable -Name PccAdb -Scope Global -ErrorAction SilentlyContinue)) {
|
|
$global:PccAdb = "D:\working\android\sdk\platform-tools\adb.exe"
|
|
}
|
|
|
|
function Adb {
|
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
try {
|
|
$out = & $global:PccAdb @AdbArgs 2>$null
|
|
@{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
|
} finally { $ErrorActionPreference = $old }
|
|
}
|
|
function AdbShell { param([string[]]$Cmd) (Adb -AdbArgs (@("shell") + $Cmd)).out }
|
|
|
|
# ---- UI tree ----
|
|
# Unique temp per process (N25 lesson applied to dump pulls, cheap insurance).
|
|
$script:PccUiTmp = "$env:TEMP\pcc_ui_$PID.xml"
|
|
$script:PccDevDump = "/sdcard/pcc_$PID.xml"
|
|
|
|
function Get-Ui {
|
|
[void](AdbShell @("uiautomator", "dump", $script:PccDevDump))
|
|
$r = Adb -AdbArgs @("pull", $script:PccDevDump, $script:PccUiTmp)
|
|
if ($r.code -ne 0 -or -not (Test-Path $script:PccUiTmp)) { return "" }
|
|
return [System.IO.File]::ReadAllText($script:PccUiTmp, [System.Text.Encoding]::UTF8)
|
|
}
|
|
# uiautomator intermittently dumps empty (measured repeatedly in 4a). Retry until
|
|
# the tree is plausibly real. Callers still assert on their own node - this only
|
|
# removes the transient, never masks a genuine absence.
|
|
function Get-UiRetry([int]$tries = 4) {
|
|
$x = ""
|
|
for ($i = 0; $i -lt $tries; $i++) {
|
|
$x = Get-Ui
|
|
if ($x.Length -gt 500) { return $x }
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
Write-Host " [ui] retry exhausted, last dump len=$($x.Length)"
|
|
return $x
|
|
}
|
|
function VisibleTexts([string]$xml) {
|
|
[regex]::Matches($xml, 'text="([^"]+)"') | ForEach-Object {
|
|
[System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value)
|
|
}
|
|
}
|
|
|
|
# ---- buttons: match by LABEL, never by position (3b/N23 lesson) ----
|
|
function ButtonNode([string]$xml, [string]$want, [bool]$prefix = $false) {
|
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
|
$hit = if ($prefix) { $t.StartsWith($want) } else { $t -eq $want }
|
|
if ($hit) { return $n.Value }
|
|
}
|
|
return $null
|
|
}
|
|
function ButtonCenter([string]$xml, [string]$want, [bool]$prefix = $false) {
|
|
$v = ButtonNode $xml $want $prefix
|
|
if (-not $v) { return $null }
|
|
$b = [regex]::Match($v, 'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
|
if (-not $b.Success) { return $null }
|
|
@{ cx = [int](([int]$b.Groups[1].Value + [int]$b.Groups[3].Value) / 2)
|
|
cy = [int](([int]$b.Groups[2].Value + [int]$b.Groups[4].Value) / 2) }
|
|
}
|
|
function ButtonLabelByPrefix([string]$xml, [string]$prefix) {
|
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
|
if ($t.StartsWith($prefix)) { return $t }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ---- 4a observation-surface discipline: controls auto-hide in landscape ----
|
|
# Hidden state = POSITIVE PAIR (video View node present AND zero Button nodes).
|
|
# An empty dump would otherwise pass "no buttons" trivially (N24 false-fails).
|
|
function ControlsHidden([string]$ui) {
|
|
$anyBtn = ($ui -match 'class="android\.widget\.Button"')
|
|
$video = ($ui -match 'class="android\.view\.View"')
|
|
return ($video -and -not $anyBtn)
|
|
}
|
|
# Wake tap lives in the letterbox black bar (x=100): outside the video in both
|
|
# states, consumed by the app only when hidden - zero remote leakage (4a/D1).
|
|
function Wake-Band {
|
|
$d = LocalDisplay
|
|
[void](AdbShell @("input", "tap", "100", "$([int]($d.h / 2))"))
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
function Find-VisibleButton([string]$want, [bool]$prefix = $false) {
|
|
for ($try = 0; $try -lt 5; $try++) {
|
|
$ui = Get-UiRetry
|
|
$b = ButtonCenter $ui $want $prefix
|
|
if ($b) { return $b }
|
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
|
if ($ui.Length -gt 500) { return $null } # real tree, genuine absence
|
|
Start-Sleep -Milliseconds 400 # empty-dump transient
|
|
}
|
|
return $null
|
|
}
|
|
function Tap-VisibleButton([string]$want, [bool]$prefix = $false) {
|
|
$b = Find-VisibleButton $want $prefix
|
|
if (-not $b) { return $null }
|
|
[void](AdbShell @("input", "tap", "$($b.cx)", "$($b.cy)"))
|
|
return $b
|
|
}
|
|
function Read-ButtonLabel([string]$want, [bool]$prefix = $false) {
|
|
for ($try = 0; $try -lt 6; $try++) {
|
|
$ui = Get-UiRetry
|
|
$l = ButtonLabelByPrefix $ui $want
|
|
if ($l) { return $l }
|
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
|
if ($ui.Length -gt 500) { return $null }
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ---- HUD / session liveness ----
|
|
# "zhen=" frame counter (char U+5E27 as code point, ASCII-safe source).
|
|
function FrameCount {
|
|
$m = [regex]::Match((Get-Ui), ([char]0x5E27 + '=(\d+)'))
|
|
if ($m.Success) { return [long]$m.Groups[1].Value }
|
|
return -1
|
|
}
|
|
function WakeControls {
|
|
$d = LocalDisplay
|
|
[void](AdbShell @("input", "tap", "100", "$([int]($d.h / 2))"))
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
function FrameCountOrWake {
|
|
$f = FrameCount
|
|
if ($f -ge 0) { return $f }
|
|
WakeControls
|
|
return FrameCount
|
|
}
|
|
# Live HUD line (RTT= only exists when connected; hidden-controls caveat above).
|
|
function Wait-LiveHud([int]$seconds = 20) {
|
|
$deadline = (Get-Date).AddSeconds($seconds)
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 500
|
|
$ui = Get-Ui
|
|
if ($ui -notmatch "RTT=") {
|
|
if (ControlsHidden $ui) { WakeControls }
|
|
continue
|
|
}
|
|
$m = [regex]::Match($ui, 'text="([^"]*RTT=[^"]*)"')
|
|
if ($m.Success) { return [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ---- display / app lifecycle ----
|
|
# vivo ROM prints NO mRotation= line and keeps a permanent 1080x2400 virtual
|
|
# display (N30): orientation MUST be read from cur= inside the mDisplayId=0 block.
|
|
function LocalDisplay {
|
|
$d = (AdbShell @("dumpsys", "window", "displays"))
|
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
|
if ($idx -lt 0) { return @{ w = 0; h = 0 } }
|
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
|
if (-not $m.Success) { return @{ w = 0; h = 0 } }
|
|
@{ w = [int]$m.Groups[1].Value; h = [int]$m.Groups[2].Value }
|
|
}
|
|
function Wake {
|
|
[void](AdbShell @("input", "keyevent", "224"))
|
|
Start-Sleep -Milliseconds 250
|
|
[void](AdbShell @("wm", "dismiss-keyguard"))
|
|
Start-Sleep -Milliseconds 350
|
|
}
|
|
function Restart-AppForm {
|
|
Wake
|
|
[void](AdbShell @("am", "force-stop", "com.verify.remote"))
|
|
Start-Sleep -Milliseconds 600
|
|
[void](AdbShell @("am", "start", "-n", "com.verify.remote/.MainActivity"))
|
|
$deadline = (Get-Date).AddSeconds(8)
|
|
while ((Get-Date) -lt $deadline) {
|
|
$x = Get-Ui
|
|
if (([regex]::Matches($x, 'class="android\.widget\.EditText"')).Count -ge 3) { return $x }
|
|
Start-Sleep -Milliseconds 300
|
|
}
|
|
throw "form did not appear: [$(VisibleTexts (Get-Ui) -join ' || ')]"
|
|
}
|
|
|
|
# ---- connect form: focus-assert typing (rel_mouse_proof grade, R-8) ----
|
|
function CenterOf($m) {
|
|
@([int](([int]$m.Groups[1].Value + [int]$m.Groups[3].Value) / 2),
|
|
[int](([int]$m.Groups[2].Value + [int]$m.Groups[4].Value) / 2))
|
|
}
|
|
function FormFields([string]$xml) {
|
|
[regex]::Matches($xml, '<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
|
}
|
|
function TypeAscii([string]$s) {
|
|
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
|
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
|
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
|
elseif ($ch -eq '.') { 56 } else { throw "unsupported char $ch" }
|
|
[void](AdbShell @("input", "keyevent", "$k"))
|
|
Start-Sleep -Milliseconds 70
|
|
}
|
|
}
|
|
function FocusedIndex([string]$xml) {
|
|
$i = -1
|
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*>')) {
|
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
|
$i++
|
|
if ($n.Value -match 'focused="true"') { return $i }
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
function Clear-Field($c, [int]$wantIdx) {
|
|
for ($try = 0; $try -lt 3; $try++) {
|
|
[void](AdbShell @("input", "tap", "$($c[0])", "$($c[1])"))
|
|
$deadline = (Get-Date).AddSeconds(1.5)
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 250
|
|
if ((FocusedIndex (Get-Ui)) -eq $wantIdx) {
|
|
for ($i = 0; $i -lt 40; $i++) { [void](AdbShell @("input", "keyevent", "67")) }
|
|
Start-Sleep -Milliseconds 120
|
|
return
|
|
}
|
|
}
|
|
}
|
|
throw "field $wantIdx never took focus"
|
|
}
|
|
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_,
|
|
[string]$fConnect, [string]$fReconnect) {
|
|
$f = FormFields $xml
|
|
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
|
|
Clear-Field (CenterOf $f[0]) 0; TypeAscii $host_
|
|
Clear-Field (CenterOf $f[1]) 1; TypeAscii $port_
|
|
Clear-Field (CenterOf $f[2]) 2; TypeAscii $code_
|
|
$expect = @($host_, $port_, $code_)
|
|
$vals = @()
|
|
for ($retry = 0; $retry -le 2; $retry++) {
|
|
$x2 = Get-UiRetry
|
|
$vals = @()
|
|
foreach ($n in [regex]::Matches($x2, '<node[^>]*>')) {
|
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
|
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
|
}
|
|
}
|
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
|
$bad = -1
|
|
for ($i = 0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
|
|
if ($bad -ge 0) {
|
|
$f = FormFields $x2
|
|
Clear-Field (CenterOf $f[$bad]) $bad
|
|
TypeAscii $expect[$bad]
|
|
continue
|
|
}
|
|
$btn = ButtonCenter $x2 $fConnect
|
|
if (-not $btn) { $btn = ButtonCenter $x2 $fReconnect }
|
|
if (-not $btn) { $btn = Find-VisibleButton $fConnect
|
|
if (-not $btn) { $btn = Find-VisibleButton $fReconnect } }
|
|
if (-not $btn) { throw "connect button not found" }
|
|
[void](AdbShell @("input", "tap", "$($btn.cx)", "$($btn.cy)"))
|
|
return
|
|
}
|
|
throw "form input never converged: [$($vals -join ' | ')]"
|
|
}
|
|
|
|
# ---- video geometry (uiautomator has no SurfaceView - match 16:9 View, N21) ----
|
|
function VideoBounds([string]$xml) {
|
|
$best = $null; $bestErr = 1e9
|
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
|
|
$x1 = [int]$n.Groups[1].Value; $y1 = [int]$n.Groups[2].Value
|
|
$x2 = [int]$n.Groups[3].Value; $y2 = [int]$n.Groups[4].Value
|
|
$w = $x2 - $x1; $h = $y2 - $y1
|
|
if ($w -lt 300 -or $h -lt 100) { continue }
|
|
$err = [Math]::Abs($w / [double]$h - 16.0 / 9.0)
|
|
if ($err -lt $bestErr) { $bestErr = $err; $best = @{ l = $x1; t = $y1; w = $w; h = $h } }
|
|
}
|
|
if ($best) { $best.err = $bestErr }
|
|
return $best
|
|
}
|
|
|
|
# ---- PC-side cursor: DPI-aware set with hijack gates ----
|
|
if (-not ('PccCur' -as [type])) {
|
|
Add-Type -TypeDefinition @"
|
|
using System; using System.Runtime.InteropServices;
|
|
public class PccCur {
|
|
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
|
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr c);
|
|
public static void Dpi(){ SetProcessDpiAwarenessContext(new IntPtr(-4)); }
|
|
public static int[] Get(){ POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
|
|
public static void Set(int x,int y){ SetCursorPos(x,y); }
|
|
}
|
|
"@
|
|
}
|
|
[PccCur]::Dpi()
|
|
|
|
# SetCursorQuiet: the machine may be shared (2026-11-02 probe: a human used the
|
|
# real mouse mid-probe and faked every reading). Gate: two reads 350ms apart must
|
|
# agree (nobody is moving), then Set + read-back must equal the request; retries
|
|
# on hijack; loud failure instead of a silently wrong sample (R-8).
|
|
function Set-CursorQuiet([int]$x, [int]$y, [int]$tries = 8) {
|
|
for ($i = 0; $i -lt $tries; $i++) {
|
|
$a = [PccCur]::Get()
|
|
Start-Sleep -Milliseconds 350
|
|
$b = [PccCur]::Get()
|
|
if (-not ($a[0] -eq $b[0] -and $a[1] -eq $b[1])) {
|
|
Write-Host " [cursor] busy ($($a -join ',')->$($b -join ',')), retry $($i+1)"
|
|
Start-Sleep -Seconds 1
|
|
continue
|
|
}
|
|
[PccCur]::Set($x, $y)
|
|
Start-Sleep -Milliseconds 120
|
|
$g = [PccCur]::Get()
|
|
if ($g[0] -eq $x -and $g[1] -eq $y) { return }
|
|
Write-Host " [cursor] readback ($($g -join ',')) != ($x,$y), retry $($i+1)"
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
throw "cursor never settled at ($x,$y) - someone is using the real mouse; retry the proof when the PC is idle"
|
|
}
|
|
|
|
# ---- memory precheck (N20/N29: OOM fakes build/test failures) ----
|
|
function Memory-Precheck([double]$minGB = 0.5) {
|
|
$os = Get-CimInstance Win32_OperatingSystem
|
|
$freeGB = $os.FreeVirtualMemory / 1MB
|
|
Write-Host (" commit free: {0:N1} GB" -f $freeGB)
|
|
if ($freeGB -lt $minGB) { throw "commit memory critically low - free RAM first (gradle --stop)" }
|
|
}
|
|
|
|
# ---- R-7 stale-agent guard: binary newer than process => restart ----
|
|
function Ensure-FreshAgent([string]$AgentExe, [int]$Port, [string]$Code) {
|
|
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe (cargo build --release -p agent)" }
|
|
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
|
if ($ag.Count -gt 0 -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
|
Write-Host " agent predates build; restarting"
|
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
|
Start-Sleep -Milliseconds 800; $ag = @()
|
|
}
|
|
if ($ag.Count -eq 0) {
|
|
Start-Process $AgentExe -ArgumentList "--port", "$Port", "--code", $Code -WindowStyle Hidden | Out-Null
|
|
Start-Sleep -Seconds 2
|
|
Write-Host " agent started (fresh)"
|
|
} else { Write-Host " agent running and current" }
|
|
}
|