# 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 ==="