remote/tools/rel_mouse_phone_proof.ps1
郑博文 3b219a442d test(tools): cursor_proof + proof_common 抽取 + rel_mouse 适配 v5/双屏与污染修复
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、全链路绿
2026-11-02 12:39:23 +08:00

699 lines
36 KiB
PowerShell

# 3b phone-side proof: relative mouse (protocol v4) + long-press right click + R7 regression.
#
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
# Expected Chinese UI strings are matched as CODE-POINT fragments (R-8: assert the
# actual value, and print what the parser really read so an empty result cannot pass).
#
# Judgements:
# V-B1a relative mode moves the PC pointer by the expected amount (direction+sign)
# V-B2 absolute mode still maps finger -> desktop 1:1 (R7 re-run, <=8px error)
# V-B3 long press (>=500ms, still) opens a context menu; a drag does NOT
# V-B7 R7 + Chinese IME + full chain regressions stay green
#
# Prereqs: vivo device online via adb, 3b APK installed, v4 agent running.
# powershell -ExecutionPolicy Bypass -File tools\rel_mouse_phone_proof.ps1 -Host_ 192.168.1.110
param(
[string]$Host_ = "192.168.1.110",
[int]$Port = 47800,
[string]$Code = "t",
[string]$AgentExe = ""
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$ev = Join-Path (Split-Path $repo -Parent) "evidence\3b"
New-Item -ItemType Directory -Force -Path $ev | Out-Null
# ---- code-point fragments (grep-verified present in the Kotlin sources) ----
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # "connect"
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # "reconnect"
$F_RELATIVE = -join ([char]0x76F8,[char]0x5BF9) # "relative"
$F_ABSOLUTE = -join ([char]0x7EDD,[char]0x5BF9) # "absolute"
$F_MODE_TAG = -join ([char]0x6A21,[char]0x5F0F) # "mode"
$F_LONGPRESS = -join ([char]0x957F,[char]0x6309,[char]0x53F3,[char]0x952E) # "long-press right click"
$F_RCLICK_TAG = -join ([char]0x53F3,[char]0x952E,[char]0x3D) # "right-click="
$F_CLICK = -join ([char]0x5355,[char]0x51FB) # "single click"
# Read the persistent right-click counter from the HUD ("right-click=N(source)").
# Returns -1 when absent. A counter is used instead of the one-line debug text
# because ACTION_UP immediately overwrites the debug line after a long press
# (measured 2026-10-31: screen showed the long-press-END label, the firing evidence was gone).
function RightClickCount {
for ($t=0; $t -lt 3; $t++) {
$x = Get-UiText
$m = [regex]::Match($x, 'text="([^"]*RTT=[^"]*)"')
if ($m.Success) {
$hud = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value)
$c = [regex]::Match($hud, "$F_RCLICK_TAG(\d+)")
if ($c.Success) { return [int]$c.Groups[1].Value }
return 0
}
# 4a: in landscape the HUD hides WITH the controls (GONE, not empty text)
Wake-Controls $x
Start-Sleep -Milliseconds 400
}
return -1
}
function Adb {
param([Parameter(Mandatory)][string[]]$AdbArgs)
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
finally { $ErrorActionPreference = $old }
}
function AdbShell {
param([string[]]$Cmd)
(Adb -AdbArgs (@("shell") + $Cmd)).out
}
function Get-UiText {
$d = AdbShell @("uiautomator","dump","/sdcard/rm.xml")
$r = Adb @("pull","/sdcard/rm.xml","$env:TEMP\rm.xml")
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\rm.xml")) {
Write-Host " [ui] dump=[$($d -replace "`n",' ')] pull_rc=$($r.code) -> empty"
return ""
}
$txt = [System.IO.File]::ReadAllText("$env:TEMP\rm.xml", [System.Text.Encoding]::UTF8)
if ($txt.Length -lt 200) {
Write-Host " [ui] short xml ($($txt.Length) chars): $($txt.Substring(0,[Math]::Min(120,$txt.Length)))"
}
return $txt
}
function VisibleTexts([string]$xml) {
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
}
function FormFields([string]$xml) {
[regex]::Matches($xml,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
}
function Center($m) {
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
$c=[int]$m.Groups[3].Value; $d=[int]$m.Groups[4].Value
@([int](($a+$c)/2), [int](($b+$d)/2))
}
function ButtonCenter([string]$xml, [string]$want) {
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 -eq $want) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
}
}
}
return $null
}
function ButtonStartingWith([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)) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
}
}
}
return $null
}
# 4a self-adaptation: in landscape the control bar auto-hides after 3s idle
# (and the HUD TextView hides with it). The wake tap goes to the LEFT BLACK BAR
# (x=100, outside the letterboxed video) - the app consumes black-bar touches
# only in the hidden state, so this never reaches the remote. Bottom-center is
# NOT usable: with the controls hidden the video is full-height, so the bottom
# edge lies INSIDE the picture and would fire a real remote click.
function PhoneDisplay {
$d = (AdbShell @("dumpsys","window","displays"))
$idx = $d.IndexOf("Display: mDisplayId=0")
if ($idx -lt 0) { return @{ w=1080; h=2400 } }
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
if (-not $m.Success) { return @{ w=1080; h=2400 } }
return @{ w=[int]$m.Groups[1].Value; h=[int]$m.Groups[2].Value }
}
function Wake-Controls([string]$xml) {
if ($xml -match 'class="android\.widget\.Button"') { return } # visible already
$pd = PhoneDisplay
[void](AdbShell @("input","tap","100","$([int]($pd.h/2))"))
Start-Sleep -Milliseconds 500
}
# HUD text with wake-retry (4a: in landscape the HUD TextView hides WITH the
# control bar - a plain read returns "" once the 3s timer expired, and each
# dump itself costs ~2s so reads routinely straddle the expiry).
function Get-HudText {
for ($t=0; $t -lt 3; $t++) {
$x = Get-UiText
$m = [regex]::Match($x, 'text="([^"]*RTT=[^"]*)"')
if ($m.Success) { return [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) }
Wake-Controls $x
Start-Sleep -Milliseconds 400
}
return ""
}
# Toggle the pointer mode and PROVE it flipped by re-reading the button label.
# The bottom bar sits at y=2256..2324, right against the system navigation bar;
# a single tap there is not reliable (measured 2026-10-31: the tap landed but the
# mode did not change). So: tap, wait, re-read, retry; and judge on the BUTTON
# label as well as the HUD (two independent observations of the same state).
function Get-UiTextRetry([int]$tries = 4) {
# landscape_proof Get-UiRetry pattern (N24-era lesson): uiautomator
# intermittently returns short/empty trees; a single miss inside a
# wake-then-read sequence used to burn the whole mode-switch budget
# (measured 2026-11-02 step 3: full run died at "mode button not found"
# although the very next steps would have re-dumped fine).
$x = ""
for ($i = 0; $i -lt $tries; $i++) {
$x = Get-UiText
if ($x.Length -gt 500) { return $x }
Start-Sleep -Milliseconds 400
}
return $x
}
function Set-Mode([string]$want) {
# $want is "relative" or "absolute"
$wantTag = if ($want -eq "relative") { $F_RELATIVE } else { $F_ABSOLUTE }
for ($try=0; $try -lt 6; $try++) {
$xml = Get-UiTextRetry
$cur = $null
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.Contains($F_MODE_TAG)) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
$cur = @{ label=$t; cx=[int](($x1+$x2)/2); cy=[int](($y1+$y2)/2) }
}
}
}
if (-not $cur) {
# 4a: controls may be auto-hidden (landscape). Wake and fold the miss
# into the SAME try budget instead of a single re-read + instant throw.
Wake-Controls $xml
continue
}
Write-Host " mode button label now: [$($cur.label)] (try $try)"
if ($cur.label.Contains($wantTag)) { return $true }
[void](AdbShell @("input","tap","$($cur.cx)","$($cur.cy)"))
Start-Sleep -Milliseconds 700
}
# Fail LOUD with evidence: a bare text dump cannot show what the screen
# actually looked like (this is how the 2026-11-02 step-3 death became a
# guessing game; one PNG makes the next occurrence self-evidencing).
$evShot = Join-Path $ev "mode-switch-fail-$PID.png"
try { & powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out $evShot | Out-Null } catch { }
$final = ((VisibleTexts (Get-UiTextRetry)) -join " || ")
throw "mode did not switch to $want after 6 tries; screen: [$final] shot: $evShot"
}
# The video SurfaceView bounds - touch coordinates must land inside it.
#
# uiautomator does NOT expose SurfaceView as "android.view.SurfaceView"; a bare
# SurfaceView with no accessibility node collapses to a plain "android.view.View"
# (measured 2026-10-31 on the live device: the only candidate is 1080x607, which
# is exactly the 16:9 letterbox of the 1920x1080 desktop inside a 1080-wide stage).
# So identify it structurally: the widest View whose aspect ratio matches 16:9.
# Assert the ratio instead of assuming it - a silent wrong node would make every
# downstream coordinate assertion meaningless.
function SurfaceBounds([string]$xml) {
$best = $null; $bestErr = [double]::MaxValue
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 }
$ratio = $w / [double]$h
$err = [Math]::Abs($ratio - (16.0/9.0))
if ($err -lt $bestErr) { $bestErr = $err; $best = @($x1,$y1,$x2,$y2) }
}
if (-not $best) { return $null }
# sanity: the chosen node must really be ~16:9, else we grabbed something else
$w = $best[2]-$best[0]; $h = $best[3]-$best[1]
if ($bestErr -gt 0.25) {
throw "no 16:9 view found (best candidate ${w}x${h}, ratio $([Math]::Round($w/[double]$h,3)))"
}
Write-Host " video view ${w}x${h} (ratio $([Math]::Round($w/[double]$h,3)), 16:9 error $([Math]::Round($bestErr,4)))"
return $best
}
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 Focus-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-UiText)) -eq $wantIdx) { return }
}
}
throw "tap did not focus field $wantIdx after 3 tries"
}
function Clear-Field($c, [int]$idx) {
Focus-Field $c $idx
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
Start-Sleep -Milliseconds 150
}
function Restart-AppForm {
[void](AdbShell @("input","keyevent","224"))
Start-Sleep -Milliseconds 300
[void](AdbShell @("wm","dismiss-keyguard"))
Start-Sleep -Milliseconds 500
[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-UiText
if ((FormFields $x).Count -ge 3) { return $x }
Start-Sleep -Milliseconds 300
}
$diag = (VisibleTexts (Get-UiText)) -join " || "
throw "connect form did not appear. screen: [$diag]"
}
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_) {
$f = FormFields $xml
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
Clear-Field (Center $f[0]) 0; TypeAscii $host_
Clear-Field (Center $f[1]) 1; TypeAscii $port_
Clear-Field (Center $f[2]) 2; TypeAscii $code_
$expect = @($host_, $port_, $code_)
for ($retry=0; $retry -le 2; $retry++) {
$x2 = Get-UiText
$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)
}
}
Write-Host " form now: $($vals -join ' | ') (try $retry)"
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 (Center $f[$bad]) $bad
TypeAscii $expect[$bad]
continue
}
break
}
if ($vals[0] -ne $host_) { throw "form input FAIL: host is [$($vals[0])], expected [$host_]" }
if ($vals[1] -ne $port_) { throw "form input FAIL: port is [$($vals[1])], expected [$port_]" }
if ($vals[2] -ne $code_) { throw "form input FAIL: code is [$($vals[2])], expected [$code_]" }
$btn = $null
foreach ($want in @($F_CONNECT, $F_RECONNECT)) { $btn = ButtonCenter $x2 $want; if ($btn) { break } }
if (-not $btn) { throw "no primary connect/reconnect button found" }
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
}
function Wait-Live([int]$seconds, [ref]$hud) {
$deadline = (Get-Date).AddSeconds($seconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 500
$m = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
if ($m.Success) { $hud.Value = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value); return $true }
}
return $false
}
# ---- PC-side cursor readback (the only honest judge of "pointer moved N px") ----
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class PC {
[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 ctx);
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);
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); }
public static int W() { return GetSystemMetrics(0); }
public static int H() { return GetSystemMetrics(1); }
}
"@
[void][PC]::Dpi()
$SW = [PC]::W(); $SH = [PC]::H()
Write-Host " PC screen ${SW}x${SH} (physical, DPI-aware)"
Write-Host "=== 0. stale-artifact guard (R-7) + IME disable + keep-awake ==="
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
Write-Host " running agent predates current build; restarting"
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = @()
}
if ($ag.Count -eq 0) {
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe" }
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
Start-Sleep -Seconds 1; Write-Host " agent started (v5)"
} else { Write-Host " agent running and current" }
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
Write-Host " original IME: [$origIme] (recorded, restored in finally)"
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
[void](AdbShell @("svc","power","stayon","true"))
$origCursor = [PC]::Get()
Write-Host " PC cursor origin ($($origCursor[0]),$($origCursor[1])) (restored in finally)"
try {
# =================================================================
Write-Host "=== 1. connect (protocol v4) and confirm HUD shows v4 ==="
$x = Restart-AppForm
[void](FillAndConnect $x $Host_ "$Port" $Code)
$hud = ""
if (-not (Wait-Live 14 ([ref]$hud))) { throw "never reached a live session" }
Write-Host " HUD: $hud"
# HUD version pin (3b/V-B4). PROTO bump MUST sync this string -
# see MainActivity PROTO_LABEL comment (4b R-5 pair).
if (-not $hud.Contains("v5")) { throw "V-B4 FAIL: HUD does not report protocol v5; got [$hud]" }
Write-Host " PASS: live session on protocol v5"
$xml = Get-UiText
$sb = SurfaceBounds $xml
if (-not $sb) { throw "cannot locate SurfaceView bounds" }
$vx1=$sb[0]; $vy1=$sb[1]; $vx2=$sb[2]; $vy2=$sb[3]
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
Write-Host " surface bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b1-live.png" | Out-Null
# 4a pollution guard: the mode PERSISTS in prefs across runs, and a run that
# threw between Set-Mode "absolute" and its restore left absolute installed
# (measured 2026-11-01: step 2 ran in absolute mode, the DOWN absolute move
# "teleported" 1441px - correct product behavior, wrong precondition).
# Force relative, then settle back into the hidden steady state: while the
# controls are hidden, gestures never re-show them, so the geometry stays
# stable for steps 2/2b/2c.
$hudNow = Get-HudText
Write-Host " HUD at start: $hudNow"
# Converge to relative UNCONDITIONALLY via Set-Mode, which reads the BUTTON
# label and returns early if already relative (idempotent, authoritative).
# The old guard `if ($hudNow.Contains(absolute))` silently skipped when the
# HUD read came back empty or straddled the auto-hide (N24 observation-surface
# trap) - a mode left polluted by a previous run that threw after Set-Mode
# absolute then poisoned step 2c: absolute mode's DOWN DOES send an absolute
# move (correct product behavior), which the leak-point check read as a
# teleport (measured again 2026-11-02: prefs relative_mode=false at start,
# V-B1a "passed" on direction alone because absolute swipes also have
# direction - the whole step 2 block was running in the wrong mode).
[void](Set-Mode "relative")
Start-Sleep -Milliseconds 3800 # let the 3s auto-hide re-engage
$xh = Get-UiText
if ($xh -match 'class="android\.widget\.Button"') { throw "precondition FAIL: controls did not re-hide after 3.8s" }
$hsb = SurfaceBounds $xh
# NB: PowerShell variable names are CASE-INSENSITIVE - a local `$sh` here
# silently clobbers `$SH` (screen height) and every later [PC]::Set($SW/2,$SH/2)
# dies with "Object[] has no op_Division" (measured 2026-11-01). Prefix locals
# distinctly ($hsb = hidden-state bounds).
if (-not $hsb) { throw "precondition FAIL: no video bounds in hidden state" }
$vx1=$hsb[0]; $vy1=$hsb[1]; $vx2=$hsb[2]; $vy2=$hsb[3]
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
Write-Host " hidden steady-state bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
# =================================================================
Write-Host "=== 2. V-B1a: relative mode moves PC pointer (swipe right) ==="
# Park the PC cursor at a known point so the delta is unambiguous.
# Let the link drain first (same discipline as 2c): residual events from an
# earlier gesture would otherwise be attributed to this one.
Start-Sleep -Milliseconds 1000
[PC]::Set([int]($SW/2), [int]($SH/2))
Start-Sleep -Milliseconds 500
$before = [PC]::Get()
# A horizontal swipe of ~40% of the view width. With sensitivity 1.0 and
# view width mapping to desktop width, expected delta = 0.4 * SW.
$swipeFromX = [int]($vx1 + ($vx2-$vx1)*0.30)
$swipeToX = [int]($vx1 + ($vx2-$vx1)*0.70)
[void](AdbShell @("input","swipe","$swipeFromX","$vcy","$swipeToX","$vcy","400"))
Start-Sleep -Milliseconds 500
$after = [PC]::Get()
$moved = $after[0] - $before[0]
Write-Host " swipe dx_device=$($swipeToX-$swipeFromX)px -> PC pointer dx=$moved px (before x=$($before[0]), after x=$($after[0]))"
if ($moved -le 0) { throw "V-B1a FAIL: rightward swipe did not move the pointer right (dx=$moved)" }
# Direction check is the hard judgement; exact magnitude depends on how many
# ACTION_MOVE samples the ROM delivers for an `input swipe` (not equal to the
# finger travel), so magnitude is reported, not asserted.
Write-Host " PASS: relative mode moves the pointer in the finger's direction"
Write-Host "=== 2b. V-B1a negative: swipe left moves pointer left ==="
Start-Sleep -Milliseconds 1000
[PC]::Set([int]($SW/2), [int]($SH/2))
Start-Sleep -Milliseconds 500
$before2 = [PC]::Get()
[void](AdbShell @("input","swipe","$swipeToX","$vcy","$swipeFromX","$vcy","400"))
Start-Sleep -Milliseconds 500
$after2 = [PC]::Get()
$moved2 = $after2[0] - $before2[0]
Write-Host " PC pointer dx=$moved2 px (before x=$($before2[0]), after x=$($after2[0]))"
if ($moved2 -ge 0) { throw "V-B1a FAIL: leftward swipe did not move the pointer left (dx=$moved2)" }
Write-Host " PASS: negative direction correct (sign preserved)"
Write-Host "=== 2c. relative mode does NOT teleport the pointer on touch-down ==="
# The defining property of relative mode: ACTION_DOWN must not send an
# absolute move. Tap at the far LEFT of the view; the pointer must not jump
# to the left edge of the desktop.
#
# DISCRIMINATION (2026-11-01, landscape): a real absolute-leak has an exact
# signature - the pointer lands at the tap's desktop mapping
# (0.05*1920=96, 540) and STAYS. Residual swipe events instead keep the
# cursor DRIFTING for seconds (observed: stable-looking 4-read windows
# followed by -890/-1418px surges - bursts with >1.2s gaps between them).
# So: settle (6 equal reads), tap, then sample 4x over 2s.
# all samples == before -> PASS
# samples drift -> harness residue: retry (max 3)
# settled AT leak point -> product FAIL (no retry will change it)
# Absolute-leak landing point for this tap: view fraction 0.05 maps to
# desktop x = 0.05*(SW-1); y = view center row -> desktop mid row.
$leakX = [int][Math]::Round(0.05 * ($SW-1))
$leakY = [int][Math]::Round((($vcy-$vy1) / [double]($vy2-$vy1)) * ($SH-1))
$verdict = "retry"
for ($att=1; $att -le 3 -and $verdict -eq "retry"; $att++) {
Start-Sleep -Milliseconds 2000
[PC]::Set([int]($SW*0.8), [int]($SH/2))
Start-Sleep -Milliseconds 1500
$stable = $false
for ($i=0; $i -lt 8; $i++) {
$runs = @()
for ($k=0; $k -lt 6; $k++) { $runs += ,([PC]::Get()); Start-Sleep -Milliseconds 250 }
$eq = $true
foreach ($r in $runs) { if ($r[0] -ne $runs[0][0] -or $r[1] -ne $runs[0][1]) { $eq = $false } }
if ($eq) { $stable = $true; break }
Write-Host (" att{0}: cursor still settling (last {1},{2}); re-parking" -f $att,$runs[-1][0],$runs[-1][1])
[PC]::Set([int]($SW*0.8), [int]($SH/2))
Start-Sleep -Milliseconds 800
}
if (-not $stable) { throw "cannot obtain a stable cursor baseline (residual events never drained)" }
$beforeTap = [PC]::Get()
$tapX = [int]($vx1 + ($vx2-$vx1)*0.05)
[void](AdbShell @("input","tap","$tapX","$vcy"))
$samples = @()
for ($k=0; $k -lt 4; $k++) { Start-Sleep -Milliseconds 500; $samples += ,([PC]::Get()) }
$dbgTap = ((VisibleTexts (Get-UiText)) -join " || ")
$maxJump = 0
foreach ($s in $samples) {
$j = [Math]::Max([Math]::Abs($s[0]-$beforeTap[0]), [Math]::Abs($s[1]-$beforeTap[1]))
if ($j -gt $maxJump) { $maxJump = $j }
}
$last = $samples[-1]
$drift = $false
for ($k=1; $k -lt 4; $k++) {
if ($samples[$k][0] -ne $samples[0][0] -or $samples[$k][1] -ne $samples[0][1]) { $drift = $true }
}
$atLeak = ([Math]::Abs($last[0]-$leakX) -le 8) -and ([Math]::Abs($last[1]-$leakY) -le 8)
# NB: "$_[0]" inside a double-quoted string expands only `$_` and prints
# "[0]" literally (PS 5.1) - use explicit sub-expressions.
$sampleStr = ($samples | ForEach-Object { "(" + $_[0] + "," + $_[1] + ")" }) -join " "
Write-Host (" att{0}: tap ({1},{2}) -> samples {3} maxJump={4} drift={5} atLeakPoint={6} (leak=({7},{8}))" -f `
$att,$beforeTap[0],$beforeTap[1],$sampleStr,$maxJump,$drift,$atLeak,$leakX,$leakY)
if ($dbgTap.Contains($F_LONGPRESS)) { throw "tap was interpreted as a long press; harness timing is wrong" }
if ($atLeak) { throw ("V-B1a FAIL: pointer settled at the absolute-leak point ({0},{1}) - DOWN sent an absolute move" -f $last[0],$last[1]) }
if ($maxJump -le 8 -and -not $drift) { $verdict = "pass"; break }
if (-not $drift) {
# settled far away but NOT at the leak point: some other single event
# moved it - that IS a product-relevant teleport, retry once more
# before condemning (a queued burst can also settle far away).
Write-Host " att${att}: settled far (jump=$maxJump) but not at leak point - treating as residue, retrying"
}
}
if ($verdict -ne "pass") { throw "V-B1a FAIL: pointer moved on touch-down in relative mode across 3 attempts (residual or teleport - see samples above)" }
Write-Host " PASS: relative mode leaves the pointer where it was (no teleport)"
# =================================================================
Write-Host "=== 3. V-B2: switch to ABSOLUTE mode, re-run R7 touch mapping ==="
[void](Set-Mode "absolute")
# 4a geometry race (measured 2026-11-01): Set-Mode WOK the controls, so the
# video shrank to 1216x684 - but each dump costs ~2s and the auto-hide timer
# is 3s, so by tap time the controls had hidden again and the video was back
# to full height. Tapping with the stale visible-state geometry produced
# "errors" of 281-357px that were pure harness error (y landed at the old
# center row 341, not 540). Wait for the hidden steady state and sample
# THOSE bounds - in hidden state a video tap only re-arms the timer, it never
# re-shows the controls, so the geometry is stable for the whole R7 loop.
Start-Sleep -Milliseconds 3800
$hud2 = Get-HudText
$sb = SurfaceBounds (Get-UiText)
if (-not $sb) { throw "V-B2 FAIL: no video bounds in hidden steady state" }
$vx1=$sb[0];$vy1=$sb[1];$vx2=$sb[2];$vy2=$sb[3]
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
Write-Host " hidden steady-state bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
Write-Host " HUD: $hud2"
if (-not $hud2.Contains($F_ABSOLUTE)) { throw "V-B2 FAIL: HUD still does not say absolute; got [$hud2]" }
Write-Host " PASS: mode switched to absolute (button label and HUD agree)"
# R7 expectations derived from the LIVE desktop size (physical pixels; 125%
# scaling does NOT apply - the agent injects into the physical desktop, and
# normX=frac*65535 lands at frac*(SW-1)).
# Fixed 1920-based literals broke the moment the capture display became
# 2560x1440 (2026-11-02: reported 575px error = 0.9*(2560-1920), a harness
# staleness, not a mapping regression). AGENTS.md: coordinate expectations
# MUST be recomputed for the current resolution. Same script already uses
# dynamic $SW/$SH for V-B1a and the leak point - this block was the outlier.
$r7 = @(
@{ frac=0.10 },
@{ frac=0.90 },
@{ frac=0.50 }
)
$worst = 0
foreach ($c in $r7) {
$tx = [int]($vx1 + ($vx2-$vx1)*$c.frac)
$ty = [int]($vy1 + ($vy2-$vy1)*$c.frac)
$c.ex = [int][Math]::Round($c.frac * ($SW-1))
$c.ey = [int][Math]::Round($c.frac * ($SH-1))
[void](AdbShell @("input","tap","$tx","$ty"))
Start-Sleep -Milliseconds 400
$p = [PC]::Get()
$errX = [Math]::Abs($p[0] - $c.ex)
$errY = [Math]::Abs($p[1] - $c.ey)
$e = [Math]::Max($errX,$errY)
if ($e -gt $worst) { $worst = $e }
Write-Host " frac=$($c.frac) expect=($($c.ex),$($c.ey)) actual=($($p[0]),$($p[1])) err=($errX,$errY)"
}
Write-Host " worst R7 error = $worst px"
if ($worst -gt 8) { throw "V-B2 FAIL: R7 absolute mapping error $worst px > 8px" }
Write-Host " PASS: absolute mode still maps 1:1 (R7 regression green)"
# =================================================================
Write-Host "=== 4. V-B3: long press opens a context menu; drag does NOT ==="
# Switch back to relative for the long-press test (that is the default mode
# users get, and the gesture must work there).
[void](Set-Mode "relative")
# Use a desktop of our own: the proof's WinForms target window (R-11: never
# inject into a window we do not own - it would modify user data).
# NB: the 3b version launched this WITHOUT its Mandatory params, so the
# child died instantly and the right-click landed at raw screen center
# (possible user window - near-miss on R-11). Launch properly now and park
# the pointer INSIDE our own window: topmost form at (80,80) 420x140.
$target = Join-Path $repo "tools\text_target.ps1"
if (-not (Test-Path $target)) { throw "missing $target" }
# UNIQUE per-run dir (same orphan-stop-flag trap as phone_chain_proof,
# fixed 2026-11-01): startup Remove-Item must not delete the retirement
# channel of targets left by a previous failed run.
$ttmp = Join-Path $env:TEMP "vbx4a-$PID"
New-Item -ItemType Directory -Force -Path $ttmp | Out-Null
$pidFile = "$ttmp\target.pid"; $stopFile = "$ttmp\stop.flag"; $resultFile = "$ttmp\received.txt"
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
$tp = Start-Process powershell -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File",$target, `
"-PidFile",$pidFile,"-ResultFile",$resultFile,"-StopFile",$stopFile -PassThru -WindowStyle Hidden
$deadline = (Get-Date).AddSeconds(8)
while (-not (Test-Path $pidFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 }
if (-not (Test-Path $pidFile)) { throw "target window did not start (no pid file)" }
Write-Host " target window started (pid $(Get-Content $pidFile))"
# Long-press and drag run in the HIDDEN steady state: geometry is stable
# there (video taps only re-arm the hide timer, they never re-show the
# controls), and the counter read wakes the HUD afterwards.
function Enter-HiddenSteady {
Start-Sleep -Milliseconds 3800
$x = Get-UiText
if ($x -match 'class="android\.widget\.Button"') { throw "precondition FAIL: controls did not hide" }
$s = SurfaceBounds $x
if (-not $s) { throw "precondition FAIL: no hidden-state video bounds" }
return $s
}
$rcBefore = RightClickCount # may wake the controls; re-enter hidden after
$hsb4 = Enter-HiddenSteady
$hx1=$hsb4[0];$hy1=$hsb4[1];$hx2=$hsb4[2];$hy2=$hsb4[3]
$hcx=[int](($hx1+$hx2)/2); $hcy=[int](($hy1+$hy2)/2)
Write-Host " right-click counter before: $rcBefore ; hidden center=($hcx,$hcy)"
# The long press right-clicks AT THE REMOTE POINTER, so park the pointer on
# our own target window first - the menu then opens over a window we own.
[PC]::Set(290, 150)
Start-Sleep -Milliseconds 300
[void](AdbShell @("input","swipe","$hcx","$hcy","$hcx","$hcy","900")) # press & hold ~900ms, no movement
Start-Sleep -Milliseconds 700
$rcAfter = RightClickCount
$debug = ((VisibleTexts (Get-UiText)) -join " || ")
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b3-longpress.png" | Out-Null
Write-Host " right-click counter after : $rcAfter (source tag in HUD)"
Write-Host " screen after long press: $debug"
if ($rcBefore -lt 0 -or $rcAfter -lt 0) { throw "V-B3 FAIL: could not read the right-click counter from the HUD" }
if ($rcAfter -le $rcBefore) {
throw "V-B3 FAIL: long press did not inject a right click (counter stayed $rcAfter; screen: $debug)"
}
Write-Host " PASS: long press injected a right click (counter $rcBefore -> $rcAfter)"
Write-Host "=== 4b. V-B3 negative: a DRAG must not fire long press ==="
$rcBeforeDrag = RightClickCount
Enter-HiddenSteady | Out-Null
[void](AdbShell @("input","swipe","$hcx","$hcy",$([int]($hcx+120)),$([int]($hcy+80)),900))
Start-Sleep -Milliseconds 700
$rcAfterDrag = RightClickCount
$debug2 = ((VisibleTexts (Get-UiText)) -join " || ")
Write-Host " right-click counter after drag: $rcAfterDrag (was $rcBeforeDrag)"
Write-Host " screen after drag: $debug2"
if ($rcAfterDrag -gt $rcBeforeDrag) {
throw "V-B3 FAIL: drag injected a right click (counter $rcBeforeDrag -> $rcAfterDrag); long press must be suppressed once the finger moves"
}
Write-Host " PASS: drag did not fire long press"
Write-Host "=== 4c. V-B3: two-finger right click still works ==="
$rcBeforeTwo = RightClickCount
# adb cannot synthesize a two-finger tap (lessons R-6): this is a MANUAL item.
Write-Host " (two-finger tap is not drivable via adb - recorded as manual, not asserted here)"
}
finally {
# 4a pollution discipline: end with no live session - the agent's 10s idle
# reclaim then happens BETWEEN runs, not in the middle of the next run's
# connect (measured 2026-11-01: a stranded zombie left the next proof unable
# to reach a live session at all).
[void](AdbShell @("am","force-stop","com.verify.remote"))
# target window exits via its stop flag (text_target contract); kill is the
# fallback. In finally so a throw never leaves a topmost window on the user's desktop.
if ($stopFile) { [void](New-Item -ItemType File -Force -Path $stopFile -ErrorAction SilentlyContinue) }
Start-Sleep -Milliseconds 400
if ($tp -and -not $tp.HasExited) { Stop-Process -Id $tp.Id -Force -ErrorAction SilentlyContinue }
[void][PC]::Set($origCursor[0], $origCursor[1])
[void](AdbShell @("svc","power","stayon","false"))
if ($origIme -and $origIme -ne "null") {
[void](AdbShell @("ime","enable",$origIme))
[void](AdbShell @("ime","set",$origIme))
Write-Host " IME restored: $(((AdbShell @('settings','get','secure','default_input_method')) -join '').Trim())"
}
Write-Host " PC cursor restored to ($($origCursor[0]),$($origCursor[1]))"
}
Write-Host "=== REL MOUSE PHONE PROOF COMPLETE ==="