param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554') . (Join-Path $PSScriptRoot 'final-common.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial # This suite never pushes a save or injects completed stages. Every checkpoint is read from the APK. function Board($State,[int]$Stage) { return @($State.puzzleStates | Where-Object stageId -eq $Stage)[0] } function Counts($Board) { return (@($Board.groups | ForEach-Object {$_.ballIds.Count} | Sort-Object) -join ',') } function Orbit([string]$Name,$CurrentBoard) { if(!$CurrentBoard){$saved=Save "$Name-view";$CurrentBoard=if($saved.puzzleStates.Count){$saved.puzzleStates[-1]}else{$saved.latestFreePlay}} $points=@($CurrentBoard.balls|ForEach-Object{,(BallPoint $CurrentBoard $_.id)}) foreach($y in @(300,700)){foreach($x in @(650,900,1150,1400,1650)){ if(@($points|Where-Object{[Math]::Pow($_[0]-$x,2)+[Math]::Pow($_[1]-$y,2)-lt22500}).Count-eq0){ Step $Name @('shell','input','swipe',"$x","$y","$($x+120)","$y",'350');return } }} throw 'No safe blank orbit start in the actual board view.' } function ZoomOut([int]$Stage,$CurrentBoard) { $helper=Join-Path $PSScriptRoot 'send-composition-touch.ps1' foreach($contacts in @(@(30,400,999,1024),@(30,400,999,0),@(900,350,1,1024,1500,350,2,1024),@(1050,350,1,1024,1350,350,2,1024),@(1050,350,1,0,1350,350,2,0))){ & pwsh -NoProfile -Command "& '$helper' -Contacts $($contacts-join',')" $code=$LASTEXITCODE;$commands.Add(@{command='native sendTouch pinch-out';contacts=$contacts;exitCode=$code;utc=[DateTime]::UtcNow.ToString('o')});if($code-ne0){throw 'Native pinch failed'};Start-Sleep -Milliseconds 120 } Step "stage-$Stage-zoom-out" @() 300 $after=Board (Save "stage-$Stage-zoom-out-state") $Stage Check "stage-$Stage-pinch-preserves-board" ((Same $CurrentBoard.balls $after.balls)-and(Same $CurrentBoard.groups $after.groups)-and(Same $CurrentBoard.countedBallIds $after.countedBallIds)-and$after.camera.distance-gt$CurrentBoard.camera.distance) 'Actual two-finger zoom-out; no ball, membership or count edits' return $after } function MergeReal([int]$Stage,[string]$Name) { for($attempt=0;$attempt-lt16;$attempt++) { $before=Save "$Name-before-$attempt"; $board=Board $before $Stage if($null-eq$board){return $before} $oldCount=$board.groups.Count $rotation=[Numerics.Quaternion]::new($board.camera.rotation.x,$board.camera.rotation.y,$board.camera.rotation.z,$board.camera.rotation.w) $pairs=@(foreach($source in $board.groups){foreach($target in $board.groups){if($source.id-eq$target.id){continue};foreach($sid in $source.ballIds){foreach($tid in $target.ballIds){ $s=@($board.balls|Where-Object id -eq $sid)[0].position; $t=@($board.balls|Where-Object id -eq $tid)[0].position $delta=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z),[Numerics.Quaternion]::Inverse($rotation)) [pscustomobject]@{sid=$sid;tid=$tid;depth=[Math]::Abs($delta.Z);size=$source.ballIds.Count} }}}}) | Sort-Object depth,size $pair=$pairs[0] if($pair.depth-lt.9) { $start=BallPoint $board $pair.sid; $end=BallPoint $board $pair.tid # Try a short lateral contact offset, then orbit to expose another depth plane if blocked. $offset=if($attempt%2-eq0){-35}else{35} Step "$Name-drag-$attempt" @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]+$offset)","$($end[1])",'500') $after=Save "$Name-after-$attempt"; $newBoard=Board $after $Stage if($null-eq$newBoard-or$newBoard.groups.Count-lt$oldCount){return $after} } Orbit "$Name-orbit-$attempt" $board } throw "Native merge bound reached for Stage $Stage; all actual attempts retained." } function SplitReal([int]$Stage,[int]$Selected,[string]$Name) { for($attempt=0;$attempt-lt8;$attempt++) { $before=Save "$Name-before-$attempt"; $board=Board $before $Stage $group=@($board.groups|Sort-Object {$_.ballIds.Count} -Descending)[0] $ids=@(if($Stage-eq13-and$Selected-eq2){$group.ballIds[0];$group.ballIds[-1]}else{$group.ballIds|Select-Object -First $Selected}) TapBall "$Name-choose-$attempt" $board $ids[0] Step "$Name-mode-$attempt" @('shell','input','tap','312','832') foreach($id in $ids){TapBall "$Name-select-$attempt-$id" $board $id} $point=BallPoint $board $ids[0] Step "$Name-drag-$attempt" @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0])","$($point[1]-160)",'500') $after=Save "$Name-after-$attempt"; $actual=Board $after $Stage if($null-eq$actual-or$actual.groups.Count-gt$board.groups.Count){return $after} # Cancel failed selection before a new view; do not silently turn an invalid selection into a solve. Step "$Name-cancel-$attempt" @('shell','input','tap','312','832') Orbit "$Name-orbit-$attempt" $board } throw "Native split bound reached for Stage $Stage." } function Collect([int]$Stage) { $state=Save "stage-$Stage-count-initial"; $board=Board $state $Stage Check "stage-$Stage-separated-ones" ($board.groups.Count-eq$board.balls.Count-and@($board.groups|Where-Object {$_.ballIds.Count-ne1}).Count-eq0-and$board.collectedBallIds.Count-eq0-and$board.countedBallIds.Count-eq0) (Counts $board) if($board.balls.Count-eq1) { $point=BallPoint $board $board.balls[0].id TapBall "stage-$Stage-precollect-tap" $board $board.balls[0].id Check "stage-$Stage-precollect-cannot-count" ((Board (Save "stage-$Stage-precollect-state") $Stage).countedBallIds.Count-eq0) 'Uncollected tap never counts' Step "stage-$Stage-direct-collect" @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+80)","$($point[1])",'500') } else { for($merge=0;$merge-lt12;$merge++) { $state=Save "stage-$Stage-merge-$merge-state"; $board=Board $state $Stage if($board.groups.Count-eq1-and$board.collectedBallIds.Count-eq$board.balls.Count){break} MergeReal $Stage "stage-$Stage-merge-$merge" | Out-Null } } $state=Save "stage-$Stage-collected"; $board=Board $state $Stage Check "stage-$Stage-direct-collected-not-counted" ($board.groups.Count-eq1-and$board.collectedBallIds.Count-eq$board.balls.Count-and$board.countedBallIds.Count-eq0) 'Every ball directly dragged or merged; no injected or automatic count' if($board.balls.Count-ge6){$board=ZoomOut $Stage $board} return $board } function CountRemaining([int]$Stage) { for($attempt=0;$attempt-lt40;$attempt++) { $state=Save "stage-$Stage-count-$attempt-state"; $board=Board $state $Stage if($null-eq$board){return $state} $uncounted=@($board.balls|Where-Object {$board.countedBallIds-notcontains$_.id}) $beforeCount=$board.countedBallIds.Count $id=$null foreach($ball in $uncounted){if(WorldSafe (BallPoint $board $ball.id)){$id=$ball.id;break}} if(!$id){Orbit "stage-$Stage-count-safe-view-$attempt" $board;continue} TapBall "stage-$Stage-count-$attempt" $board $id $after=Save "stage-$Stage-count-$attempt-after"; $newBoard=Board $after $Stage if($null-eq$newBoard){return $after} if($newBoard.countedBallIds.Count-eq$beforeCount){Orbit "stage-$Stage-count-reveal-$attempt" $newBoard} } throw "Native counting bound reached Stage $Stage." } try { $result.scope='Fresh 30-stage sequential actual touch progression, no save writes or completion fixtures; separate boundary suites are labelled separately' Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit $build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw|ConvertFrom-Json Check 'build-provenance' ($build.status-eq'passed'-and$build.exitCode-eq0-and!$build.sourceDirty-and!$build.sourceDirtyEnd-and$build.sourceCommit-eq$result.sourceCommit-and$build.sourceCommitEnd-eq$result.sourceCommit-and$build.apkSha256-eq$result.apkSha256) $result.apkSha256 Check 'boot-completed' ((Adb @('shell','getprop','sys.boot_completed'))-eq'1') 'Actual AVD boot property' $result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'));size=(Adb @('shell','wm','size'));graphics=(Adb @('shell','dumpsys','SurfaceFlinger'))} $audioBefore=Adb @('shell','dumpsys','audio');$audioBefore|Set-Content (Join-Path $EvidenceDir 'music-before.txt') $current=[regex]::Match($audioBefore,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value $musicBefore=@{};foreach($m in [regex]::Matches($current,'([0-9a-f]+) \([^)]+\): (\d+)')){$musicBefore[$m.Groups[1].Value]=[int]$m.Groups[2].Value} Check 'music-original-routes' ($musicBefore.ContainsKey('2')-and$musicBefore.ContainsKey('8000')-and$musicBefore.ContainsKey('40000000')) $current Adb @('install','-r',$ApkPath)|Out-Null $installed=(Adb @('shell','pm','path',$package)).Replace('package:','').Trim() Adb @('pull',$installed,(Join-Path $EvidenceDir 'installed-base.apk'))|Out-Null Check 'installed-exact-final-apk' ((Get-FileHash (Join-Path $EvidenceDir 'installed-base.apk')).Hash-eq$result.apkSha256) $result.apkSha256 Adb @('logcat','-c')|Out-Null;Adb @('shell','pm','clear',$package)|Out-Null $networkBefore=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))} $result.networkBefore=$networkBefore Adb @('shell','cmd','connectivity','airplane-mode','enable')|Out-Null;Adb @('shell','svc','wifi','disable')|Out-Null;Adb @('shell','svc','data','disable')|Out-Null for($attempt=0;$attempt-lt30;$attempt++){$offline=(Adb @('shell','settings','get','global','airplane_mode_on'))-eq'1'-and(Adb @('shell','settings','get','global','wifi_on'))-eq'0'-and(Adb @('shell','settings','get','global','mobile_data'))-eq'0'-and(Adb @('shell','dumpsys','connectivity'))-match'Active default network: none';if($offline){break};Start-Sleep -Milliseconds 500} Check 'offline-through-all-30' $offline 'Airplane1 wifi0 data0, no active default network' StartHome 'fresh-home';Step 'fresh-selection' @('shell','input','tap','1200','616') $point=StagePoint 2;Step 'fresh-locked2' @('shell','input','tap',"$($point[0])","$($point[1])") $fresh=Save 'fresh-state';Check 'fresh-only1-open' ($fresh.completedStageIds.Count-eq0-and$fresh.puzzleStates.Count-eq0-and$null-eq$fresh.latestFreePlay) 'Actual new save; locked2 opens no puzzle' $point=StagePoint 1;Step 'stage-1-enter' @('shell','input','tap',"$($point[0])","$($point[1])") 2000 $targets=@{6='2';7='3';8='4';9='5';10='1,1';11='1,2';12='2,2';13='2,3';14='2,2';15='2,3';21='6';22='7';23='8';24='10';25='1,5';26='3,4';27='4,5';28='5,5';29='4,4';30='5,5'} $splitSizes=@{10=1;11=1;12=2;13=2;14=2;15=2;25=1;26=3;27=4;28=5;29=4;30=5} for($stage=1;$stage-le30;$stage++) { Write-Host "Final actual progression Stage $stage" $before=Save "stage-$stage-entered"; $original=Board $before $stage Check "stage-$stage-entered" ($null-ne$original-and$before.completedStageIds.Count-eq($stage-1)) 'Real next stage reached with exactly preceding records' if($stage-eq6-or$stage-eq10){Step "stage-$stage-tutorial-completes" @() 9000;$during=Save "stage-$stage-ghost-ended";Check "stage-$stage-readonly-ghost" (Same $before $during) 'First-entry seen persisted, real board unchanged by ghost'} if($stage-le5-or($stage-ge16-and$stage-le20)) { $collected=Collect $stage if($stage-eq2) { TapBall 'partial2-first-count' $collected $collected.balls[0].id Orbit 'partial2-orbit' $collected;$partial=Save 'partial2-before';$partialBoard=Board $partial 2 Check 'positive-partial2' ($partialBoard.countedBallIds.Count-eq1-and$partialBoard.collectedBallIds.Count-eq2-and!(Same $partialBoard.camera $collected.camera)) 'Actual1/2 plus changed camera' Step 'partial2-home' @('shell','input','tap','312','108');SelectStage 'replay1-unfinished' 1 $replayBefore=Save 'replay1-before';$replayBoard=Board $replayBefore 1;$point=BallPoint $replayBoard $replayBoard.balls[0].id Step 'replay1-unfinished-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+60)","$($point[1])",'500') Step 'replay1-home' @('shell','input','tap','312','108');Step 'free1-continue' @('shell','input','tap','1200','810') Orbit 'free1-orbit' (Save 'free1-before-orbit').latestFreePlay;$three=Save 'two-unfinished-and-free-before' Check 'two-unfinished-plus-free' ($three.puzzleStates.Count-eq2-and$three.latestFreePlay.sourceStageId-eq1-and(Same $partialBoard (Board $three 2))-and$three.completedStageIds.Count-eq1) 'Replay1, actual partially counted2, and independent Free1 coexist without fixture' Restart 'three-board-restart';Step 'free1-restored' @('shell','input','tap','1200','810') Check 'two-puzzles-free-whole-restored' (Same $three (Save 'two-unfinished-and-free-after')) 'Pause force-stop startup Home then Free restore exact full save' Step 'free1-home' @('shell','input','tap','312','108');SelectStage 'replay1-restored' 1 Check 'replay1-independent-ui-restored' (Same (Board $three 1) (Board (Save 'replay1-restored-state') 1)) 'Second unfinished board independently entered through actual UI with exact altered positions' Step 'replay1-restored-home' @('shell','input','tap','312','108');SelectStage 'partial2-restored' 2 Check 'partial2-exact-restored' (Same $partialBoard (Board (Save 'partial2-restored') 2)) 'OriginalIDs positions membership collected counted camera exact' } CountRemaining $stage|Out-Null } else { if($original.groups.Count-gt1){MergeReal $stage "stage-$stage-merge"|Out-Null} if($splitSizes.ContainsKey($stage)){SplitReal $stage $splitSizes[$stage] "stage-$stage-split"|Out-Null} } $success=Save "stage-$stage-success";$free=$success.latestFreePlay $expected=if($targets.ContainsKey($stage)){$targets[$stage]}elseif($stage-le5){"$stage"}else{"$($stage-10)"} Check "stage-$stage-actual-success" ($success.completedStageIds.Count-eq$stage-and$success.completedStageIds-contains$stage-and$null-eq(Board $success $stage)-and$free.sourceStageId-eq$stage-and(Counts $free)-eq$expected) "groups=$(Counts $free) completed=$($success.completedStageIds-join',')" Check "stage-$stage-conserves-all-ids" (((($free.balls.id|Sort-Object)-join',') -eq (($original.balls.id|Sort-Object)-join','))) 'All original identities preserved' if($stage-lt30){Step "stage-$stage-next" @('shell','input','tap','2064','648') 1800} } $complete=Save 'thirty-completed';Step 'thirty-no-next' @('shell','input','tap','2064','648');Check 'no-stage31' (Same $complete (Save 'thirty-no-next-after')) 'Final no-next tap leaves Free30' Check 'both-tutorials-persisted-once' ((($complete.seenTutorialIds|Sort-Object)-join',') -eq 'merge,split') ($complete.seenTutorialIds-join',') Adb @('logcat','-d','-s','Unity','AndroidRuntime')|Set-Content (Join-Path $EvidenceDir 'final-runtime.log') Check 'runtime-no-exception' ((Get-Content (Join-Path $EvidenceDir 'final-runtime.log') -Raw)-notmatch'NullReferenceException|FATAL EXCEPTION|MissingReferenceException') 'All30 native run log' $result.sourceCommitEnd=(& git rev-parse HEAD);$result.sourceDirtyEnd=[bool](& git status --porcelain) Check 'unchanged-source-end' (!$result.sourceDirtyEnd-and$result.sourceCommitEnd-eq$result.sourceCommit) $result.sourceCommitEnd $result.status='passed' } catch {$result.error=$_.Exception.Message;Write-Host $result.error -ForegroundColor Red} finally { foreach ($recorder in $recorders) { if (!$recorder.process.HasExited -and !$recorder.process.WaitForExit(($recorder.seconds+30)*1000)) { $result.status='failed'; $result.recorderError='Recorder exceeded completion bound; own native process stopped before restoration' $ready=Get-Content -LiteralPath (Join-Path $EvidenceDir "$($recorder.name).wav.ready.json") -Raw | ConvertFrom-Json $native=Get-Process -Id $ready.recorderPid -ErrorAction SilentlyContinue if ($native) { $native.Kill(); $native.WaitForExit() } $recorder.process.WaitForExit(10000) | Out-Null if (!$recorder.process.HasExited) { $recorder.process.Kill(); $recorder.process.WaitForExit() } } $readyPath=Join-Path $EvidenceDir "$($recorder.name).wav.ready.json" if (Test-Path -LiteralPath $readyPath) { $ready=Get-Content -LiteralPath $readyPath -Raw | ConvertFrom-Json $native=Get-Process -Id $ready.recorderPid -ErrorAction SilentlyContinue if ($native) { $native.WaitForExit() } $commands.Add(@{command='recorder termination boundary';recorderPid=$ready.recorderPid;wrapperPid=$recorder.process.Id;nativeExited=$true;wrapperExited=$recorder.process.HasExited;utc=[DateTime]::UtcNow.ToString('o')}) } } if ($musicBefore) { try { Adb @('shell','cmd','audio','set-volume','3',"$($musicBefore['2'])") | Out-Null Adb @('shell','cmd','audio','set-device-volume','3',"$($musicBefore['8000'])",'32768') | Out-Null Adb @('shell','cmd','audio','set-device-volume','3',"$($musicBefore['40000000'])",'1073741824') | Out-Null $audioAfter=Adb @('shell','dumpsys','audio') $audioAfter | Set-Content -LiteralPath (Join-Path $EvidenceDir 'music-after.txt') -Encoding utf8 $current=[regex]::Match($audioAfter,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value Check 'music-routes-restored' ($current -match "2 \(speaker\): $($musicBefore['2'])(,|$)" -and $current -match "8000 \(remote_submix\): $($musicBefore['8000'])(,|$)" -and $current -match "40000000 \(default\): $($musicBefore['40000000'])(,|$)") $current } catch { $result.status='failed'; $result.volumeRestoreError=$_.Exception.Message } } if ($networkBefore) { try { Adb @('shell','cmd','connectivity','airplane-mode',$(if ($networkBefore.airplane -eq '1') {'enable'} else {'disable'})) | Out-Null Adb @('shell','svc','wifi',$(if ($networkBefore.wifi -eq '1') {'enable'} else {'disable'})) | Out-Null Adb @('shell','svc','data',$(if ($networkBefore.data -eq '1') {'enable'} else {'disable'})) | Out-Null for ($attempt=0; $attempt -lt 30; $attempt++) { $result.networkAfter=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))} if (Same $networkBefore $result.networkAfter) { break }; Start-Sleep -Milliseconds 500 } Check 'network-restored' (Same $networkBefore $result.networkAfter) ($result.networkAfter | ConvertTo-Json -Compress) } catch { $result.status='failed'; $result.restoreError=$_.Exception.Message } } $result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o') $result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-final-progression-evidence.json') -Encoding utf8 } if ($result.status -ne 'passed') { exit 1 } Write-Host "Final fresh 30-stage real-input checks passed: $($checks.Count) checks." exit 0