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(); $recorders=[Collections.Generic.List[object]]::new() $result=[ordered]@{milestone='Content';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'} $networkBefore=$null; $musicBefore=$null function Adb([string[]]$Arguments) { $started=[DateTime]::UtcNow.ToString('o'); $output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE $commands.Add(@{startedUtc=$started;finishedUtc=[DateTime]::UtcNow.ToString('o');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 "Content 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 } 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 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 MergeStage([string]$Name,[int]$Id) { $save=Save "$Name-before"; $board=@($save.puzzleStates | Where-Object stageId -eq $Id)[0] $start=BallPoint $board $board.groups[0].ballIds[0]; $end=BallPoint $board $board.groups[1].ballIds[0] Step $Name @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]-45)","$($end[1])",'650') } function StagePoint([int]$Id) { $index=$Id-1; return @([int][Math]::Round((.12+($index%6)*.152)*2400),[int][Math]::Round((1-(.76-[Math]::Floor($index/6)*.123))*1080)) } function SelectStage([string]$Name,[int]$Id) { Step "$Name-selection" @('shell','input','tap','1200','616') $point=StagePoint $Id; Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])") } function StartHome([string]$Name) { Adb @('shell','input','keyevent','224') | Out-Null Adb @('shell','am','start','-W','-n',$activity) | Out-Null $observations=[Collections.Generic.List[object]]::new() $ready=$false; $pauseRequested=$false Add-Type -AssemblyName System.Drawing for($attempt=0;$attempt-lt30;$attempt++) { $pidText=Adb @('shell','pidof',$package) $saveText=Adb @('shell',('if test -s '+$remote+'; then cat '+$remote+'; fi')) $validSave=$false try { $state=$saveText|ConvertFrom-Json -ErrorAction Stop; $validSave=$state.schemaVersion-eq1 -and $null-ne$state.completedStageIds -and $null-ne$state.puzzleStates } catch { } Step "$Name-ready-$attempt" @() 1000 $png=Join-Path $EvidenceDir "$Name-ready-$attempt.png" $image=[Drawing.Bitmap]::new($png) $sky=0;$gold=0;$dark=0 try { for($y=1;$y-lt20;$y++){for($x=1;$x-lt30;$x++) { $pixel=$image.GetPixel([int]($image.Width*$x/30),[int]($image.Height*$y/20)) if($pixel.R-ge135-and$pixel.R-le163-and$pixel.G-ge197-and$pixel.G-le225-and$pixel.B-ge210-and$pixel.B-le239){$sky++} if($pixel.R-ge200-and$pixel.G-ge130-and$pixel.B-le120){$gold++} if($y-le6-and$pixel.R-lt110-and$pixel.G-lt110-and$pixel.B-lt110){$dark++} }} $dark=0 for ($y=[int]($image.Height*.17); $y -lt [int]($image.Height*.28); $y+=4) { for ($x=[int]($image.Width*.39); $x -lt [int]($image.Width*.61); $x+=4) { $pixel=$image.GetPixel($x,$y) if ($pixel.R -lt 110 -and $pixel.G -lt 110 -and $pixel.B -lt 110) { $dark++ } } } $homeButton=0; foreach($px in @(.36,.64)){foreach($py in @(.53,.61)){$p=$image.GetPixel([int]($image.Width*$px),[int]($image.Height*$py));if($p.R-ge200-and$p.G-ge130-and$p.B-le120){$homeButton++}}} $ui=$image.Width-gt$image.Height-and$sky-ge200-and$gold-ge10-and$dark-ge20-and$homeButton-eq4 $observations.Add(@{attempt=$attempt;utc=[DateTime]::UtcNow.ToString('o');pid=$pidText;validAppSave=$validSave;landscape=($image.Width-gt$image.Height);skySamples=$sky;goldButtonSamples=$gold;titleDarkSamples=$dark;titleBoundsNormalized=@(.39,.17,.61,.28);titleStridePixels=4;homeButtonSamples=$homeButton;readyUi=$ui;png=$png}) } finally {$image.Dispose()} if ($pidText -match '^\d+' -and $ui -and !$validSave -and !$pauseRequested) { $observations[$observations.Count-1] | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-first-home-without-save.json") -Encoding utf8 Check "$Name-first-home-ui" $true 'Actual first Home title/button/PID observed before production pause creates save' Adb @('shell','input','keyevent','3') | Out-Null Start-Sleep -Milliseconds 500 Adb @('shell','am','start','-W','-n',$activity) | Out-Null $pauseRequested=$true } if($pidText-match'^\d+'-and$validSave-and$ui){$ready=$true;$saveText|Set-Content (Join-Path $EvidenceDir "$Name-ready-save.json") -Encoding utf8;break} } $observations.ToArray()|ConvertTo-Json -Depth 6|Set-Content (Join-Path $EvidenceDir "$Name-readiness.json") -Encoding utf8 Check "$Name-home-ready" $ready 'Actual app PID, landscape home colors/button/title and app save JSON; bounded30 observations, black/Unity splash excluded, PNG retained for direct review' Step $Name @() 0 } function Restart([string]$Name) { Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null StartHome "$Name-home" } function Fixture([int]$Stage) { Adb @('shell','am','force-stop',$package) | Out-Null $fixture=[ordered]@{schemaVersion=1;completedStageIds=@(1..($Stage-1));seenTutorialIds=@();puzzleStates=@();latestFreePlay=$null} $path=Join-Path $EvidenceDir "fixture-$Stage.json"; $fixture | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding utf8 Adb @('shell','mkdir','-p',"/sdcard/Android/data/$package/files") | Out-Null; Adb @('push',$path,$remote) | Out-Null Adb @('shell','rm','-f',($remote+'.bak')) | Out-Null StartHome "fixture-$Stage-home" Step "fixture-$Stage-selection" @('shell','input','tap','1200','616') } function StartAudio([string]$Name,[int]$Seconds) { $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" if (Test-Path -LiteralPath $readyPath) { Remove-Item -LiteralPath $readyPath } $args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-native-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds",'-Serial',$Serial) $process=Start-Process pwsh -ArgumentList ($args | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru $recorders.Add(@{name=$Name;process=$process;seconds=$Seconds;nativeProcess=$null;startedUtc=[DateTime]::UtcNow}) $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" $ready=$false for ($attempt=0; $attempt -lt 120; $attempt++) { if ($process.HasExited) { break } if (Test-Path -LiteralPath $readyPath) { $ready=$true; break } Start-Sleep -Milliseconds 100 } Check "audio-ready-$Name" $ready 'Recorder reported actual Recording started before interaction' $readyRecord=Get-Content -LiteralPath $readyPath -Raw|ConvertFrom-Json $native=Get-Process -Id $readyRecord.recorderPid -ErrorAction Stop Check "audio-native-identity-$Name" ($native.ProcessName-eq'scrcpy'-and$native.StartTime.ToUniversalTime()-ge$recorders[-1].startedUtc-and$native.StartTime.ToUniversalTime()-le([DateTime]$readyRecord.readyUtc).ToUniversalTime()) 'Own scrcpy created in this recording window; retain Process object rather than recycled PID lookup' $native.Handle|Out-Null # Retain this native process handle through its exit, even if Windows reuses its PID. $recorders[-1].nativeProcess=$native for ($attempt=0; $attempt -lt 20; $attempt++) { Adb @('shell','cmd','audio','set-volume','3','15') | Out-Null $route=Adb @('shell','dumpsys','audio') $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route-$attempt.txt") -Encoding utf8 $current=[regex]::Match($route,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value if ($current -match '8000 \(remote_submix\): 15') { break } Start-Sleep -Milliseconds 250 } $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route.txt") -Encoding utf8 Check "audio-active-route-$Name" ($current -match '8000 \(remote_submix\): 15') $current } function CompareAudio([string]$Name,[string[]]$Ids) { $recorder=@($recorders | Where-Object name -eq $Name)[0].process Check "audio-finished-$Name" ($recorder.WaitForExit((@($recorders | Where-Object name -eq $Name)[0].seconds+30)*1000)) 'PCM capture finished'; $recorder.Refresh(); Check "audio-exit-$Name" ($recorder.ExitCode -eq 0) "$($recorder.ExitCode)" foreach ($id in $Ids) { $reference=Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav" $comparison=Join-Path $EvidenceDir "$Name-$id-match.json" & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison -RequireFullSpeech $exit=$LASTEXITCODE; $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json Check "pcm-$Name-$id" ($exit -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95 -and $match.rmsPcm -gt 32) ($match | ConvertTo-Json -Compress) } } 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 'boot-completed' ((Adb @('shell','getprop','sys.boot_completed')) -eq '1') 'Emulator boot completed' $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'))} $audioBefore=Adb @('shell','dumpsys','audio') $audioBefore | Set-Content -LiteralPath (Join-Path $EvidenceDir 'music-before.txt') -Encoding utf8 $current=[regex]::Match($audioBefore,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value $musicBefore=@{} foreach ($match in [regex]::Matches($current,'([0-9a-f]+) \([^)]+\): (\d+)')) { $musicBefore[$match.Groups[1].Value]=[int]$match.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; 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 $offline=$false for ($attempt=0; $attempt -lt 30; $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-real-network' $offline 'airplane1 wifi0 data0 no active default network' StartHome 'fresh-home'; Step 'fresh-30-selection' @('shell','input','tap','1200','616') $two=StagePoint 2; Step 'fresh-locked-two' @('shell','input','tap',"$($two[0])","$($two[1])") $locked=Save 'fresh-locked'; Check 'fresh-only-one' ($locked.completedStageIds.Count -eq 0 -and $locked.puzzleStates.Count -eq 0) 'Locked Stage2 tap opens no puzzle' StartAudio 'audio-one-offline' 20 $one=StagePoint 1; Step 'one-initial' @('shell','input','tap',"$($one[0])","$($one[1])") 2500 $initial=Save 'one-initial'; Step 'one-help-find' @('shell','input','tap','312','713') 4500 Check 'one-help-readonly' (Same $initial (Save 'one-help-after')) 'Help highlights one real current ball without changing save' $point=BallPoint $initial.puzzleStates[0] $initial.puzzleStates[0].balls[0].id Step 'one-direct-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+80)","$($point[1])",'650') 4000 $collected=Save 'one-collected'; Check 'one-direct-required' ($collected.puzzleStates[0].collectedBallIds.Count -eq 1 -and $collected.puzzleStates[0].countedBallIds.Count -eq 0) 'Real drag collected, no automatic count' TapBall 'one-count-success' $collected.puzzleStates[0] $collected.puzzleStates[0].balls[0].id $free=Save 'one-success'; Check 'one-same-board-free' ($free.completedStageIds -contains 1 -and $free.puzzleStates.Count -eq 0 -and (Same $free.latestFreePlay.balls $collected.puzzleStates[0].balls) -and (Same $free.latestFreePlay.camera $collected.puzzleStates[0].camera)) 'Successful free preserves real positions/camera and atomically removes puzzle' CompareAudio 'audio-one-offline' @('guide_collect','guide_find','guide_count','count_01') StartAudio 'audio-two-help' 23 Step 'next-two' @('shell','input','tap','2064','648') 2500; Step 'two-help-find' @('shell','input','tap','312','713') 4500 MergeStage 'two-direct-merge' 2; Start-Sleep -Seconds 4 $twoCollected=Save 'two-collected'; $board=@($twoCollected.puzzleStates | Where-Object stageId -eq 2)[0] Check 'two-real-collected' ($board.groups.Count -eq 1 -and $board.collectedBallIds.Count -eq 2) 'Real confirmed merge collects both' TapBall 'two-first-count' $board $board.balls[0].id; Start-Sleep -Seconds 1 $beforeHelp=Save 'two-counted-one'; Step 'two-help-uncounted' @('shell','input','tap','312','713') 3500 Check 'two-help-current-readonly' (Same $beforeHelp (Save 'two-help-current-after')) 'Current uncounted highlight leaves counted ID and save intact' Step 'two-orbit-camera' @('shell','input','swipe','2150','450','2210','475','650') $partial=Save 'two-positive-partial'; $partialBoard=@($partial.puzzleStates | Where-Object stageId -eq 2)[0] Check 'positive-partial-count' ($partialBoard.countedBallIds.Count -eq 1 -and $partialBoard.collectedBallIds.Count -eq 2 -and $partial.completedStageIds -notcontains 2 -and [Math]::Abs($partialBoard.camera.rotation.w-1) -gt .001) 'Actual1/2 count and nonidentity camera saved' CompareAudio 'audio-two-help' @('guide_find','guide_uncounted','count_01') Restart 'two-pause-restart'; SelectStage 'two-resumed' 2 $restored=Save 'two-restored'; Check 'positive-partial-restored' (Same $partialBoard @($restored.puzzleStates | Where-Object stageId -eq 2)[0]) 'All real IDs positions groups collected counted camera fields restored offline' Step 'two-home' @('shell','input','tap','312','108'); SelectStage 'one-replay' 1 Step 'one-replay-home' @('shell','input','tap','312','108'); $replay=Save 'replay-left' Check 'replay-keeps-record-and-partial' ($replay.completedStageIds -contains 1 -and @($replay.puzzleStates | Where-Object stageId -eq 2)[0].countedBallIds.Count -eq 1) 'Leaving replay keeps completed1 and unfinished2' Fixture 6; StartAudio 'audio-six-demo' 17 $six=StagePoint 6; Step 'six-ghost-initial' @('shell','input','tap',"$($six[0])","$($six[1])") 1200 $sixBefore=Save 'six-ghost-before'; Check 'six-tutorial-seen' ($sixBefore.seenTutorialIds -contains 'merge') 'First6 records merge seen' Step 'six-ghost-moved' @() 2200; $sixDuring=Save 'six-ghost-during'; Check 'six-ghost-board-unchanged' (Same $sixBefore $sixDuring) 'Ghost leaves real IDs positions groups camera and completion unchanged' Step 'six-ghost-ended' @() 5000; Step 'six-help-merge' @('shell','input','tap','312','713') 4000 Check 'six-help-readonly' (Same $sixBefore (Save 'six-help-after')) 'Current merge help does not solve' CompareAudio 'audio-six-demo' @('demo_merge6','guide_merge') Restart 'six-reentry'; SelectStage 'six-reentered' 6 $sixAgain=Save 'six-reentry-save'; Check 'six-seen-board-preserved' (Same $sixBefore $sixAgain) 'Reentry preserves seen merge and real unfinished board' MergeStage 'six-real-solve' 6 $sixSuccess=Save 'six-success'; Check 'six-free-final' ($sixSuccess.completedStageIds -contains 6 -and $sixSuccess.latestFreePlay.groups.Count -eq 1 -and $sixSuccess.latestFreePlay.groups[0].ballIds.Count -eq 2) 'Actual merge solves6 to one2 group' Step 'next-seven' @('shell','input','tap','2064','648'); $seven=Save 'seven-next'; $sevenBoard=@($seven.puzzleStates | Where-Object stageId -eq 7)[0] Check 'actual-next-seven' ($sevenBoard.stageId -eq 7) 'Next enters7 without automatic prior transition' $point=BallPoint $sevenBoard $sevenBoard.groups[0].ballIds[0] Step 'seven-partial-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]-60)","$($point[1])",'650') $sevenPartial=Save 'seven-partial'; Step 'seven-home' @('shell','input','tap','312','108'); SelectStage 'six-replay-again' 6 MergeStage 'six-replay-solve' 6; Step 'next-seven-resume' @('shell','input','tap','2064','648') $sevenResume=Save 'seven-next-resumed'; Check 'next-prefers-seven-save' (Same @($sevenPartial.puzzleStates | Where-Object stageId -eq 7)[0] @($sevenResume.puzzleStates | Where-Object stageId -eq 7)[0]) 'Next resumes existing7 state' Fixture 10; StartAudio 'audio-ten-demo-help' 20 $ten=StagePoint 10; Step 'ten-ghost-start' @('shell','input','tap',"$($ten[0])","$($ten[1])") 1200 $tenBefore=Save 'ten-ghost-before'; Check 'ten-tutorial-seen' ($tenBefore.seenTutorialIds -contains 'split') 'First10 records split seen' Step 'ten-ghost-selected-moved' @() 2000; Check 'ten-ghost-real-board-unchanged' (Same $tenBefore (Save 'ten-ghost-during')) 'Ghost moves selected silhouette only' Step 'ten-ghost-ended' @() 6500; Step 'ten-current-split-help' @('shell','input','tap','312','713') 6000 Check 'ten-help-current-board-unchanged' (Same $tenBefore (Save 'ten-help-after')) 'Help selects currently valid2→1+1 without execution' CompareAudio 'audio-ten-demo-help' @('demo_split10','guide_split_2_1') Restart 'ten-reentry'; SelectStage 'ten-reentered' 10 Check 'ten-seen-and-board-preserved' (Same $tenBefore (Save 'ten-reentry-save')) 'No second first-entry mutation' $point=BallPoint $tenBefore.puzzleStates[0] $tenBefore.puzzleStates[0].groups[0].ballIds[0] TapBall 'ten-choose-real' $tenBefore.puzzleStates[0] $tenBefore.puzzleStates[0].groups[0].ballIds[0]; Step 'ten-split-mode' @('shell','input','tap','312','832'); TapBall 'ten-select-real' $tenBefore.puzzleStates[0] $tenBefore.puzzleStates[0].groups[0].ballIds[0] Step 'ten-real-split' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]-190)","$($point[1])",'650'); $tenSuccess=Save 'ten-success' Check 'ten-final-one-one' ($tenSuccess.completedStageIds -contains 10 -and $tenSuccess.latestFreePlay.groups.Count -eq 2 -and @($tenSuccess.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 1}).Count -eq 0) 'Actual select/drag yields1,1 and success' Step 'next-eleven-cloud' @('shell','input','tap','2064','648'); $eleven=Save 'eleven-next' Check 'actual-next-eleven' (@($eleven.puzzleStates | Where-Object stageId -eq 11)[0].stageId -eq 11) 'Stage11 opens with its Cloud theme source' Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'content-runtime.log') -Encoding utf8 Check 'runtime-no-exception' ((Get-Content -LiteralPath (Join-Path $EvidenceDir 'content-runtime.log') -Raw) -notmatch 'NullReferenceException|FATAL EXCEPTION|MissingReferenceException') 'Runtime log has no checked fatal/Unity reference exceptions' $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-content-evidence.json') -Encoding utf8 } if ($result.status -ne 'passed') { exit 1 } Write-Host "Content Android real-input/offline/save/ghost/help/next checks passed: $($checks.Count) checks." exit 0