Files
MagnetismHeroes/tools/android/exercise-final-progression.ps1
T

419 lines
35 KiB
PowerShell

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,[switch]$Pitch,[int]$YawPixels=120,[int]$PitchPixels=120) {
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){
$endX=if($Pitch){$x}else{$x+$YawPixels};$endY=if($Pitch){$y+$PitchPixels}else{$y}
if($endY-lt280-or$endY-gt750){continue}
if($YawPixels-ne120-and!$Pitch){
# Latch the real >14px orbit drag, then reverse to the small net yaw.
try{
NativePointer @($x,$y,81,1024);Start-Sleep -Milliseconds 100
NativePointer @(($x+[Math]::Sign($YawPixels)*20),$y,81,1024);Start-Sleep -Milliseconds 100
NativePointer @($endX,$endY,81,1024);Start-Sleep -Milliseconds 100
}finally{NativePointer @($endX,$endY,81,0)}
Step $Name @() 500
}else{Step $Name @('shell','input','swipe',"$x","$y","$endX","$endY",'350')}
return
}
}}
throw 'No safe blank orbit start in the actual board view.'
}
function ZoomOut([int]$Stage,$CurrentBoard) {
$before=Save "stage-$Stage-zoom-out-before"
Check "stage-$Stage-current-pinch-projection" (Same $CurrentBoard (Board $before $Stage)) 'The pinch uses the exact current board and manual pose'
$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
$state=Save "stage-$Stage-zoom-out-state";$after=Board $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'
$expected=$state|ConvertTo-Json -Depth 20|ConvertFrom-Json
(Board $expected $Stage).camera=$CurrentBoard.camera
Check "stage-$Stage-pinch-preserves-whole-save" (Same $before $expected) 'Only this board manual camera may change; every independent record remains exact'
return $after
}
function RevealBall([int]$Stage,[string]$Id,[string]$Name) {
for($view=0;$view-lt12;$view++){
$board=Board (Save "$Name-view-$view") $Stage
if(BallClear $board $Id){return $board}
Orbit "$Name-orbit-$view" $board -Pitch:($view%4-eq3)
}
throw "No safe first-hit view for actual ball $Id within a full native orbit."
}
function LiftMergeRoute([int]$Stage,[string]$Name,$Board,[int]$SourceCount=0,[int]$TargetCount=0) {
$eligible=@($Board.groups|Where-Object {
$candidate=$_
($SourceCount-eq0-or$candidate.ballIds.Count-eq$SourceCount)-and@($Board.groups|Where-Object {$_.id-ne$candidate.id-and($TargetCount-eq0-or$_.ballIds.Count-eq$TargetCount)}).Count-gt0
}|Sort-Object {$_.ballIds.Count})
$id=$null
foreach($group in $eligible){
$low=@($Board.balls|Where-Object {$group.ballIds-contains$_.id-and$_.position.y-lt2}|Sort-Object {$_.position.y})
if($low.Count){$id=$low[0].id;break}
}
if(!$id){return $null}
$board=RevealBall $Stage $id "$Name-reveal"
$point=BallPoint $board $id;$end=@($point[0],($point[1]-120))
if(!(WorldSafe $end)){
$unchanged=Save "$Name-no-safe-upward-endpoint"
$actual=Board $unchanged $Stage
Check "$Name-unsafe-endpoint-board-unchanged" ($null-ne$actual-and(Same $board $actual)) 'No native touch: the complete revealed board, counts and manual pose remain exact'
return [pscustomobject]@{State=$unchanged;Before=$board;SourceId=$id}
}
$confirmed=Save "$Name-first-hit-before"
Check "$Name-current-source-projection" (Same $board (Board $confirmed $Stage)) 'The intended ID uses the exact currently confirmed board and manual projection'
$after=NativeMoveAndHold $Name $board $id $end
if(IsNativeOrbitMiss $confirmed $after $Stage){
$commands.Add(@{command='Actual first-hit miss: background orbit only, never a grabbed detour or merge';intendedSourceId=$id;utc=[DateTime]::UtcNow.ToString('o')})
return [pscustomobject]@{State=$after;Before=$board;BeforeState=$confirmed;SourceId=$id;Outcome='MissedGrabOrbit'}
}
$actual=Board $after $Stage;if(!$actual){$actual=$after.latestFreePlay}
Check "$Name-detour-manual-camera-preserved" (Same $board.camera $actual.camera) 'Every detour outcome retains the exact confirmed manual pose'
if($actual.groups.Count-lt$board.groups.Count){AssertNativeMerge $Name $board $actual $id $TargetCount;return [pscustomobject]@{State=$after;Before=$board;SourceId=$id}}
Check "$Name-detour-membership-preserved" (Same $board.groups $actual.groups) 'An upward route never injects a split or merge'
Check "$Name-detour-not-counted" (Same $board.countedBallIds $actual.countedBallIds) 'An actual detour does not count friends'
if(Same $board.balls $actual.balls){
$commands.Add(@{command='Upward detour produced no observed ball movement; continue actual view search, never count as merge';sourceId=$id;utc=[DateTime]::UtcNow.ToString('o')})
}else{AssertNativeTranslation $Name $board $actual $id}
return [pscustomobject]@{State=$after;Before=$board;SourceId=$id}
}
function MergeReal([int]$Stage,[string]$Name,[int]$SourceCount=0,[int]$TargetCount=0) {
for($attempt=0;$attempt-lt16;$attempt++) {
$before=Save "$Name-before-$attempt"; $board=Board $before $Stage
if($null-eq$board){return $before}
# If the initial view has no safe pair, route upward before pitching further.
# Reproject the actual result within this attempt; the original bound stays sixteen.
for($projection=0;$projection-lt2;$projection++){
$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-or($SourceCount-gt0-and$source.ballIds.Count-ne$SourceCount)-or($TargetCount-gt0-and$target.ballIds.Count-ne$TargetCount)){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=$null
$offset=if($attempt%2-eq0){-35}else{35}
foreach($candidate in $pairs){
if($candidate.depth-ge.9-or!(BallClear $board $candidate.sid)){continue}
$targetPoint=BallPoint $board $candidate.tid
if(WorldSafe @(($targetPoint[0]+$offset),$targetPoint[1])){$pair=$candidate;break}
}
if(!$pair-and$projection-eq0-and$board.camera.distance-lt15){
# Keep a visited contact depth plane when its endpoint is outside the UI-safe view.
$needsFrame=@($pairs|Where-Object {$point=BallPoint $board $_.tid;$_.depth-lt.9-and(BallClear $board $_.sid)-and!(WorldSafe @(($point[0]+$offset),$point[1]))}).Count-gt0
if($needsFrame){$board=ZoomOut $Stage $board;continue}
}
if($pair-or$attempt-ne0-or$projection-ne0){break}
$routed=LiftMergeRoute $Stage "$Name-upward-route" $board $SourceCount $TargetCount
if(!$routed){break}
$routedBoard=Board $routed.State $Stage
if($routed.Outcome-eq'MissedGrabOrbit'){
Check "$Name-actual-first-hit-miss-only" (IsNativeOrbitMiss $routed.BeforeState $routed.State $Stage) 'Actual board and all independent records unchanged; only the expected clamped UP120 manual orbit occurred'
$unchanged=$routed.State|ConvertTo-Json -Depth 20|ConvertFrom-Json
(Board $unchanged $Stage).camera=(Board $before $Stage).camera
Check "$Name-miss-global-records-unmodified" (Same $before $unchanged) 'Reveal and missed pointer input preserve the whole original save except this stage manual view'
$board=$routedBoard;continue
}
if(!$routedBoard-or$routedBoard.groups.Count-lt$routed.Before.groups.Count){
$actual=if($routedBoard){$routedBoard}else{$routed.State.latestFreePlay}
if(!$routedBoard){Check "$Name-route-completed-stage" ($routed.State.completedStageIds-contains$Stage-and$actual.sourceStageId-eq$Stage) 'A missing puzzle requires actual completion and its own free-play board'}
AssertNativeMerge "$Name-route-actual" $routed.Before $actual $routed.SourceId $TargetCount
return $routed.State
}
$board=$routedBoard
}
$oldCount=$board.groups.Count
if($pair) {
$end=BallPoint $board $pair.tid
$dragBefore=Save "$Name-drag-$attempt-before"
Check "$Name-current-pair-projection-$attempt" (Same $board (Board $dragBefore $Stage)) 'Actual pair IDs and UI-safe endpoints use the current saved board'
# Try a short lateral contact offset, then orbit to expose another depth plane if blocked.
$after=NativeMoveAndHold "$Name-drag-$attempt" $board $pair.sid @(($end[0]+$offset),$end[1]);$newBoard=Board $after $Stage
if($null-eq$newBoard-or$newBoard.groups.Count-lt$oldCount){
$actual=if($newBoard){$newBoard}else{$after.latestFreePlay}
AssertNativeMerge "$Name-actual-$attempt" $board $actual $pair.sid $TargetCount
return $after
}
if(Same $board $newBoard){
Check "$Name-blocked-drop-whole-save-$attempt" (Same $dragBefore $after) 'An unchanged drop preserves every independent record before a real upward retry'
$routed=LiftMergeRoute $Stage "$Name-blocked-route-$attempt" $newBoard $SourceCount $TargetCount
if($routed){
$revealed=$newBoard|ConvertTo-Json -Depth 20|ConvertFrom-Json;$revealed.camera=$routed.Before.camera
Check "$Name-blocked-route-reveal-board-$attempt" (Same $revealed $routed.Before) 'Revealing the intended ID changes only its manual view'
$routedBoard=Board $routed.State $Stage
$expected=$after|ConvertTo-Json -Depth 20|ConvertFrom-Json
if($routedBoard){$expected.puzzleStates=@($expected.puzzleStates|ForEach-Object{if($_.stageId-eq$Stage){$routedBoard}else{$_}})}
else{
Check "$Name-blocked-route-completed-stage-$attempt" ($routed.State.completedStageIds-contains$Stage-and$routed.State.latestFreePlay.sourceStageId-eq$Stage) 'A missing puzzle requires actual completion and its own free-play board'
$expected.puzzleStates=@($expected.puzzleStates|Where-Object stageId -ne $Stage)
$expected.completedStageIds=@($expected.completedStageIds+@($Stage)|Sort-Object -Unique)
$expected.latestFreePlay=$routed.State.latestFreePlay
}
Check "$Name-blocked-route-independent-records-$attempt" (Same $expected $routed.State) 'Only the actual current board and its own strict completion may change'
if($routedBoard){Check "$Name-blocked-route-not-counted-$attempt" (Same $routed.Before.countedBallIds $routedBoard.countedBallIds) 'An upward retry never counts a friend'}
if($routed.Outcome-eq'MissedGrabOrbit'){
Check "$Name-blocked-route-first-hit-miss-$attempt" (IsNativeOrbitMiss $routed.BeforeState $routed.State $Stage) 'A background orbit is never a grabbed route or merge'
}elseif(!$routedBoard-or$routedBoard.groups.Count-lt$routed.Before.groups.Count){
$actual=if($routedBoard){$routedBoard}else{$routed.State.latestFreePlay}
AssertNativeMerge "$Name-blocked-route-actual-$attempt" $routed.Before $actual $routed.SourceId $TargetCount
return $routed.State
}
$newBoard=$routedBoard
}
}
$board=$newBoard
}
$fineYaw=0
if(!$pair){
# A coarse orbit can skip the narrow depth window of an otherwise safe pair.
# Choose a small improving yaw, then read the actual projection next attempt.
foreach($candidate in $pairs){
if($candidate.depth-lt.9-or$candidate.depth-gt1.2-or!(BallClear $board $candidate.sid)){continue}
$target=BallPoint $board $candidate.tid
if(!(WorldSafe @(($target[0]+$offset),$target[1]))){continue}
$s=@($board.balls|Where-Object id -eq $candidate.sid)[0].position;$t=@($board.balls|Where-Object id -eq $candidate.tid)[0].position
$separation=[Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z)
$best=$candidate.depth
foreach($pixels in @(-8,8)){
$predicted=[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitY,($pixels*.25*[Math]::PI/180))*$rotation
$depth=[Math]::Abs([Numerics.Vector3]::Transform($separation,[Numerics.Quaternion]::Inverse($predicted)).Z)
if($depth-lt$best){$best=$depth;$fineYaw=$pixels}
}
if($fineYaw){break}
}
}
if($fineYaw){
Orbit "$Name-fine-orbit-$attempt" $board -YawPixels $fineYaw
$view=Board (Save "$Name-fine-orbit-$attempt-state") $Stage
Check "$Name-fine-orbit-preserves-board-$attempt" ($null-ne$view-and(Same $board.balls $view.balls)-and(Same $board.groups $view.groups)-and(Same $board.collectedBallIds $view.collectedBallIds)-and(Same $board.countedBallIds $view.countedBallIds)-and!(Same $board.camera $view.camera)) 'Actual manual pose changed; no ball, membership, collection or count edits'
}else{Orbit "$Name-orbit-$attempt" $board -Pitch:($attempt%4-eq3)}
}
throw "Native merge bound reached for Stage $Stage; all actual attempts retained."
}
function SplitReal([int]$Stage,[int]$Selected,[string]$Name,[string]$OnlyId='') {
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($OnlyId){$OnlyId}elseif($Stage-eq13-and$Selected-eq2){$group.ballIds[0];$group.ballIds[-1]}else{$group.ballIds|Select-Object -First $Selected})
$mode=$false
foreach($chooseId in @($ids[-1])+@($group.ballIds)){
$board=RevealBall $Stage $chooseId "$Name-choose-reveal-$attempt-$chooseId"
TapBall "$Name-choose-$attempt-$chooseId" $board $chooseId
$modeName="$Name-mode-$attempt-$chooseId"
Step $modeName @('shell','input','tap','312','832')
if(SplitModeVisible (Join-Path $EvidenceDir "$modeName.png")){$mode=$true;break}
}
if(!$mode){Orbit "$Name-reveal-group-$attempt" $board -Pitch;continue}
foreach($id in $ids){$board=RevealBall $Stage $id "$Name-select-reveal-$attempt-$id";TapBall "$Name-select-$attempt-$id" $board $id}
$board=RevealBall $Stage $ids[-1] "$Name-drag-reveal-$attempt"
$point=BallPoint $board $ids[-1]
$end=@($point[0],($point[1]-160))
if(!(WorldSafe $end)){$end=@($point[0],($point[1]+160))}
$after=NativeMoveAndHold "$Name-drag-$attempt" $board $ids[-1] $end;$actual=Board $after $Stage
if($null-eq$actual){$actual=$after.latestFreePlay}
if($actual.groups.Count-gt$board.groups.Count){
Check "$Name-split-original-ids-$attempt" (Same @($board.balls.id|Sort-Object) @($actual.balls.id|Sort-Object)) 'Actual split retains all original identities exactly'
Check "$Name-split-manual-camera-$attempt" (Same $board.camera $actual.camera) 'Split assistance preserves the exact saved manual pose'
if(@($actual.groups|Where-Object{(($_.ballIds|Sort-Object)-join',')-eq(($ids|Sort-Object)-join',')}).Count-ne1){
$commands.Add(@{command='Actual split selected IDs differ: preserve requested and actual memberships';expected=$ids;actualGroups=$actual.groups;utc=[DateTime]::UtcNow.ToString('o')})
if($null-eq(Board $after $Stage)){
Check "$Name-valid-actual-completed-partition-$attempt" ($after.completedStageIds-contains$Stage-and$actual.sourceStageId-eq$Stage-and(Counts $actual)-eq$targets[$Stage]-and((($actual.balls.id|Sort-Object)-join',')-eq(($board.balls.id|Sort-Object)-join','))) 'Real UI selected a different valid subset; actual goal and all IDs conserved. Requested-ID equality is not claimed.'
return $after
}
MergeReal $Stage "$Name-remerge-$attempt"|Out-Null
Orbit "$Name-retry-view-$attempt" (Board (Save "$Name-retry-state-$attempt") $Stage) -Pitch:($attempt%2-eq1)
continue
}
Check "$Name-exact-selected-ids-$attempt" (@($actual.groups|Where-Object{(($_.ballIds|Sort-Object)-join',')-eq(($ids|Sort-Object)-join',')}).Count-eq1) 'Native split group is exactly the tapped selection, not an occluded substitute'
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" $actual -Pitch:($attempt%2-eq1)
}
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(BallClear $board $ball.id){$id=$ball.id;break}}
if(!$id){
$rotation=[Numerics.Quaternion]::new($board.camera.rotation.x,$board.camera.rotation.y,$board.camera.rotation.z,$board.camera.rotation.w)
# Local pitch reverses its elevation effect after crossing the vertical pole.
$pitchPixels=if([Numerics.Vector3]::Transform([Numerics.Vector3]::UnitY,$rotation).Y-ge0){-120}else{120}
if([Numerics.Vector3]::Transform(-[Numerics.Vector3]::UnitZ,$rotation).Y-lt.5){$pitchPixels=-$pitchPixels}
Orbit "stage-$Stage-count-safe-view-$attempt" $board -Pitch:($attempt%4-eq3) -PitchPixels $pitchPixels
$view=Save "stage-$Stage-count-view-after-$attempt"
$unchanged=$view|ConvertTo-Json -Depth 20|ConvertFrom-Json
$viewBoard=Board $unchanged $Stage
if($viewBoard){$viewBoard.camera=$board.camera}
Check "stage-$Stage-count-view-preserves-whole-save-$attempt" (Same $state $unchanged) 'Only the actual manual view may change while searching for an uncounted first hit'
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 -Pitch:($attempt%4-eq3)}
}
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
} elseif($stage-eq29-or$stage-eq30) {
# 5+3 -> 4+1+3 -> 4+4; 6+4 -> 5+1+4 -> 5+5, using only normal group controls.
$other=if($stage-eq29){3}else{4}
for($pairAttempt=0;$pairAttempt-lt4;$pairAttempt++){
$pairBoard=Board (Save "stage-$stage-pair-$pairAttempt") $stage
if(!$pairBoard){break}
$large=@($pairBoard.groups|Sort-Object {$_.ballIds.Count} -Descending)[0]
SplitReal $stage 1 "stage-$stage-take-one-$pairAttempt" $large.ballIds[-1]|Out-Null
MergeReal $stage "stage-$stage-one-plus-$other-$pairAttempt" 1 $other|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=$recorder.nativeProcess
if ($native -and !$native.HasExited) { $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=$recorder.nativeProcess
if ($native -and !$native.HasExited -and !$native.WaitForExit(10000)) { throw 'Own native recorder did not terminate' }
$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