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 IsNativeOrbitMiss($Before,$After,[int]$Stage) { $beforeBoards=@($Before.puzzleStates|Where-Object stageId -eq $Stage) $afterBoards=@($After.puzzleStates|Where-Object stageId -eq $Stage) if($beforeBoards.Count-ne1-or$afterBoards.Count-ne1){return $false} $camera=$beforeBoards[0].camera;$observed=$afterBoards[0].camera if(!(Same $camera.target $observed.target)-or!(Same $camera.distance $observed.distance)){return $false} $unchanged=$After|ConvertTo-Json -Depth 20|ConvertFrom-Json @($unchanged.puzzleStates|Where-Object stageId -eq $Stage)[0].camera=$camera if(!(Same $Before $unchanged)){return $false} $from=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w) $to=[Numerics.Quaternion]::new($observed.rotation.x,$observed.rotation.y,$observed.rotation.z,$observed.rotation.w) if([Math]::Abs($from.LengthSquared()-1)-gt.00001-or[Math]::Abs($to.LengthSquared()-1)-gt.00001){return $false} $relative=[Numerics.Quaternion]::Normalize([Numerics.Quaternion]::Inverse($from)*$to) if($relative.W-lt0){$relative=[Numerics.Quaternion]::Negate($relative)} $angle=2*[Math]::Acos([Math]::Clamp([double]$relative.W,-1.0,1.0))*180/[Math]::PI # One actual UP120 gives local-right -30 degrees; scenery/ground may clamp it. return $relative.X-lt-.000001-and[Math]::Abs($relative.Y)-lt.00001-and[Math]::Abs($relative.Z)-lt.00001-and$angle-le30.002 } function NativePointer([int[]]$Contacts) { $touchHelper=Join-Path $PSScriptRoot 'send-composition-touch.ps1' & pwsh -NoProfile -CommandWithArgs '$ErrorActionPreference="Stop"; $touchHelper=$args[0]; $touchContacts=@($args[1..($args.Count-1)]|ForEach-Object {[int]$_}); & $touchHelper -Contacts $touchContacts; exit $LASTEXITCODE' $touchHelper @Contacts $actualExit=$LASTEXITCODE $commands.Add(@{command='EmulatorController.sendTouch';contacts=$Contacts;exitCode=$actualExit;utc=[DateTime]::UtcNow.ToString('o')}) if($actualExit-ne0){throw "Actual native gesture failed: $actualExit"} } function NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End) { $point=BallPoint $Board $Id Check "$Name-safe-start-end" ((WorldSafe $point)-and(WorldSafe $End)) 'Current exact whole-ID projection and documented UI-safe endpoints' $confirmed=Save "$Name-confirmed" try { NativePointer @($point[0],$point[1],79,1024);Start-Sleep -Milliseconds 100 # One real Move precedes assistance. Hold the finger still as the camera changes; # a 500ms interpolated swipe would reuse stale screen coordinates during rotation. NativePointer @($End[0],$End[1],79,1024);Start-Sleep -Milliseconds 700 Step "$Name-during-live-camera" @() 0 Check "$Name-live-preview-not-saved" (Same $confirmed (Save "$Name-during-save")) 'Stationary native contact: preview and auto camera never overwrite confirmed state' } finally { NativePointer @($End[0],$End[1],79,0) } Step $Name @() 2500 return Save "$Name-dropped" } function AssertNativeTranslation([string]$Name,$Before,$After,[string]$SourceId) { $idsBefore=@($Before.balls.id|Sort-Object);$idsAfter=@($After.balls.id|Sort-Object) Check "$Name-exact-original-ids" (Same $idsBefore $idsAfter) 'No lost, duplicated or substituted IDs after actual native input' Check "$Name-manual-camera-preserved" (Same $Before.camera $After.camera) 'Automatic assistance never persists as the manual camera' $source=@($Before.groups|Where-Object {$_.ballIds-contains$SourceId})[0] $firstBefore=@($Before.balls|Where-Object id -eq $SourceId)[0].position $firstAfter=@($After.balls|Where-Object id -eq $SourceId)[0].position $delta=[Numerics.Vector3]::new($firstAfter.x-$firstBefore.x,$firstAfter.y-$firstBefore.y,$firstAfter.z-$firstBefore.z) Check "$Name-requested-source-actually-moved" ($delta.Length()-gt.00001) "Actual grabbed source $SourceId moved in 3D" foreach($ball in $Before.balls){ $actual=@($After.balls|Where-Object id -eq $ball.id)[0].position if($source.ballIds-contains$ball.id){ $movement=[Numerics.Vector3]::new($actual.x-$ball.position.x,$actual.y-$ball.position.y,$actual.z-$ball.position.z) Check "$Name-rigid-source-$($ball.id)" (($movement-$delta).Length()-lt.0001) 'Every source member shares the same actual translation' }else{Check "$Name-stationary-foreign-$($ball.id)" (Same $ball.position $actual) 'Unselected foreign balls retain exact saved positions'} } } function AssertNativeMerge([string]$Name,$Before,$After,[string]$SourceId,[int]$TargetCount=0) { AssertNativeTranslation $Name $Before $After $SourceId $source=@($Before.groups|Where-Object {$_.ballIds-contains$SourceId})[0] $merged=@($After.groups|Where-Object {$_.ballIds-contains$SourceId})[0] $targets=@($Before.groups|Where-Object {$_.id-ne$source.id-and@($_.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0}) Check "$Name-actual-source-and-target-membership" ($targets.Count-eq1-and$merged.ballIds.Count-eq($source.ballIds.Count+$targets[0].ballIds.Count)-and@($source.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0-and($TargetCount-eq0-or$targets[0].ballIds.Count-eq$TargetCount)) 'Grabbed source plus exactly one actual foreign group; requested target size retained' $distance=[double]::PositiveInfinity foreach($sid in $source.ballIds){foreach($tid in $targets[0].ballIds){ $s=@($After.balls|Where-Object id -eq $sid)[0].position;$t=@($After.balls|Where-Object id -eq $tid)[0].position $distance=[Math]::Min($distance,[Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z).Length()) }} Check "$Name-real-three-dimensional-contact" ($distance-ge.998-and$distance-le1.002) "Actual source/foreign unit-sphere contact distance=$distance; screen overlap alone is insufficient" } 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 BallClear($Board,[string]$Id) { $point=BallPoint $Board $Id if(!(WorldSafe $point)){return $false} $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) $target=@($Board.balls|Where-Object id -eq $Id)[0].position $direction=[Numerics.Vector3]::new($target.x,$target.y,$target.z)-$origin $targetHit=$direction.Length()-.5 $direction=[Numerics.Vector3]::Normalize($direction) foreach($ball in $Board.balls){ if($ball.id-eq$Id){continue} $delta=[Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin $along=[Numerics.Vector3]::Dot($direction,$delta) $perpendicularSquared=$delta.LengthSquared()-$along*$along if($perpendicularSquared-le.25){ $near=$along-[Math]::Sqrt([Math]::Max(0,.25-$perpendicularSquared)) if($near-ge0-and$near-lt$targetHit){return $false} } } return $true } function SplitModeVisible([string]$Path) { Add-Type -AssemblyName System.Drawing $image=[Drawing.Bitmap]::new($Path) $min=450;$max=0;$dark=0 try { if($image.Width-ne2400-or$image.Height-ne1080){throw 'Split UI probe requires the documented 2400x1080 AVD'} for($y=802;$y-lt862;$y++){for($x=180;$x-lt450;$x++){ $pixel=$image.GetPixel($x,$y) if($pixel.R-lt100-and$pixel.G-lt110-and$pixel.B-lt150){$dark++;$min=[Math]::Min($min,$x);$max=[Math]::Max($max,$x)} }} } finally {$image.Dispose()} # Korean fixed-size label: 취소 spans79px; 나누기 spans117px in the actual APK captures. $visible=$dark-ge300-and($max-$min)-ge60-and($max-$min)-le95 $commands.Add(@{command='Read actual native split button label bounds';png=$Path;darkPixels=$dark;minX=$min;maxX=$max;splitCancelLabel=$visible;utc=[DateTime]::UtcNow.ToString('o')}) return $visible } 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-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 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) } }