From 3b219a442d2099256dac911915be6931ae5c5dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Mon, 2 Nov 2026 12:39:23 +0800 Subject: [PATCH] =?UTF-8?q?test(tools):=20cursor=5Fproof=20+=20proof=5Fcom?= =?UTF-8?q?mon=20=E6=8A=BD=E5=8F=96=20+=20rel=5Fmouse=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=20v5/=E5=8F=8C=E5=B1=8F=E4=B8=8E=E6=B1=A1=E6=9F=93=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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、全链路绿 --- tools/cursor_proof.ps1 | 280 +++++++++++++++++++++++++ tools/proof_common.ps1 | 349 ++++++++++++++++++++++++++++++++ tools/rel_mouse_phone_proof.ps1 | 85 +++++--- 3 files changed, 685 insertions(+), 29 deletions(-) create mode 100644 tools/cursor_proof.ps1 create mode 100644 tools/proof_common.ps1 diff --git a/tools/cursor_proof.ps1 b/tools/cursor_proof.ps1 new file mode 100644 index 0000000..2f14f14 --- /dev/null +++ b/tools/cursor_proof.ps1 @@ -0,0 +1,280 @@ +# 4b cursor-overlay proof: marker position, off-screen fold, refresh, old-client reject. +# +# ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; repo lessons). Chinese expected +# texts are built from code points (R-8: never trust an encoding round trip). +# +# Judgements (loop-4b-plan.md section 9): +# V-D6 OLD v4 APK + new v5 agent -> form shows the version-mismatch text and +# the session never goes live. RUN THIS PHASE BEFORE INSTALLING THE NEW +# APK: -Phase reject +# V-D1 screencap cyan cluster centroid == CursorMarker expectation +/- 6 px +# for 4 desktop points incl. (48,48) ~ origin (Some(0,0) vs None: the +# marker MUST be painted at the corner, not skipped) +# V-D3 after moving the PC cursor on a quiet desktop the marker reaches the +# new spot within ~1.2s (covers the 500ms idle-resend path; the frame +# header rebuild with fresh cursor was the whole point of R-A) +# V-D7 cursor into the LEFT SECOND MONITOR (negative X, machine is dual-screen +# since 2026-11-02) -> agent folds to None -> cyan cluster GONE +# +# Prereqs: v5 release agent.exe built; new (v5) APK installed for -Phase main; +# second monitor actually present (V-D7 skips loudly if not - see probe output); +# nobody touching the real mouse during a step (gates retry, loud fail otherwise). +# +# powershell -ExecutionPolicy Bypass -File tools\cursor_proof.ps1 -Host_ 192.168.1.181 -Phase reject +# powershell -ExecutionPolicy Bypass -File tools\cursor_proof.ps1 -Host_ 192.168.1.181 -Phase main +param( + [Parameter(Mandatory)][string]$Host_, + [int]$Port = 47800, + [string]$Code = "t", + [ValidateSet("reject", "main")][string]$Phase = "main", + [string]$AgentExe = "" +) +$ErrorActionPreference = "Stop" +$repo = (Resolve-Path "$PSScriptRoot\..").Path +. "$PSScriptRoot\proof_common.ps1" + +# ---- expected Chinese fragments as code points (R-8) ---- +# "connect" button label +$F_CONNECT = -join ([char]0x8FDE, [char]0x63A5) +# "reconnect" +$F_RECONNECT = -join ([char]0x91CD, [char]0x65B0, [char]0x8FDE, [char]0x63A5) +# "protocol version mismatch" error summary (ConnectErrorText KIND_VERSION) +$F_VERSION = -join ([char]0x534F, [char]0x8BAE, [char]0x7248, [char]0x672C, + [char]0x4E0D, [char]0x4E00, [char]0x81F4) +# "live" HUD prefix (yi-lian-jie = connected) +$F_LIVE = -join ([char]0x5DF2, [char]0x8FDE, [char]0x63A5) + +$ev = Join-Path (Split-Path $repo -Parent) "evidence\4b" +New-Item -ItemType Directory -Force -Path $ev | Out-Null + +# ---- cyan marker pixel detector (LockBits scan, full centroid) ---- +Add-Type -AssemblyName System.Windows.Forms # VirtualScreen for V-D7 +if (-not ('PccCyan' -as [type])) { + Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @" +using System; using System.Drawing; using System.Drawing.Imaging; +using System.Runtime.InteropServices; using System.Collections.Generic; +public class PccCyan { + public static string Find(string path) { + using (var bmp = new Bitmap(path)) { + var rect = new Rectangle(0,0,bmp.Width,bmp.Height); + var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + int n = data.Stride * data.Height; byte[] buf = new byte[n]; + Marshal.Copy(data.Scan0, buf, 0, n); + bmp.UnlockBits(data); + // dot color = RGB(0x00,0xE5,0xFF); BGRA in memory. Loose tolerance: + // the ring is white/black; a desktop with a big cyan region would poison the + // centroid, so the total-count guard below rejects it instead of averaging. + int total = 0; + for (int y=0; y=0xC0 && g<=0xFF) && b>=0xD0) total++; + } + } + if (total == 0) return "none"; + if (total > 4000) return "patch:" + total; // suspicious giant color field + // Centroid over ALL cyan pixels would be corrupted by a few stray ones far + // away (measured 2026-11-02: n=181 at the right spot but spread=955 - the + // desktop itself had a handful of cyan pixels elsewhere). Cluster properly: + // bin the hits on an 8px grid, take the DENSEST bin as the marker seed, + // then centroid/spread over hits within 40px of that seed only. + var bins = new Dictionary(); + for (int y=0; y=0xC0 && g<=0xFF) && b>=0xD0) { + long key = (long)(x/8)*1000000 + (y/8); + bins[key] = bins.ContainsKey(key) ? bins[key]+1 : 1; + } + } + } + long bestK = -1; int bestN = 0; int secondN = 0; + foreach (var kv in bins) { + if (kv.Value > bestN) { secondN = bestN; bestN = kv.Value; bestK = kv.Key; } + else if (kv.Value > secondN) { secondN = kv.Value; } + } + int bx = (int)(bestK/1000000)*8 + 4, by = (int)(bestK%1000000)*8 + 4; + long sx=0, sy=0; int cnt=0; int minx=99999999,miny=99999999,maxx=-1,maxy=-1; + for (int y=0; y=0xC0 && g<=0xFF) && b>=0xD0) { + if (Math.Abs(x-bx)<=40 && Math.Abs(y-by)<=40) { + sx+=x; sy+=y; cnt++; + if(xmaxx)maxx=x; if(ymaxy)maxy=y; + } + } + } + } + if (cnt == 0) return "none"; + int spread = Math.Max(maxx-minx, maxy-miny); + // report seed too: harness prints it when a check fails (diagnosis, not judgement) + return string.Format("{0}|{1}|{2}|{3}|{4}|{5}|{6}", sx/(double)cnt, sy/(double)cnt, cnt, spread, total, bx, secondN); + } + } +} +"@ +} +function Find-Marker([string]$pngPath) { + $r = [PccCyan]::Find($pngPath) + if ($r -eq "none") { return $null } + if ($r -like "patch:*") { throw "screenshot contains a huge cyan-ish color field ($($r.Substring(6)) px) - clear the desktop (wallpaper/browser) and re-run; refusing to average a fake marker (R-8)" } + $p = $r.Split('|') + @{ x = [double]$p[0]; y = [double]$p[1]; n = [int]$p[2]; spread = [int]$p[3]; total = [int]$p[4]; seed = "$($p[5]),$($p[6])" } +} +function Take-Shot([string]$tag) { + $dev = "/sdcard/pcc_$PID.png" + $local = Join-Path $ev "$tag.png" + $old = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { + [void](AdbShell @("screencap", "-p", $dev)) + $r = Adb -AdbArgs @("pull", $dev, $local) + } finally { $ErrorActionPreference = $old } + if ($r.code -ne 0 -or -not (Test-Path $local)) { throw "screencap/pull failed for $tag" } + return $local +} + +Write-Host "=== 0. preflight (memory / fresh v5 agent) ===" +Memory-Precheck +if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" } +Ensure-FreshAgent $AgentExe $Port $Code + +$installed = ((AdbShell @("dumpsys", "package", "com.verify.remote"))) -join "`n" +$vn = [regex]::Match($installed, 'versionName=(\S+)').Groups[1].Value +Write-Host " installed APK: $vn" + +# ====================================================================== +if ($Phase -eq "reject") { + Write-Host "=== V-D6: OLD (v4) APK against the NEW v5 agent must be rejected ===" + if ($vn -like "*phase4a*") { + Write-Host " (running against the v4 client - this phase MUST come before installing 4b)" + } else { + Write-Host " WARNING: installed APK is $vn - the v4-rejection test is only meaningful against a real v4 client" + } + # NOTE (first-run lesson 2026-11-02): launching via `am start --es host ...` + # does NOT dial: the intent only prefill-prefers stale SharedPreferences and + # the form just sits there ("未连接"). Rejection must be observed through the + # REAL dial path - type the form, tap connect, read the failure screen. + $x = Restart-AppForm + FillAndConnect $x $Host_ "$Port" $Code $F_CONNECT $F_RECONNECT + $deadline = (Get-Date).AddSeconds(25); $shown = $null + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 800 + $ui = Get-UiRetry + $texts = (VisibleTexts $ui) -join " / " + if ($texts -match "version mismatch" -or $texts.Contains($F_VERSION)) { $shown = $texts; break } + if ($texts.Contains($F_LIVE)) { throw "V-D6 FAIL: v4 client CONNECTED to the v5 agent ('$F_LIVE' on screen) - version gate broken" } + } + if (-not $shown) { throw "V-D6 FAIL: no version-mismatch text within 25s; last screen: [$((VisibleTexts (Get-Ui)) -join ' / ')]" } + Write-Host " screen: $shown" + Write-Host " PASS: v5 agent refused the v4 client with the actionable Chinese hint" + & powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-d6-reject.png" | Out-Null + # pollution discipline (4a): leave no live session for the next proof + [void](AdbShell @("am", "force-stop", "com.verify.remote")) + Write-Host "=== CURSOR PROOF (reject phase) COMPLETE ===" + exit 0 +} + +# ====================================================================== +# ---- main phase: needs the NEW apk; go live once, then geometry checks ---- +Write-Host "=== 1. go live (new v5 APK), capture HUD (v5 pin + desktop size) ===" +if ($vn -notlike "*phase4b*") { throw "main phase needs the 4b APK installed (found $vn)" } +$x = Restart-AppForm +FillAndConnect $x $Host_ "$Port" $Code $F_CONNECT $F_RECONNECT +$hud = Wait-LiveHud 20 +if (-not $hud) { throw "never reached live: [$((VisibleTexts (Get-Ui)) -join ' / ')]" } +Write-Host " HUD: $($hud -replace "`n", ' | ')" +if (-not $hud.Contains("v5")) { throw "V-D5x FAIL: HUD does not report v5 (PROTO_LABEL out of sync?): [$hud]" } +$m = [regex]::Match($hud, '(\d+)x(\d+)') +if (-not $m.Success) { throw "cannot parse desktop size from HUD: [$hud]" } +$dW = [int]$m.Groups[1].Value; $dH = [int]$m.Groups[2].Value +Write-Host " desktop from HUD: ${dW}x${dH}" + +# Marker geometry works in EITHER orientation (letterbox mapping is proportional), +# but landscape is the 4a headline case. AUTO rotates for a wide desktop - give the +# rotation a moment to land, then proceed on whatever orientation is actually up. +$rot = (Get-Date).AddSeconds(3); $d = LocalDisplay +while ((Get-Date) -lt $rot) { + $d = LocalDisplay + if ($d.w -gt $d.h) { break } + Start-Sleep -Milliseconds 250 +} +Write-Host (" orientation: {0}x{1} ({2})" -f $d.w, $d.h, $(if ($d.w -gt $d.h) { "landscape" } else { "portrait" })) +Start-Sleep -Seconds 2 # let the hide timer run so the video fills its final rect + +$ui = Get-UiRetry +$vb = VideoBounds $ui +if (-not $vb) { throw "video (16:9 View) not found in the tree - every coordinate expectation below would be meaningless (N21)" } +Write-Host (" video bounds: l={0} t={1} w={2} h={3} ratio err {4:N4}" -f $vb.l, $vb.t, $vb.w, $vb.h, $vb.err) +if ($vb.err -gt 0.02) { throw "video not 16:9 - geometry changed since 4a, re-derive the mapping here" } + +function Expect-OnScreen([int]$cx, [int]$cy) { + # CursorMarker on-screen anchor = video view origin + mapped point. + # PS parses `a / [double]b, c` inside @() as an array construction (the type + # literal + comma is a known pitfall - measured 2026-11-02: this threw + # op_Division on Object[] / silently produced 3 elements). Parenthesize each + # element so each division is evaluated as a scalar first. + @( ($vb.l + $cx * $vb.w / [double]$dW), ($vb.t + $cy * $vb.h / [double]$dH) ) +} +function Check-MarkerAt([int]$cx, [int]$cy, [string]$tag, [double]$tol = 6.0) { + Set-CursorQuiet $cx $cy + Start-Sleep -Milliseconds 700 # >= one 500ms idle frame + render + $png = Take-Shot $tag + $mk = Find-Marker $png + if (-not $mk) { throw "V-D1 FAIL [$tag]: no marker at desktop ($cx,$cy) - cyan cluster absent" } + $e = Expect-OnScreen $cx $cy + $dx = [Math]::Abs($mk.x - $e[0]); $dy = [Math]::Abs($mk.y - $e[1]) + Write-Host (" [{0}] marker ({1:N0},{2:N0}) n={3} spread={4} expect ({5:N0},{6:N0}) dev=({7:N1},{8:N1})" -f ` + $tag, $mk.x, $mk.y, $mk.n, $mk.spread, $e[0], $e[1], $dx, $dy) + if ($dx -gt $tol -or $dy -gt $tol) { throw "V-D1 FAIL [$tag]: deviation ($dx,$dy) > $tol px (shot: $png)" } + if ($mk.spread -gt 90) { throw "V-D1 FAIL [$tag]: cyan spread $($mk.spread)px - too wide to be the marker dot (false positive path)" } + return $mk +} + +Write-Host "=== 2. V-D1: four desktop points (incl. near-origin) ===" +# (48,48) instead of (0,0): the dot radius would clip at the exact corner and +# bias the centroid; Some(0,0)-vs-None semantics are already locked by unit + +# golden tests. Corner-proximity error is bounded well below tol here. +Check-MarkerAt 48 48 "v-d1-origin" | Out-Null +Check-MarkerAt 1280 720 "v-d1-center" | Out-Null +Check-MarkerAt 2400 1300 "v-d1-lowright" | Out-Null +Check-MarkerAt 2000 300 "v-d1-upperright" | Out-Null +Write-Host " PASS: marker centroid within 6 px for all 4 points" + +Write-Host "=== 3. V-D3: idle-desktop move refreshes the marker <= ~1.2s ===" +# Quiet gate, then one decisive jump; 700ms above already covers 500ms resend. +$from = Check-MarkerAt 300 1200 "v-d3-from" +$e2 = Expect-OnScreen 1600 400 +Set-CursorQuiet 1600 400 +Start-Sleep -Milliseconds 900 +$mk3 = Find-Marker (Take-Shot "v-d3-to") +if (-not $mk3) { throw "V-D3 FAIL: marker vanished after moving the cursor on a quiet desktop" } +$dx3 = [Math]::Abs($mk3.x - $e2[0]); $dy3 = [Math]::Abs($mk3.y - $e2[1]) +Write-Host (" after jump: marker ({0:N0},{1:N0}) expect ({2:N0},{3:N0}) dev=({4:N1},{5:N1})" -f ` + $mk3.x, $mk3.y, $e2[0], $e2[1], $dx3, $dy3) +if ($dx3 -gt 6 -or $dy3 -gt 6) { throw "V-D3 FAIL: marker did not follow within 900ms (stale cursor field?)" } +Write-Host " PASS: marker refreshes within one idle-resend window" + +Write-Host "=== 4. V-D7: cursor into the LEFT second monitor folds to None ===" +$vd = [System.Windows.Forms.SystemInformation]::VirtualScreen +if ($vd.X -ge 0) { + Write-Host " SKIP: no negative-X monitor on this machine - V-D7 (off-screen fold) cannot be tested here; the fold rule is locked by fold_cursor unit tests" +} else { + $ox = [int]($vd.X + 100) # deep into the second display (negative X) + $oy = 500 + Set-CursorQuiet $ox $oy + Start-Sleep -Milliseconds 900 + $mk7 = Find-Marker (Take-Shot "v-d7-second-monitor") + if ($mk7) { throw "V-D7 FAIL: marker still drawn with cursor at ($ox,$oy) outside the captured display (n=$($mk7.n) at {x=$($mk7.x);y=$($mk7.y)})" } + Write-Host " PASS: no cyan cluster with the pointer at ($ox,$oy) - folded to None end to end" +} + +Write-Host "=== 5. tidy: park the pointer inside the primary screen ===" +Set-CursorQuiet ([int]($dW/2)) ([int]($dH/2)) +Write-Host "=== CURSOR PROOF COMPLETE ===" diff --git a/tools/proof_common.ps1 b/tools/proof_common.ps1 new file mode 100644 index 0000000..a989589 --- /dev/null +++ b/tools/proof_common.ps1 @@ -0,0 +1,349 @@ +# 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, ']*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, ']*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, ']*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, ']*>')) { + 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, ']*>')) { + 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, ']*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" } +} diff --git a/tools/rel_mouse_phone_proof.ps1 b/tools/rel_mouse_phone_proof.ps1 index 6a2b275..4462018 100644 --- a/tools/rel_mouse_phone_proof.ps1 +++ b/tools/rel_mouse_phone_proof.ps1 @@ -157,11 +157,25 @@ function Get-HudText { # 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 4; $try++) { - $xml = Get-UiText + for ($try=0; $try -lt 6; $try++) { + $xml = Get-UiTextRetry $cur = $null foreach ($n in [regex]::Matches($xml,']*class="android\.widget\.Button"[^>]*>')) { $t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value) @@ -175,30 +189,23 @@ function Set-Mode([string]$want) { } } if (-not $cur) { - # 4a: controls may be auto-hidden (landscape). Wake once and retry - # inside the same try budget instead of throwing on the first miss. + # 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 - $xml = Get-UiText - foreach ($n in [regex]::Matches($xml,']*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) { throw "mode button not found (no Button whose text contains '$F_MODE_TAG', even after wake)" } + 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 } - $final = ((VisibleTexts (Get-UiText)) -join " || ") - throw "mode did not switch to $want after 4 taps; screen: [$final]" + # 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. # @@ -360,7 +367,7 @@ if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWr 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 (v4)" + 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() @@ -379,8 +386,10 @@ try { $hud = "" if (-not (Wait-Live 14 ([ref]$hud))) { throw "never reached a live session" } Write-Host " HUD: $hud" - if (-not $hud.Contains("v4")) { throw "V-B4 FAIL: HUD does not report protocol v4; got [$hud]" } - Write-Host " PASS: live session on protocol v4" + # 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 @@ -398,8 +407,18 @@ try { # controls are hidden, gestures never re-show them, so the geometry stays # stable for steps 2/2b/2c. $hudNow = Get-HudText - Write-Host " mode at start: $hudNow" - if ($hudNow.Contains($F_ABSOLUTE)) { [void](Set-Mode "relative") } + 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" } @@ -542,17 +561,25 @@ try { 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 for 1920x1080 (physical pixels, 125% scaling does NOT apply - # to the injected desktop space - the agent injects into the physical desktop) + # 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; ex=[int](192); ey=[int](108) }, - @{ frac=0.90; ex=[int](1728); ey=[int](972) }, - @{ frac=0.50; ex=[int](960); ey=[int](540) } + @{ 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()