Files
MagnetismHeroes/tools/android/exercise-persistence.ps1
T

214 lines
25 KiB
PowerShell

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'}
$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,$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))
$scale=540/([Math]::Tan(23*[Math]::PI/180)*$local.Z)
return @([int][Math]::Round(1200+$local.X*$scale),[int][Math]::Round(540-$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
Step 'two-merge-collect' @('shell','input','swipe','1009','510','1291','510','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
Step 'six-real-move-unfinished' @('shell','input','swipe','1009','510','930','560','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