Files
MagnetismHeroes/tools/android/final-common.ps1
T

261 lines
20 KiB
PowerShell

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,[switch]$AllowMissingPid) {
$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")})
$missingPid=$AllowMissingPid -and $code-eq1 -and $Arguments.Count-eq3 -and $Arguments[0]-eq'shell' -and $Arguments[1]-eq'pidof' -and $Arguments[2]-eq$package -and [string]::IsNullOrWhiteSpace(($output -join "`n"))
if ($code -ne 0 -and !$missingPid) { 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)
$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=[Math]::Tan(31*[Math]::PI/180)/($height/2)
# Match the rounded native DOWN pixel, including its actual target entry distance.
$direction=[Numerics.Vector3]::Normalize([Numerics.Vector3]::Transform([Numerics.Vector3]::new(($point[0]-$width/2)*$scale,($height/2-$point[1])*$scale,1),$rotation))
$target=@($Board.balls|Where-Object id -eq $Id)[0].position
$delta=[Numerics.Vector3]::new($target.x,$target.y,$target.z)-$origin
$along=[Numerics.Vector3]::Dot($direction,$delta)
$perpendicularSquared=($delta-$direction*$along).LengthSquared()
if($perpendicularSquared-gt.25){return $false}
$targetHit=$along-[Math]::Sqrt([Math]::Max(0.0,.25-$perpendicularSquared))
if($targetHit-lt0){return $false}
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-$direction*$along).LengthSquared()
if($perpendicularSquared-le.25){
$near=$along-[Math]::Sqrt([Math]::Max(0.0,.25-$perpendicularSquared))
if($near-ge0-and$near-le$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) -AllowMissingPid
$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}
if($pidText-notmatch'^\d+'){Adb @('shell','am','start','-W','-n',$activity)|Out-Null}
}
$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)
}
}