param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554') $ErrorActionPreference='Stop' $EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path $projectRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $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='Final';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) { $matching=@($Board.balls | Where-Object id -eq $Id) if ($matching.Count -ne 1) { throw "Native projection requires exactly one existing ball ID: $Id" } $ball=$matching[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 WorldSafe($Point) { return $Point[0]-ge530-and$Point[0]-le1850-and$Point[1]-ge280-and$Point[1]-le750 } function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id if(!(WorldSafe $point)){throw "Refused unsafe native ball tap $Id at $($point-join',')"} Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") } 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-ge150-and$pixel.G-ge180-and$pixel.B-ge210){$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 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;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' 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) } }