param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554') $ErrorActionPreference='Stop' $EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path $adb=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe' $package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity" $remote="/sdcard/Android/data/$package/files/progress.json" $checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new() $result=[ordered]@{milestone='Persistence';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash;status='failed'} $result.rendererProjection=@{fieldOfView=62;fixedWorldPivot=@(0,.65,0);rawCamera='Saved Board.camera JSON remains canonical';viewport='Landscape wm size max/min; remaining UI and physical touch helper assume sole 2400x1080 AVD'} $physicalInput=$false function Adb([string[]]$Arguments) { $output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE $commands.Add(@{command=('adb -s '+$Serial+' '+($Arguments -join ' '));exitCode=$code;output=($output -join "`n")}) if ($code -ne 0) { throw "ADB exit $code : $($Arguments -join ' ')" }; return ($output -join "`n").Trim() } function Check([string]$Id,[bool]$Passed,[string]$Actual) { $checks.Add(@{id=$Id;passed=$Passed;actual=$Actual}); if (!$Passed) { throw "Persistence check failed: $Id ($Actual)" } } function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) { & (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay if ($Arguments.Count -gt 1 -and $Arguments[1] -eq 'input') { $script:physicalInput=$false } } function Save([string]$Name) { Adb @('pull',$remote,(Join-Path $EvidenceDir "$Name.json")) | Out-Null; return Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json } function Same($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) } function Restart([string]$Name) { Adb @('shell','input','keyevent','3') | Out-Null; Start-Sleep -Milliseconds 700 Adb @('shell','am','force-stop',$package) | Out-Null Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500 } function Touch([int[]]$Contacts) { if (!$script:physicalInput) { & (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts @(30,400,999,100) Start-Sleep -Milliseconds 100 & (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts @(30,400,999,0) Start-Sleep -Milliseconds 100; $script:physicalInput=$true $commands.Add(@{command='EmulatorController.sendTouch warmup 30,400,999 down/up';exitCode=0}) } & (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts $Contacts $commands.Add(@{command=('EmulatorController.sendTouch contacts='+($Contacts -join ','));exitCode=0}) } function BallPoint($Board,[string]$Id) { $ball=@($Board.balls | Where-Object id -eq $Id)[0]; $camera=$Board.camera $rotation=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w) $origin=[Numerics.Vector3]::new($camera.target.x,($camera.target.y+.65),$camera.target.z)+[Numerics.Vector3]::Transform([Numerics.Vector3]::new(0,0,-$camera.distance),$rotation) $local=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin,[Numerics.Quaternion]::Inverse($rotation)) $size=Adb @('shell','wm','size'); if ($size -notmatch '(\d+)x(\d+)') { throw "Unknown device resolution: $size" } $width=[Math]::Max([int]$Matches[1],[int]$Matches[2]); $height=[Math]::Min([int]$Matches[1],[int]$Matches[2]) $scale=($height/2)/([Math]::Tan(31*[Math]::PI/180)*$local.Z) return @([int][Math]::Round(($width/2)+$local.X*$scale),[int][Math]::Round(($height/2)-$local.Y*$scale)) } function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id; Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") } function SelectStage([string]$Name,[int]$Id) { Step "$Name-selection" @('shell','input','tap','1200','616') $point=switch ($Id) { 1 {@(1200,562)} 2 {@(1200,691)} 6 {@(480,562)} 14 {@(1200,389)} default {throw 'Unsupported test stage'} } Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])") } function RecordAudio([string]$Name,[int]$Seconds) { $arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds") $process=Start-Process pwsh -ArgumentList ($arguments | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru Start-Sleep -Seconds 2; return $process } try { 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 -eq 0 -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 Adb @('wait-for-device') | Out-Null Check 'device-boot-completed' ((Adb @('shell','getprop','sys.boot_completed')) -eq '1') 'Actual emulator boot completed before install' Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | Out-Null $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'))} Step 'initial-home' @('shell','am','start','-W','-n',$activity) 6500 Step 'initial-selection' @('shell','input','tap','1200','616'); Step 'initial-locked-two' @('shell','input','tap','1200','691') $locked=Save 'locked-two'; Check 'new-save-only-one-unlocked' ($locked.puzzleStates.Count -eq 0 -and $locked.completedStageIds.Count -eq 0) 'Stage02 tap creates no puzzle before Stage01 completion' Step 'one-start' @('shell','input','tap','1200','562'); $oneInitial=Save 'one-initial' $onePoint=BallPoint $oneInitial.puzzleStates[0] $oneInitial.puzzleStates[0].balls[0].id Step 'one-collect' @('shell','input','swipe',"$($onePoint[0])","$($onePoint[1])","$($onePoint[0]+80)","$($onePoint[1])",'650') $one=Save 'one-collected'; TapBall 'one-count-success' $one.puzzleStates[0] $one.puzzleStates[0].balls[0].id $free=Save 'one-free'; Check 'one-completion-atomic' ($free.completedStageIds.Count -eq 1 -and $free.completedStageIds[0] -eq 1 -and $free.puzzleStates.Count -eq 0 -and $free.latestFreePlay.sourceStageId -eq 1) 'Completed1, removed puzzle1, latestFree1 in one file' Step 'one-home' @('shell','input','tap','312','108'); SelectStage 'two' 2 $twoInitial=Save 'two-initial'; $board=@($twoInitial.puzzleStates | Where-Object stageId -eq 2)[0] $start=BallPoint $board $board.groups[0].ballIds[0]; $end=BallPoint $board $board.groups[1].ballIds[0] Step 'two-merge-collect' @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]-45)","$($end[1])",'650') $two=Save 'two-collected'; $board=@($two.puzzleStates | Where-Object stageId -eq 2)[0] Check 'two-real-collection' ($board.groups.Count -eq 1 -and $board.collectedBallIds.Count -eq 2 -and $board.countedBallIds.Count -eq 0) 'Actual confirmed drag merged both balls and collected both' TapBall 'two-count-one-of-two' $board $board.balls[0].id Step 'two-orbit' @('shell','input','swipe','2100','440','2180','480','650') Touch @(1700,400,501,100,1900,400,502,100); Start-Sleep -Milliseconds 150 Touch @(1700,400,501,100,1920,400,502,100); Start-Sleep -Milliseconds 150 Touch @(1700,400,501,0,1920,400,502,0); Step 'two-camera-confirmed' $partial=Save 'two-partial-before-pause'; $partialBoard=@($partial.puzzleStates | Where-Object stageId -eq 2)[0] Check 'positive-partial-count-real-tap' ($partialBoard.countedBallIds.Count -eq 1 -and $partialBoard.balls.Count -eq 2 -and $partialBoard.collectedBallIds.Count -eq 2 -and $partial.completedStageIds -notcontains 2) 'Actual tap saved exactly1/2 unfinished' Check 'nonidentity-camera-and-zoom' ([Math]::Abs($partialBoard.camera.rotation.w-1) -gt .001 -and [Math]::Abs($partialBoard.camera.distance-8) -gt .01) ($partialBoard.camera | ConvertTo-Json -Compress) Restart 'two-pause-force-stop'; $restoreAudio=RecordAudio 'audio-partial-restore' 8; SelectStage 'two-restored' 2 $restored=Save 'two-partial-after-restart'; Check 'positive-partial-count-camera-restored' (Same $partialBoard @($restored.puzzleStates | Where-Object stageId -eq 2)[0]) 'IDs positions membership collected counted target rotation distance all match' Check 'restore-audio-recorder-finished' ($restoreAudio.WaitForExit(30000)) 'Actual emulator PCM capture finished' $restoreAudio.Refresh(); Check 'restore-audio-recorder-exit' ($restoreAudio.ExitCode -eq 0) "$($restoreAudio.ExitCode)" $wave=[IO.File]::ReadAllBytes((Join-Path $EvidenceDir 'audio-partial-restore.wav')); $sum=0.0; $samples=0 for ($index=44; $index+1 -lt $wave.Length; $index+=2) { $sample=[BitConverter]::ToInt16($wave,$index); $sum+=[double]$sample*$sample; $samples++ } $rms=if ($samples -gt 0) {[Math]::Sqrt($sum/$samples)} else {[double]::PositiveInfinity} Check 'restore-no-historical-count-or-guide-audio' ($samples -gt 0 -and $rms -lt 32) "Actual restore PCM rms=$rms samples=$samples" Step 'two-home' @('shell','input','tap','312','108'); Adb @('shell','am','force-stop',$package) | Out-Null # Test-only completion fixture opens representative6; it retains the real counted puzzle and real Free1. $fixture=Save 'before-unlock-fixture'; $fixture.completedStageIds=@(1..5) $fixturePath=Join-Path $EvidenceDir 'fixture-completion-access-six.json'; $fixture | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixturePath -Encoding utf8 Adb @('push',$fixturePath,$remote) | Out-Null; Step 'six-access-home' @('shell','am','start','-W','-n',$activity) 6500; SelectStage 'six' 6 $sixInitial=Save 'six-initial'; $board=@($sixInitial.puzzleStates | Where-Object stageId -eq 6)[0]; $point=BallPoint $board $board.groups[0].ballIds[0] Step 'six-real-move-unfinished' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]-55)","$($point[1]+35)",'650') $six=Save 'two-unfinished-before'; Check 'two-independent-unfinished' ($six.puzzleStates.Count -eq 2 -and (Same $partialBoard @($six.puzzleStates | Where-Object stageId -eq 2)[0]) -and @($six.puzzleStates | Where-Object stageId -eq 6)[0].groups.Count -eq 2) 'Stage02 partially counted and Stage06 actually moved coexist' Step 'six-home' @('shell','input','tap','312','108'); Step 'free-continue' @('shell','input','tap','1200','810') $freeBefore=Save 'free-before-move'; $point=BallPoint $freeBefore.latestFreePlay $freeBefore.latestFreePlay.balls[0].id Step 'free-actual-move' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+45)","$($point[1]+20)",'650') $freeMoved=Save 'free-moved-before'; Check 'free-independent-of-two-puzzles' ($freeMoved.puzzleStates.Count -eq 2 -and (Same $six.puzzleStates $freeMoved.puzzleStates) -and !(Same $freeBefore.latestFreePlay.balls $freeMoved.latestFreePlay.balls)) 'Only latestFree board positions changed' Restart 'three-boards-restart'; Step 'free-restored' @('shell','input','tap','1200','810') Check 'latest-free-whole-restore' (Same $freeMoved (Save 'free-restored-after')) 'Two unfinished plus latestFree all restore' Step 'free-home' @('shell','input','tap','312','108'); SelectStage 'two-second-restore' 2 Check 'two-second-independent-restore' (Same $partialBoard @((Save 'two-second-after').puzzleStates | Where-Object stageId -eq 2)[0]) 'Partial counting board remains unchanged through Free/six' Step 'two-second-home' @('shell','input','tap','312','108'); SelectStage 'six-second-restore' 6 Check 'six-second-independent-restore' (Same @($six.puzzleStates | Where-Object stageId -eq 6)[0] @((Save 'six-second-after').puzzleStates | Where-Object stageId -eq 6)[0]) 'Moved composition board restores independently' Step 'six-second-home' @('shell','input','tap','312','108'); Step 'free-supply-continue' @('shell','input','tap','1200','810') for ($index=1; $index -le 9; $index++) { Step "free-spawn-$index" @('shell','input','tap','2064','756') } $full=Save 'free-ten'; Check 'actual-free-cap-ten' ($full.latestFreePlay.balls.Count -eq 10 -and @($full.latestFreePlay.balls.id | Sort-Object -Unique).Count -eq 10) 'Ten unique actual spawned balls' Step 'free-eleventh-refused' @('shell','input','tap','2064','756'); Check 'ten-add-refused-no-duplicate' (Same $full (Save 'free-cap-after')) 'Extra tap leaves exact saved file state' # Merge two actual singleton friends, then return the whole selected friend. $first=$full.latestFreePlay.balls[0].id; $second=$full.latestFreePlay.balls[1].id $start=BallPoint $full.latestFreePlay $first; $end=BallPoint $full.latestFreePlay $second Step 'free-merge-two' @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]-70)","$($end[1])",'650') $merged=Save 'free-merged'; $whole=@($merged.latestFreePlay.groups | Where-Object {$_.ballIds.Count -gt 1})[0] Check 'actual-free-merge-for-whole-return' ($null -ne $whole) 'Real merge created a multi-ball friend' TapBall 'free-select-whole-friend' $merged.latestFreePlay $whole.ballIds[0]; Step 'free-whole-return' @('shell','input','tap','2064','864') $returned=Save 'free-whole-return-after'; Check 'whole-friend-return' ($returned.latestFreePlay.balls.Count -eq 10-$whole.ballIds.Count -and @($returned.latestFreePlay.balls | Where-Object {$whole.ballIds -contains $_.id}).Count -eq 0) 'Every selected friend member returned together' Step 'free-resupply-after-return' @('shell','input','tap','2064','756'); $supply=Save 'free-resupplied' Check 'resupply-after-return' ($supply.latestFreePlay.balls.Count -eq $returned.latestFreePlay.balls.Count+1) 'Capacity available again after return' while ($supply.latestFreePlay.balls.Count -gt 0) { TapBall "empty-select-$($supply.latestFreePlay.balls.Count)" $supply.latestFreePlay $supply.latestFreePlay.balls[0].id $beforeCount=$supply.latestFreePlay.balls.Count Step "empty-return-$beforeCount" @('shell','input','tap','2064','864'); $supply=Save "empty-after-$beforeCount" Check "empty-return-progress-$beforeCount" ($supply.latestFreePlay.balls.Count -lt $beforeCount) 'Selected friend actually returned' } Check 'empty-free-board-valid' ($supply.latestFreePlay.groups.Count -eq 0 -and $supply.completedStageIds -contains 1 -and $supply.puzzleStates.Count -eq 2) 'Empty board keeps completion and both unfinished' Step 'empty-respawn' @('shell','input','tap','2064','756'); $respawn=Save 'empty-respawn-after'; Check 'empty-respawn-one' ($respawn.latestFreePlay.balls.Count -eq 1) 'Supply works from empty board' # A directory at the temporary-file path causes a real FileStream failure, without touching valid primary/backup. $validBefore=Adb @('shell','sha256sum',$remote); Adb @('shell','mkdir',($remote+'.tmp')) | Out-Null $point=BallPoint $respawn.latestFreePlay $respawn.latestFreePlay.balls[0].id Step 'failed-write-visible-play-notice' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+35)","$($point[1])",'650') Check 'failed-write-primary-bytes-preserved' ((Adb @('shell','sha256sum',$remote)) -eq $validBefore) 'Real write failure preserves primary SHA256' Step 'failed-camera-visible-notice' @('shell','input','swipe','2100','440','2170','475','650') Adb @('shell','input','keyevent','3') | Out-Null; Start-Sleep -Milliseconds 700 Step 'failed-pause-resume-notice' @('shell','am','start','-W','-n',$activity) 1500 Check 'failed-pause-primary-bytes-preserved' ((Adb @('shell','sha256sum',$remote)) -eq $validBefore) 'Camera and pause failures preserve same SHA256' Adb @('shell','rmdir',($remote+'.tmp')) | Out-Null Step 'save-recovered-notice-cleared' @('shell','input','tap','312','108'); $recovered=Save 'write-recovered' Check 'write-recovery-saves-current-whole-state' (!(Same $respawn.latestFreePlay $recovered.latestFreePlay) -and (Same $respawn.puzzleStates $recovered.puzzleStates)) 'Recovered write includes committed free move/camera, preserves both puzzles' Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8 $runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log' # Missing primary must restore the whole backup, including both unfinished boards and latest Free. Adb @('shell','am','force-stop',$package) | Out-Null $missingExpected=Save 'missing-primary-valid-backup-before' Check 'missing-primary-fixture-full-state' ($missingExpected.puzzleStates.Count -eq 2 -and $null -ne $missingExpected.latestFreePlay -and @($missingExpected.puzzleStates | Where-Object stageId -eq 2)[0].countedBallIds.Count -eq 1) 'Actual two unfinished boards, positive partial count and latest Free retained' Adb @('push',(Join-Path $EvidenceDir 'missing-primary-valid-backup-before.json'),($remote+'.bak')) | Out-Null Adb @('shell','rm',$remote) | Out-Null Step 'missing-primary-valid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500 $missingLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime') $missingLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-valid-load.log') -Encoding utf8 Check 'missing-primary-valid-recovery-logged' ($missingLoadLog -match '\[ProgressLoad\] Recovered valid backup:') 'Backup-only recovery reaches existing recovery notice branch' Step 'missing-primary-valid-backup-free' @('shell','input','tap','1200','810') Check 'missing-primary-valid-backup-whole-restore' (Same $missingExpected (Save 'missing-primary-valid-backup-after')) 'Every saved field restores from backup alone' Step 'missing-primary-valid-backup-home' @('shell','input','tap','312','108') SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0] $uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0] $countAudio=RecordAudio 'audio-stage-two-final-count' 8 TapBall 'two-final-count-success' $secondBoard $uncounted.id $twoComplete=Save 'two-final-count-completed'; Check 'two-latest-free-replaces-one-preserves-six' ($twoComplete.latestFreePlay.sourceStageId -eq 2 -and $twoComplete.latestFreePlay.balls.Count -eq 2 -and $twoComplete.puzzleStates.Count -eq 1 -and $twoComplete.puzzleStates[0].stageId -eq 6 -and $twoComplete.completedStageIds -contains 1) 'Second actual count transitions to Free2 while retaining unfinished6 and completion1' TapBall 'two-free-select-during-final-number' $twoComplete.latestFreePlay $twoComplete.latestFreePlay.balls[0].id Step 'two-free-split-during-final-number' @('shell','input','tap','312','832'); Step 'two-free-cancel-during-final-number' @('shell','input','tap','312','832') Check 'count-two-audio-recorder-finished' ($countAudio.WaitForExit(30000)) 'Actual final count PCM capture finished'; $countAudio.Refresh() Check 'count-two-audio-recorder-exit' ($countAudio.ExitCode -eq 0) "$($countAudio.ExitCode)" $comparison=Join-Path $EvidenceDir 'audio-stage-two-count02-match.json' & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_02.wav' -CapturedPath (Join-Path $EvidenceDir 'audio-stage-two-final-count.wav') -OutputPath $comparison $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json; Check 'actual-count-two-plays-through-free-split-cancel' ($LASTEXITCODE -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95) ($match | ConvertTo-Json -Compress) # Corrupt primary, restore complete valid backup, and preserve forensic bytes on next confirmed save. Adb @('shell','am','force-stop',$package) | Out-Null; Adb @('pull',($remote+'.bak'),(Join-Path $EvidenceDir 'valid-backup-before-damage.json')) | Out-Null $backup=Get-Content -LiteralPath (Join-Path $EvidenceDir 'valid-backup-before-damage.json') -Raw | ConvertFrom-Json $damagePath=Join-Path $EvidenceDir 'damaged-primary.txt'; [IO.File]::WriteAllText($damagePath,'damaged-primary-persistence') Adb @('push',$damagePath,$remote) | Out-Null; Step 'backup-recovery-home-notice' @('shell','am','start','-W','-n',$activity) 6500 Check 'load-does-not-overwrite-damaged-primary' ((Adb @('shell','cat',$remote)) -eq 'damaged-primary-persistence') 'Damaged original remains before any new save' Step 'backup-restored-free' @('shell','input','tap','1200','810'); Check 'backup-whole-state-restored' (Same $backup (Save 'backup-restored-all')) 'Entire valid backup restored without partial primary load' $forensic=Adb @('shell','ls',($remote+'.damaged-*')); $result.forensicFiles=$forensic Check 'damaged-primary-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-primary-persistence') $forensic Adb @('shell','am','force-stop',$package) | Out-Null [IO.File]::WriteAllText($damagePath,'damaged-both-primary'); Adb @('push',$damagePath,$remote) | Out-Null $badBackup=Join-Path $EvidenceDir 'damaged-backup.txt'; [IO.File]::WriteAllText($badBackup,'damaged-both-backup'); Adb @('push',$badBackup,($remote+'.bak')) | Out-Null Step 'both-damaged-initial-home-notice' @('shell','am','start','-W','-n',$activity) 6500 Check 'both-damaged-originals-preserved-on-load' ((Adb @('shell','cat',$remote)) -eq 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Both original byte strings survive failed load' Step 'both-damaged-new-selection' @('shell','input','tap','1200','616'); $initial=Save 'both-damaged-new-initial' Check 'both-damage-normal-initial' ($initial.completedStageIds.Count -eq 0 -and $initial.puzzleStates.Count -eq 0 -and $null -eq $initial.latestFreePlay) 'No valid save starts clean initial state' Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained' Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial' Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes' Adb @('shell','am','force-stop',$package) | Out-Null [IO.File]::WriteAllText($badBackup,'damaged-backup-only'); Adb @('push',$badBackup,($remote+'.bak')) | Out-Null Adb @('shell','rm',$remote) | Out-Null; Adb @('logcat','-c') | Out-Null Step 'missing-primary-invalid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500 Check 'missing-primary-invalid-original-preserved-on-load' ((Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-backup-only' -and (Adb @('shell','ls',($remote+'*'))) -notmatch '(?m)/progress\.json$') 'Invalid backup bytes remain and no primary is created by failed load' $invalidLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime') $invalidLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-invalid-load.log') -Encoding utf8 Check 'missing-primary-invalid-read-failure-logged' ($invalidLoadLog -match '\[ProgressLoad\] Invalid primary and backup:') 'Backup-only damage is recorded as read failure' Step 'missing-primary-invalid-new-selection' @('shell','input','tap','1200','616'); $missingInitial=Save 'missing-primary-invalid-first-save' Check 'missing-primary-invalid-normal-initial' ($missingInitial.completedStageIds.Count -eq 0 -and $missingInitial.puzzleStates.Count -eq 0 -and $missingInitial.seenTutorialIds.Count -eq 0 -and $null -eq $missingInitial.latestFreePlay) 'Invalid backup alone enters normal fresh progress' Step 'missing-primary-invalid-second-save' @('shell','input','tap','1200','562'); $missingSecond=Save 'missing-primary-invalid-second-save' Check 'missing-primary-invalid-two-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-backup-only' -and $missingSecond.puzzleStates.Count -eq 1) 'Distinct backup-only damage survives two successful saves' $result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain) Check 'clean-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 { $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-persistence-evidence.json') -Encoding utf8 } if ($result.status -ne 'passed') { exit 1 }; Write-Host "Persistence actual Android checks passed: $($checks.Count). PNG visual review remains explicit."; exit 0