From f4ee23c10db58711db61aed953aeb40f737a9236 Mon Sep 17 00:00:00 2001 From: NINI Date: Tue, 6 Oct 2026 22:59:57 +0900 Subject: [PATCH] Add Final Android gate with fresh native thirty-stage progression --- tools/android/exercise-final-progression.ps1 | 343 +++++++++++++++++++ tools/android/exercise-final.ps1 | 51 +++ tools/android/smoke.ps1 | 4 + tools/android/test-final-dispatch.ps1 | 20 ++ tools/verify.ps1 | 32 ++ 5 files changed, 450 insertions(+) create mode 100644 tools/android/exercise-final-progression.ps1 create mode 100644 tools/android/exercise-final.ps1 create mode 100644 tools/android/test-final-dispatch.ps1 diff --git a/tools/android/exercise-final-progression.ps1 b/tools/android/exercise-final-progression.ps1 new file mode 100644 index 0000000..31a7cc2 --- /dev/null +++ b/tools/android/exercise-final-progression.ps1 @@ -0,0 +1,343 @@ +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='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) { + $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-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) + } +} + +# This suite never pushes a save or injects completed stages. Every checkpoint is read from the APK. +function Board($State,[int]$Stage) { return @($State.puzzleStates | Where-Object stageId -eq $Stage)[0] } +function Counts($Board) { return (@($Board.groups | ForEach-Object {$_.ballIds.Count} | Sort-Object) -join ',') } +function Orbit([string]$Name) { Step $Name @('shell','input','swipe','2200','410','2320','410','350') } +function MergeReal([int]$Stage,[string]$Name) { + for($attempt=0;$attempt-lt16;$attempt++) { + $before=Save "$Name-before-$attempt"; $board=Board $before $Stage + if($null-eq$board){return $before} + $oldCount=$board.groups.Count + $rotation=[Numerics.Quaternion]::new($board.camera.rotation.x,$board.camera.rotation.y,$board.camera.rotation.z,$board.camera.rotation.w) + $pairs=@(foreach($source in $board.groups){foreach($target in $board.groups){if($source.id-eq$target.id){continue};foreach($sid in $source.ballIds){foreach($tid in $target.ballIds){ + $s=@($board.balls|Where-Object id -eq $sid)[0].position; $t=@($board.balls|Where-Object id -eq $tid)[0].position + $delta=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z),[Numerics.Quaternion]::Inverse($rotation)) + [pscustomobject]@{sid=$sid;tid=$tid;depth=[Math]::Abs($delta.Z);size=$source.ballIds.Count} + }}}}) | Sort-Object depth,size + $pair=$pairs[0] + if($pair.depth-lt.9) { + $start=BallPoint $board $pair.sid; $end=BallPoint $board $pair.tid + # Try a short lateral contact offset, then orbit to expose another depth plane if blocked. + $offset=if($attempt%2-eq0){-35}else{35} + Step "$Name-drag-$attempt" @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]+$offset)","$($end[1])",'500') + $after=Save "$Name-after-$attempt"; $newBoard=Board $after $Stage + if($null-eq$newBoard-or$newBoard.groups.Count-lt$oldCount){return $after} + } + Orbit "$Name-orbit-$attempt" + } + throw "Native merge bound reached for Stage $Stage; all actual attempts retained." +} +function SplitReal([int]$Stage,[int]$Selected,[string]$Name) { + for($attempt=0;$attempt-lt8;$attempt++) { + $before=Save "$Name-before-$attempt"; $board=Board $before $Stage + $group=@($board.groups|Sort-Object {$_.ballIds.Count} -Descending)[0] + $ids=if($Stage-eq13-and$Selected-eq2){@($group.ballIds[0],$group.ballIds[-1])}else{@($group.ballIds|Select-Object -First $Selected)} + TapBall "$Name-choose-$attempt" $board $ids[0] + Step "$Name-mode-$attempt" @('shell','input','tap','312','832') + foreach($id in $ids){TapBall "$Name-select-$attempt-$id" $board $id} + $point=BallPoint $board $ids[0] + Step "$Name-drag-$attempt" @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0])","$($point[1]-160)",'500') + $after=Save "$Name-after-$attempt"; $actual=Board $after $Stage + if($null-eq$actual-or$actual.groups.Count-gt$board.groups.Count){return $after} + # Cancel failed selection before a new view; do not silently turn an invalid selection into a solve. + Step "$Name-cancel-$attempt" @('shell','input','tap','312','832') + Orbit "$Name-orbit-$attempt" + } + throw "Native split bound reached for Stage $Stage." +} +function Collect([int]$Stage) { + $state=Save "stage-$Stage-count-initial"; $board=Board $state $Stage + Check "stage-$Stage-separated-ones" ($board.groups.Count-eq$board.balls.Count-and@($board.groups|Where-Object {$_.ballIds.Count-ne1}).Count-eq0-and$board.collectedBallIds.Count-eq0-and$board.countedBallIds.Count-eq0) (Counts $board) + if($board.balls.Count-eq1) { + $point=BallPoint $board $board.balls[0].id + TapBall "stage-$Stage-precollect-tap" $board $board.balls[0].id + Check "stage-$Stage-precollect-cannot-count" ((Board (Save "stage-$Stage-precollect-state") $Stage).countedBallIds.Count-eq0) 'Uncollected tap never counts' + Step "stage-$Stage-direct-collect" @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+80)","$($point[1])",'500') + } else { + for($merge=0;$merge-lt12;$merge++) { + $state=Save "stage-$Stage-merge-$merge-state"; $board=Board $state $Stage + if($board.groups.Count-eq1-and$board.collectedBallIds.Count-eq$board.balls.Count){break} + MergeReal $Stage "stage-$Stage-merge-$merge" | Out-Null + } + } + $state=Save "stage-$Stage-collected"; $board=Board $state $Stage + Check "stage-$Stage-direct-collected-not-counted" ($board.groups.Count-eq1-and$board.collectedBallIds.Count-eq$board.balls.Count-and$board.countedBallIds.Count-eq0) 'Every ball directly dragged or merged; no injected or automatic count' + return $board +} +function CountRemaining([int]$Stage) { + for($attempt=0;$attempt-lt40;$attempt++) { + $state=Save "stage-$Stage-count-$attempt-state"; $board=Board $state $Stage + if($null-eq$board){return $state} + $uncounted=@($board.balls|Where-Object {$board.countedBallIds-notcontains$_.id}) + $beforeCount=$board.countedBallIds.Count + TapBall "stage-$Stage-count-$attempt" $board $uncounted[0].id + $after=Save "stage-$Stage-count-$attempt-after"; $newBoard=Board $after $Stage + if($null-eq$newBoard){return $after} + if($newBoard.countedBallIds.Count-eq$beforeCount){Orbit "stage-$Stage-count-reveal-$attempt"} + } + throw "Native counting bound reached Stage $Stage." +} +try { + $result.scope='Fresh 30-stage sequential actual touch progression, no save writes or completion fixtures; separate boundary suites are labelled separately' + 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-eq0-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 + Check 'boot-completed' ((Adb @('shell','getprop','sys.boot_completed'))-eq'1') 'Actual AVD boot property' + $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'));size=(Adb @('shell','wm','size'));graphics=(Adb @('shell','dumpsys','SurfaceFlinger'))} + $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 + Adb @('install','-r',$ApkPath)|Out-Null + $installed=(Adb @('shell','pm','path',$package)).Replace('package:','').Trim() + Adb @('pull',$installed,(Join-Path $EvidenceDir 'installed-base.apk'))|Out-Null + Check 'installed-exact-final-apk' ((Get-FileHash (Join-Path $EvidenceDir 'installed-base.apk')).Hash-eq$result.apkSha256) $result.apkSha256 + 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 + for($attempt=0;$attempt-lt30;$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-through-all-30' $offline 'Airplane1 wifi0 data0, no active default network' + StartHome 'fresh-home';Step 'fresh-selection' @('shell','input','tap','1200','616') + $point=StagePoint 2;Step 'fresh-locked2' @('shell','input','tap',"$($point[0])","$($point[1])") + $fresh=Save 'fresh-state';Check 'fresh-only1-open' ($fresh.completedStageIds.Count-eq0-and$fresh.puzzleStates.Count-eq0-and$null-eq$fresh.latestFreePlay) 'Actual new save; locked2 opens no puzzle' + $point=StagePoint 1;Step 'stage-1-enter' @('shell','input','tap',"$($point[0])","$($point[1])") 2000 + $targets=@{6='2';7='3';8='4';9='5';10='1,1';11='1,2';12='2,2';13='2,3';14='2,2';15='2,3';21='6';22='7';23='8';24='10';25='1,5';26='3,4';27='4,5';28='5,5';29='4,4';30='5,5'} + $splitSizes=@{10=1;11=1;12=2;13=2;14=2;15=2;25=1;26=3;27=4;28=5;29=4;30=5} + for($stage=1;$stage-le30;$stage++) { + Write-Host "Final actual progression Stage $stage" + $before=Save "stage-$stage-entered"; $original=Board $before $stage + Check "stage-$stage-entered" ($null-ne$original-and$before.completedStageIds.Count-eq($stage-1)) 'Real next stage reached with exactly preceding records' + if($stage-eq6-or$stage-eq10){Step "stage-$stage-tutorial-completes" @() 9000;$during=Save "stage-$stage-ghost-ended";Check "stage-$stage-readonly-ghost" (Same $before $during) 'First-entry seen persisted, real board unchanged by ghost'} + if($stage-le5-or($stage-ge16-and$stage-le20)) { + $collected=Collect $stage + if($stage-eq2) { + TapBall 'partial2-first-count' $collected $collected.balls[0].id + Orbit 'partial2-orbit';$partial=Save 'partial2-before';$partialBoard=Board $partial 2 + Check 'positive-partial2' ($partialBoard.countedBallIds.Count-eq1-and$partialBoard.collectedBallIds.Count-eq2-and!(Same $partialBoard.camera $collected.camera)) 'Actual1/2 plus changed camera' + Step 'partial2-home' @('shell','input','tap','312','108');SelectStage 'replay1-unfinished' 1 + $replayBefore=Save 'replay1-before';$replayBoard=Board $replayBefore 1;$point=BallPoint $replayBoard $replayBoard.balls[0].id + Step 'replay1-unfinished-drag' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+60)","$($point[1])",'500') + Step 'replay1-home' @('shell','input','tap','312','108');Step 'free1-continue' @('shell','input','tap','1200','810') + Orbit 'free1-orbit';$three=Save 'two-unfinished-and-free-before' + Check 'two-unfinished-plus-free' ($three.puzzleStates.Count-eq2-and$three.latestFreePlay.sourceStageId-eq1-and(Same $partialBoard (Board $three 2))-and$three.completedStageIds.Count-eq1) 'Replay1, actual partially counted2, and independent Free1 coexist without fixture' + Restart 'three-board-restart';Step 'free1-restored' @('shell','input','tap','1200','810') + Check 'two-puzzles-free-whole-restored' (Same $three (Save 'two-unfinished-and-free-after')) 'Pause force-stop startup Home then Free restore exact full save' + Step 'free1-home' @('shell','input','tap','312','108');SelectStage 'partial2-restored' 2 + Check 'partial2-exact-restored' (Same $partialBoard (Board (Save 'partial2-restored') 2)) 'OriginalIDs positions membership collected counted camera exact' + } + if($stage-eq20) { + # Cache projected coordinates, then only native taps during speech; duplicates must not queue another word. + $actual=Board (Save 'twenty-before-rapid') 20 + $points=@($actual.balls|ForEach-Object{,(BallPoint $actual $_.id)}) + StartAudio 'audio-ten-rapid' 24 + $result.rapidStartedUtc=[DateTime]::UtcNow.ToString('o') + for($pindex=0;$pindex-lt$points.Count;$pindex++){$p=$points[$pindex];Adb @('shell','input','tap',"$($p[0])","$($p[1])")|Out-Null;if($pindex-eq0){Adb @('shell','input','tap',"$($p[0])","$($p[1])")|Out-Null}} + $result.rapidFinishedUtc=[DateTime]::UtcNow.ToString('o') + Start-Sleep -Seconds 12 + $done=Save 'twenty-rapid-after';Check 'twenty-native-rapid-complete' ($done.completedStageIds-contains20-and(Same $actual.balls $done.latestFreePlay.balls)-and(Same $actual.camera $done.latestFreePlay.camera)) 'Ten direct taps plus duplicate, same board becomes Free' + CompareAudio 'audio-ten-rapid' @(1..10|ForEach-Object{'count_{0:D2}'-f$_}) + $matches=@(1..10|ForEach-Object{Get-Content (Join-Path $EvidenceDir ('audio-ten-rapid-count_{0:D2}-match.json'-f$_)) -Raw|ConvertFrom-Json}) + for($i=1;$i-lt10;$i++){Check "rapid-fifo-$i" ($matches[$i-1].onsetSeconds+$matches[$i-1].referenceDurationSeconds-le$matches[$i].onsetSeconds+.015) 'Entire numeric clip completes before next onset'} + $result.speechMatches=$matches + } else {CountRemaining $stage|Out-Null} + } else { + if($original.groups.Count-gt1){MergeReal $stage "stage-$stage-merge"|Out-Null} + if($splitSizes.ContainsKey($stage)){SplitReal $stage $splitSizes[$stage] "stage-$stage-split"|Out-Null} + } + $success=Save "stage-$stage-success";$free=$success.latestFreePlay + $expected=if($targets.ContainsKey($stage)){$targets[$stage]}elseif($stage-le5){"$stage"}else{"$($stage-10)"} + Check "stage-$stage-actual-success" ($success.completedStageIds.Count-eq$stage-and$success.completedStageIds-contains$stage-and$null-eq(Board $success $stage)-and$free.sourceStageId-eq$stage-and(Counts $free)-eq$expected) "groups=$(Counts $free) completed=$($success.completedStageIds-join',')" + Check "stage-$stage-conserves-all-ids" (((($free.balls.id|Sort-Object)-join',') -eq (($original.balls.id|Sort-Object)-join','))) 'All original identities preserved' + if($stage-lt30){Step "stage-$stage-next" @('shell','input','tap','2064','648') 1800} + } + $complete=Save 'thirty-completed';Step 'thirty-no-next' @('shell','input','tap','2064','648');Check 'no-stage31' (Same $complete (Save 'thirty-no-next-after')) 'Final no-next tap leaves Free30' + Check 'both-tutorials-persisted-once' ((($complete.seenTutorialIds|Sort-Object)-join',') -eq 'merge,split') ($complete.seenTutorialIds-join',') + Adb @('logcat','-d','-s','Unity','AndroidRuntime')|Set-Content (Join-Path $EvidenceDir 'final-runtime.log') + Check 'runtime-no-exception' ((Get-Content (Join-Path $EvidenceDir 'final-runtime.log') -Raw)-notmatch'NullReferenceException|FATAL EXCEPTION|MissingReferenceException') 'All30 native run log' + $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=Get-Process -Id $ready.recorderPid -ErrorAction SilentlyContinue + if ($native) { $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=Get-Process -Id $ready.recorderPid -ErrorAction SilentlyContinue + if ($native) { $native.WaitForExit() } + $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-progression-evidence.json') -Encoding utf8 +} +if ($result.status -ne 'passed') { exit 1 } +Write-Host "Final fresh 30-stage real-input checks passed: $($checks.Count) checks." +exit 0 diff --git a/tools/android/exercise-final.ps1 b/tools/android/exercise-final.ps1 new file mode 100644 index 0000000..f6b38ad --- /dev/null +++ b/tools/android/exercise-final.ps1 @@ -0,0 +1,51 @@ +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 +$checks=[Collections.Generic.List[object]]::new();$children=[Collections.Generic.List[object]]::new() +$record=[ordered]@{milestone='Final';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);apkSha256=(Get-FileHash -LiteralPath $ApkPath).Hash;status='failed';excludedAudio='SFX and BGM excluded by user2026-10-06; all44 approved Korean voices mandatory'} +function Check([string]$Id,[bool]$Passed,[string]$Actual){$checks.Add(@{id=$Id;passed=$Passed;actual=$Actual});if(!$Passed){throw "Final check failed: $Id ($Actual)"}} +try { + Check 'clean-frozen-source' (!$record.sourceDirty) $record.sourceCommit + $build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw|ConvertFrom-Json + Check 'exact-final-build' ($build.status-eq'passed'-and$build.exitCode-eq0-and!$build.sourceDirty-and!$build.sourceDirtyEnd-and$build.sourceCommit-eq$record.sourceCommit-and$build.sourceCommitEnd-eq$record.sourceCommit-and$build.apkSha256-eq$record.apkSha256) $record.apkSha256 + $standard=Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk' + Check 'final-and-deliverable-apk-identical' ((Get-FileHash -LiteralPath $standard).Hash-eq$record.apkSha256) $standard + $adoption=Get-Content -LiteralPath (Join-Path $projectRoot 'SourceAssets/Audio/Voice/active-adoption.json') -Raw|ConvertFrom-Json + Check 'approved44-active-voices' ($adoption.approvedCount-eq44-and$adoption.draftCount-eq0-and$adoption.clips.Count-eq44-and@($adoption.clips.id|Sort-Object -Unique).Count-eq44) 'All active voices approved, no draft' + $packed=@(Get-Content -LiteralPath ($ApkPath+'.packed-assets.txt')) + foreach($clip in $adoption.clips){ + foreach($entry in @(@{kind='source';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.sourceFile));expected=$clip.sourceSha256},@{kind='edited';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.editedFile));expected=$clip.editedSha256},@{kind='runtime';path=(Join-Path $projectRoot $clip.runtimeFile);expected=$clip.runtimeSha256})){ + Check "voice-$($clip.id)-$($entry.kind)-exact-sha" ((Get-FileHash -LiteralPath $entry.path).Hash-eq$entry.expected) $entry.expected + } + Check "voice-$($clip.id)-packed-in-built-apk" ($packed-contains$clip.runtimeFile) 'BuildReport packedAssets contains adopted runtime clip; Unity imported representation, not original WAV byte identity' + } + $zip=[IO.Compression.ZipFile]::OpenRead($ApkPath) + try { + foreach($name in @('NotoSansCJK-OFL.txt','TTS-Apache-2.0.txt','TTS-model-card.txt')){ + $entry=$zip.GetEntry('assets/Notices/'+$name);Check "apk-notice-present-$name" ($null-ne$entry) $name + $stream=$entry.Open();try{$sha=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream))}finally{$stream.Dispose()} + Check "apk-notice-exact-bytes-$name" ($sha-eq(Get-FileHash -LiteralPath (Join-Path $projectRoot ('Assets/StreamingAssets/Notices/'+$name))).Hash) $sha + } + }finally{$zip.Dispose()} + foreach($suite in @(@{name='Fresh30';script='exercise-final-progression.ps1';report='android-final-progression-evidence.json'},@{name='ContentBoundary';script='exercise-content.ps1';report='android-content-evidence.json'})){ + $childDir=Join-Path $EvidenceDir $suite.name;New-Item -ItemType Directory -Path $childDir -Force|Out-Null + Copy-Item -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Destination (Join-Path $childDir 'androidbuild-evidence.json') + $log=Join-Path $childDir 'outer.log';$command="pwsh -NoProfile -File tools/android/$($suite.script) -ApkPath $ApkPath -EvidenceDir $childDir -Serial $Serial" + & pwsh -NoProfile -File (Join-Path $PSScriptRoot $suite.script) -ApkPath $ApkPath -EvidenceDir $childDir -Serial $Serial *> $log + $actualExit=$LASTEXITCODE;$child=Get-Content -LiteralPath (Join-Path $childDir $suite.report) -Raw|ConvertFrom-Json + $children.Add(@{suite=$suite.name;command=$command;exitCode=$actualExit;log=$log;report=$child}) + Check "suite-$($suite.name)-actual-exit-status-checks" ($actualExit-eq0-and$child.status-eq'passed'-and$child.sourceCommit-eq$record.sourceCommit-and!$child.sourceDirty-and$child.apkSha256-eq$record.apkSha256-and$child.checks.Count-gt0-and@($child.checks|Where-Object passed -ne $true).Count-eq0) "$actualExit / $($child.checks.Count) checks" + if($suite.name-eq'Fresh30'){ + foreach($stage in 1..30){Check "fresh30-required-success-$stage" (@($child.checks|Where-Object {$_.id-eq"stage-$stage-actual-success"-and$_.passed}).Count-eq1) 'Native sequential checkpoint present'} + Check 'fresh30-native-commands-no-fixture-write' (@($child.commands|Where-Object {$_.command-match'\bpush\b'}).Count-eq0) 'Native progression never pushes save data' + } + } + $record.sourceCommitEnd=(& git rev-parse HEAD);$record.sourceDirtyEnd=[bool](& git status --porcelain) + Check 'unchanged-source-end' (!$record.sourceDirtyEnd-and$record.sourceCommitEnd-eq$record.sourceCommit) $record.sourceCommitEnd + $record.status='passed' +}catch{$record.error=$_.Exception.Message;Write-Host $record.error -ForegroundColor Red} +finally{$record.checks=$checks.ToArray();$record.children=$children.ToArray();$record.finishedUtc=[DateTime]::UtcNow.ToString('o');$record|ConvertTo-Json -Depth 18|Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-final-evidence.json') -Encoding utf8} +if($record.status-ne'passed'){exit 1} +Write-Host "Final Android passed: $($checks.Count) package/suite gates plus fresh30 and separately labelled Content boundaries." +exit 0 diff --git a/tools/android/smoke.ps1 b/tools/android/smoke.ps1 index 8937db7..638bfd2 100644 --- a/tools/android/smoke.ps1 +++ b/tools/android/smoke.ps1 @@ -1,4 +1,8 @@ param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554') +if ($Milestone -eq 'Final') { + & (Join-Path $PSScriptRoot 'exercise-final.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial + exit $LASTEXITCODE +} if ($Milestone -eq 'Content') { & (Join-Path $PSScriptRoot 'exercise-content.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial exit $LASTEXITCODE diff --git a/tools/android/test-final-dispatch.ps1 b/tools/android/test-final-dispatch.ps1 new file mode 100644 index 0000000..0357edc --- /dev/null +++ b/tools/android/test-final-dispatch.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory)][string]$EvidenceDir) +$ErrorActionPreference='Stop' +New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null +$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path +$sandbox=Join-Path $EvidenceDir ('dispatch-'+[guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $sandbox | Out-Null +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'smoke.ps1') -Destination (Join-Path $sandbox 'smoke.ps1') +@' +param([string]$ApkPath,[string]$EvidenceDir,[string]$Serial) +@{apk=$ApkPath;serial=$Serial;entrypoint='Final'}|ConvertTo-Json|Set-Content (Join-Path $EvidenceDir 'final-marker.json') +exit 37 +'@ | Set-Content -LiteralPath (Join-Path $sandbox 'exercise-final.ps1') +& pwsh -NoProfile -File (Join-Path $sandbox 'smoke.ps1') -Milestone Final -ApkPath 'sentinel-final.apk' -Serial 'sentinel-device' -EvidenceDir $sandbox *> (Join-Path $sandbox 'invocation.log') +$actualExit=$LASTEXITCODE +$markerPath=Join-Path $sandbox 'final-marker.json' +$marker=if(Test-Path -LiteralPath $markerPath){Get-Content -LiteralPath $markerPath -Raw|ConvertFrom-Json}else{$null} +$passed=$actualExit-eq37-and$marker.entrypoint-eq'Final'-and$marker.apk-eq'sentinel-final.apk'-and$marker.serial-eq'sentinel-device' +@{criterion='Final dispatches Final script with arguments and propagates nonzero exit, never FirstSlice';passed=$passed;actualExit=$actualExit;marker=$marker;sandbox=$sandbox}|ConvertTo-Json -Depth 4|Set-Content (Join-Path $EvidenceDir 'dispatch-result.json') +if(!$passed){Write-Host "Final dispatch regression: actual exit $actualExit, marker present $([bool]$marker)";exit 1} +exit 0 diff --git a/tools/verify.ps1 b/tools/verify.ps1 index 033c777..ba8f850 100644 --- a/tools/verify.ps1 +++ b/tools/verify.ps1 @@ -81,6 +81,38 @@ try { Get-Content -LiteralPath $logPath | Select-Object -Last 5 | Write-Host if ($LASTEXITCODE -ne 0) { throw "Android smoke verification exited $LASTEXITCODE." } } + # Edit/Play tests may strip this Editor-generated runtime list. Restore only that exact + # transformation after proving every other serialized byte (apart from CRLF) is unchanged. + if ($Phase -in @('EditMode','PlayMode') -and !$record.sourceDirty) { + $record.testSourceDirtyBeforeNormalization = [bool](& git status --porcelain) + $record.testGeneratedSettings = @() + foreach ($settingsPath in @('Assets/Settings/UniversalRenderPipelineGlobalSettings.asset','ProjectSettings/EditorBuildSettings.asset','ProjectSettings/ProjectAuditorSettings.asset','ProjectSettings/ShaderGraphSettings.asset')) { + $workingBlob = & git hash-object --path=$settingsPath -- $settingsPath + $sourceBlob = & git rev-parse "HEAD:$settingsPath" + $same = $workingBlob -eq $sourceBlob + $runtimeListStripped = $false + if (!$same -and $settingsPath -eq 'Assets/Settings/UniversalRenderPipelineGlobalSettings.asset') { + $sourceText = ((& git show "HEAD:$settingsPath") -join "`n") + "`n" + $workingText = (Get-Content -LiteralPath $settingsPath -Raw).Replace("`r`n","`n") + $pattern = '(?m)^ m_RuntimeSettings:\n m_List:\n(?: - rid: \d+\n)+' + $expected = [regex]::Replace($sourceText,$pattern," m_RuntimeSettings:`n m_List: []`n") + $runtimeListStripped = $expected -ne $sourceText -and $expected -ceq $workingText + if ($runtimeListStripped) { (& git diff -- $settingsPath) | Set-Content -LiteralPath (Join-Path $evidenceDir "$phaseName-generated-runtime-list.diff") -Encoding utf8 } + } + if ($same -or $runtimeListStripped) { + if ([bool](& git status --porcelain -- $settingsPath)) { + & git restore --source=HEAD --worktree -- $settingsPath + if ($LASTEXITCODE -ne 0) { throw "Cannot normalize proven generated setting: $settingsPath" } + } + } + $record.testGeneratedSettings += @{path=$settingsPath;workingBlob=$workingBlob;sourceBlob=$sourceBlob;identicalContent=$same;exactRuntimeListStripping=$runtimeListStripped} + } + } + $record.sourceCommitEnd = (& git rev-parse HEAD) + & git update-index --refresh | Out-Null + $record.sourceDirtyEnd = [bool](& git status --porcelain) + $record.trackedDiffEnd = @(& git diff --name-only HEAD) + if ($record.sourceDirty -or $record.sourceDirtyEnd -or $record.sourceCommitEnd -ne $record.sourceCommit) { throw 'Source changed during verification or is dirty.' } $record.status = 'passed' } catch { $record.status = 'failed'