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

165 lines
16 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
$result.milestone='FinalBoundary'
function Touch([int[]]$Contacts) {
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts $Contacts
$commands.Add(@{command=('Native EmulatorController.sendTouch contacts='+($Contacts-join','));exitCode=0;utc=[DateTime]::UtcNow.ToString('o')})
}
function ReturnOne([string]$Name) {
for($attempt=0;$attempt-lt12;$attempt++) {
$before=Save "$Name-before-$attempt";$board=$before.latestFreePlay;$group=$board.groups[0]
$point=BallPoint $board $group.ballIds[0]
TapBall "$Name-select-$attempt" $board $group.ballIds[0]
Step "$Name-return-$attempt" @('shell','input','tap','2064','864')
$after=Save "$Name-after-$attempt"
if($after.latestFreePlay.balls.Count-lt$board.balls.Count){return @($before,$after)}
Step "$Name-orbit-$attempt" @('shell','input','swipe','2200','410','2320','410','350')
}
throw 'Whole-group return did not confirm through real native selection'
}
function CanceledSpeech([string]$Name,[string]$Id) {
$recorder=@($recorders|Where-Object name -eq $Name)[0].process
Check "cancel-recorder-$Name" ($recorder.WaitForExit(30000)) 'Actual recorder finished';$recorder.Refresh()
Check "cancel-recorder-exit-$Name" ($recorder.ExitCode-eq0) "$($recorder.ExitCode)"
$comparison=Join-Path $EvidenceDir "$Name-$Id-incomplete-match.json"
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath (Join-Path $projectRoot "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$Id.wav") -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison -RequireFullSpeech
$code=$LASTEXITCODE;$match=Get-Content $comparison -Raw|ConvertFrom-Json
Check "cancel-clip-not-completed-$Name-$Id" ($code-eq1-and!$match.passed) 'Actual native interrupted clip does not falsely pass complete-speech comparison'
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'check-canceled-audio.ps1') -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath (Join-Path $EvidenceDir "$Name-no-stale.json")
Check "cancel-positive-start-silent-home-tail-$Name" ($LASTEXITCODE-eq0) 'Actual PCM starts non-silent; final3seconds after navigation/pause/startup are silent'
}
try {
$result.scope='Separate native boundaries after real30 completion; deliberate corrupt-file tests labelled, no fabricated completion or board injection'
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw|ConvertFrom-Json
Check 'build-provenance' ($build.status-eq'passed'-and$build.sourceCommit-eq$result.sourceCommit-and$build.apkSha256-eq$result.apkSha256-and!$build.sourceDirty-and!$build.sourceDirtyEnd) $result.apkSha256
$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
Step 'boundary-free30-start' @() 0;$original=Save 'boundary-real30-state'
Check 'boundary-starts-real-all30-completed' ($original.completedStageIds.Count-eq30-and$original.latestFreePlay.sourceStageId-eq30-and$original.latestFreePlay.balls.Count-eq10) 'Consumes actual progression state, no fixture'
Step 'free10-refuse-eleventh' @('shell','input','tap','2064','756')
Check 'free10-eleventh-refused-no-mutation' (Same $original (Save 'free10-after-eleventh')) '10 balls exact save unchanged'
$returned=ReturnOne 'free30-whole5-return';$before=$returned[0];$after=$returned[1]
Check 'return-exact-entire5-group' ($before.latestFreePlay.balls.Count-eq10-and$after.latestFreePlay.balls.Count-eq5-and$after.latestFreePlay.groups.Count-eq1-and$after.latestFreePlay.groups[0].ballIds.Count-eq5) 'One selected5 friend returned, other5 remains'
for($spawn=0;$spawn-lt5;$spawn++){Step "free-refill-$spawn" @('shell','input','tap','2064','756')}
$ten=Save 'free-refilled10';Check 'free-supply-refills-up-to10' ($ten.latestFreePlay.balls.Count-eq10-and@($ten.latestFreePlay.balls.id|Sort-Object -Unique).Count-eq10) 'Five native supply buttons add five unique singleton units'
for($returnedCount=0;$returnedCount-lt12;$returnedCount++){$state=Save "free-empty-step-$returnedCount";if($state.latestFreePlay.balls.Count-eq0){break};ReturnOne "free-empty-return-$returnedCount"|Out-Null}
$empty=Save 'free-empty';Check 'free-empty-allowed' ($empty.latestFreePlay.balls.Count-eq0-and$empty.latestFreePlay.groups.Count-eq0) 'All whole groups natively returned, empty state accepted'
Step 'free-empty-refill-one' @('shell','input','tap','2064','756');$one=Save 'free-empty-refilled'
Check 'free-empty-refills-one' ($one.latestFreePlay.balls.Count-eq1-and$one.latestFreePlay.groups.Count-eq1-and(Same $one.completedStageIds $original.completedStageIds)-and(Same $one.puzzleStates $original.puzzleStates)) 'Refill changes only latest Free board, all records/puzzles unchanged'
Step 'cancel-guide-home' @('shell','input','tap','312','108');Step 'cancel-guide-selection' @('shell','input','tap','1200','616')
StartAudio 'audio-guide-navigation-cancel' 9
$stageThree=StagePoint 3;Step 'cancel-guide-enter3' @('shell','input','tap',"$($stageThree[0])","$($stageThree[1])") 450
Step 'cancel-guide-to-home' @('shell','input','tap','312','108') 3500
CanceledSpeech 'audio-guide-navigation-cancel' 'guide_collect'
SelectStage 'cancel-count-enter2' 2
$two=Save 'cancel-count-two-initial';$board=@($two.puzzleStates|Where-Object stageId -eq 2)[0]
$s=BallPoint $board $board.groups[0].ballIds[0];$t=BallPoint $board $board.groups[1].ballIds[0]
Step 'cancel-count-two-merge' @('shell','input','swipe',"$($s[0])","$($s[1])","$($t[0]-35)","$($t[1])",'500') 3000
$two=Save 'cancel-count-two-collected';$board=@($two.puzzleStates|Where-Object stageId -eq 2)[0]
Check 'cancel-count-real2-collected' ($board.groups.Count-eq1-and$board.collectedBallIds.Count-eq2) 'Actual replay2 collected before numeric cancellation'
$p=BallPoint $board $board.balls[0].id
StartAudio 'audio-numeric-pause-cancel' 12
Adb @('shell','input','tap',"$($p[0])","$($p[1])")|Out-Null
Start-Sleep -Milliseconds 130
Adb @('shell','input','keyevent','3')|Out-Null;Start-Sleep -Milliseconds 500;Adb @('shell','am','force-stop',$package)|Out-Null
StartHome 'numeric-cancel-restart-home'
CanceledSpeech 'audio-numeric-pause-cancel' 'count_01'
Step 'cancel-free-return' @('shell','input','tap','1200','810');$one=Save 'cancel-free-before-pinch'
# Genuine two-contact zoom, followed by out-of-window release cancellation of a friend drag.
Touch @(30,400,999,100);Touch @(30,400,999,0);Start-Sleep -Milliseconds 100
Touch @(1100,500,101,100,1400,500,102,100);Start-Sleep -Milliseconds 150
Touch @(900,500,101,100,1600,500,102,100);Start-Sleep -Milliseconds 150
Touch @(900,500,101,0,1600,500,102,0);Step 'free-native-pinch'
$pinch=Save 'free-pinch-state'
Check 'native-pinch-zoom-only' ((Same $one.latestFreePlay.balls $pinch.latestFreePlay.balls)-and(Same $one.latestFreePlay.groups $pinch.latestFreePlay.groups)-and$pinch.latestFreePlay.camera.distance-lt$one.latestFreePlay.camera.distance) 'Two physical contacts change zoom, no ball or group mutation'
Step 'free-pinch-home' @('shell','input','tap','312','108');SelectStage 'boundary13' 13
$thirteen=Save 'boundary13-initial';$board=@($thirteen.puzzleStates|Where-Object stageId -eq 13)[0];$point=BallPoint $board $board.groups[0].ballIds[0]
TapBall 'boundary13-choose' $board $board.groups[0].ballIds[0];Step 'boundary13-split-mode' @('shell','input','tap','312','832')
$confirmed=Save 'boundary13-split-confirmed'
Step 'boundary13-zero-selected-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]-190)","$($point[1])",'500')
Check 'split-zero-selection-unchanged' (Same $confirmed (Save 'boundary13-zero-after')) 'Invalid zero selection never changes confirmed state'
foreach($id in $board.groups[0].ballIds){TapBall "boundary13-all-$id" $board $id}
Step 'boundary13-all-selected-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]-190)","$($point[1])",'500')
Check 'split-all-selection-unchanged' (Same $confirmed (Save 'boundary13-all-after')) 'Invalid whole friend selection never changes confirmed state'
Step 'boundary13-cancel-all' @('shell','input','tap','312','832');TapBall 'boundary13-select-again' $board $board.groups[0].ballIds[0];Step 'boundary13-split-again' @('shell','input','tap','312','832');TapBall 'boundary13-one-selected' $board $board.groups[0].ballIds[0]
Step 'boundary13-offscreen-release' @('shell','input','swipe',"$($point[0])","$($point[1])",'2500',"$($point[1])",'500')
Check 'split-offscreen-cancel-unchanged' (Same $confirmed (Save 'boundary13-offscreen-after')) 'Native out-of-window release cancels selection and restores last confirmed board'
TapBall 'boundary13-disconnected-choose' $board $board.groups[0].ballIds[0];Step 'boundary13-disconnected-mode' @('shell','input','tap','312','832')
foreach($id in @($board.groups[0].ballIds[0],$board.groups[0].ballIds[-1])){TapBall "boundary13-disconnected-$id" $board $id}
Step 'boundary13-disconnected-split' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0])","$($point[1]-160)",'500')
$split=Save 'boundary13-disconnected-success'
Check 'disconnected-two-exact3-plus2' ($split.latestFreePlay.sourceStageId-eq13-and(($split.latestFreePlay.groups|ForEach-Object {$_.ballIds.Count}|Sort-Object)-join',')-eq'2,3'-and(Same $split.completedStageIds $original.completedStageIds)) 'Native first and last of5 selected; splits into exact2 and3 without changing old completion'
Step 'native13-free-rotated-numerals' @('shell','input','swipe','2200','410','2320','445','500');Save 'native13-free-rotated'|Out-Null
# Deliberate external corruption is a separate resilience case, after all native progression records.
Adb @('shell','input','keyevent','3')|Out-Null;Start-Sleep -Milliseconds 700;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 (Join-Path $EvidenceDir 'valid-backup-before-damage.json') -Raw|ConvertFrom-Json
$damage=Join-Path $EvidenceDir 'damaged-primary.txt';[IO.File]::WriteAllText($damage,'T8-final-deliberate-primary-damage')
Adb @('push',$damage,$remote)|Out-Null
Step 'valid-backup-recovery-home' @('shell','am','start','-W','-n',$activity) 6500
Check 'corrupt-original-retained-on-load' ((Adb @('shell','cat',$remote))-eq'T8-final-deliberate-primary-damage') 'Whole corrupt original remains before next save'
Step 'valid-backup-free-restore' @('shell','input','tap','1200','810')
Check 'valid-backup-whole-state-restored' (Same $backup (Save 'backup-restored-state')) 'No partial load; entire valid backup selected'
Check 'corrupt-original-forensics-preserved' ((Adb @('shell','cat',($remote+'.damaged-*')))-match'T8-final-deliberate-primary-damage') 'New save retains damaged original bytes in forensic file'
Adb @('shell','am','force-stop',$package)|Out-Null
[IO.File]::WriteAllText($damage,'T8-final-damaged-primary');Adb @('push',$damage,$remote)|Out-Null
$bad=Join-Path $EvidenceDir 'damaged-backup.txt';[IO.File]::WriteAllText($bad,'T8-final-damaged-backup');Adb @('push',$bad,($remote+'.bak'))|Out-Null
Step 'both-damaged-home' @('shell','am','start','-W','-n',$activity) 6500
Check 'both-corrupt-originals-preserved' ((Adb @('shell','cat',$remote))-eq'T8-final-damaged-primary'-and(Adb @('shell','cat',($remote+'.bak')))-eq'T8-final-damaged-backup') 'Both failed originals remain before new save'
Step 'both-damaged-new-selection' @('shell','input','tap','1200','616');$initial=Save 'both-damaged-initial'
Check 'both-damaged-valid-initial' ($initial.completedStageIds.Count-eq0-and$initial.puzzleStates.Count-eq0-and$null-eq$initial.latestFreePlay) 'No valid backup starts normal initial state'
$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-boundary-evidence.json') -Encoding utf8
}
if ($result.status -ne 'passed') { exit 1 }
Write-Host "Final native boundary checks passed: $($checks.Count) checks."
exit 0