From 4936180e453b985107ddd0630f47224f6d8867fd Mon Sep 17 00:00:00 2001 From: NINI Date: Mon, 5 Oct 2026 03:46:17 +0900 Subject: [PATCH] Bind Android smoke to build provenance and actual PCM assertions --- docs/android-first-slice.md | 6 ++ tools/android/compare-audio.ps1 | 58 +++++++++++++ tools/android/exercise-first-slice.ps1 | 114 +++++++++++++++++++++++++ tools/android/inspect-apk.ps1 | 7 +- tools/android/record-audio.ps1 | 21 ++++- tools/android/smoke.ps1 | 34 +++++++- tools/verify.ps1 | 10 ++- 7 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 tools/android/compare-audio.ps1 create mode 100644 tools/android/exercise-first-slice.ps1 diff --git a/docs/android-first-slice.md b/docs/android-first-slice.md index 2bf22a1..6874b5a 100644 --- a/docs/android-first-slice.md +++ b/docs/android-first-slice.md @@ -11,6 +11,12 @@ pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidBuild -Milestone FirstSlice pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone FirstSlice ``` +첫 API 37 headless 기동의 GPU auto는 SwiftShader GLES 3.0/Lavapipe Vulkan 1.3을 선택했으며 즉시 찍은 화면은 비어 있었습니다. 최종 검증 AVD는 `-gpu host -no-window -no-snapshot-save -grpc-use-token`으로 실행해 실제 NVIDIA 호스트 렌더러와 게임 화면을 확인합니다. 처음 시작 직후 프로세스만 생존하는 시점과 UI가 렌더링된 시점을 구분하며, 전체 화면 안내가 뜨면 Android의 안내를 닫고 실제 화면을 다시 캡처합니다. + +`tools/android/exercise-first-slice.ps1`은 담당자가 설치한 시험용 앱 데이터만 초기화한 뒤 위 표의 실제 ADB 조작, 앱 pause/force-stop/재실행, 앱 전용 JSON 읽기, 오프라인 상태와 에뮬레이터 음성을 수집합니다. `android-state-checks.json`의 기계 검사는 실제 수집 필요·빈 계수·동일 보드 성공·자유 이동의 위치 변화·JSON 복원·과거 음성 없는 복원·오프라인 성공 전이와 네트워크 상태를 검사합니다. 그림의 뜻은 별도 직접 검수입니다. airplane=1, wifi=0, mobile_data=0 및 `Active default network: none`을 확인한 시간에 세 발화를 기록합니다. + +`compare-audio.ps1`은 승인 원본과 실제 PCM을 mono PCM16 24kHz로 맞추고 가장 강한 구간의 위치를 찾은 후 전체 발화의 정규화 상관을 평가합니다. 기준은 상관 ≥0.95, RMS >32, 비무음 샘플 >100입니다. 세 발화의 시작/끝이 차례로 분리되어야 합니다. APK 생성 출처는 원본 `androidbuild-evidence.json`과 `apk-metadata.json`을 유지하고, Smoke의 재검사는 `apk-inspection.json`에만 씁니다. 시작/종료 dirty=false와 소스 SHA·APK hash를 대조하므로 이전 APK를 후속 커밋 산출물로 다시 이름 붙일 수 없습니다. 잘못된 SHA/hash 검사 복사본은 실제 기기 조작 전 실패해야 합니다. + APK는 `artifacts/android/FirstSlice/MagnetismHeroes.apk`, 원본 경로를 기록한 Unity 실제 packed-assets 목록은 APK 옆 `.packed-assets.txt`입니다. 근거는 `artifacts/verification/FirstSlice/`에 보존합니다. Build/Smoke의 명령·실제 종료 코드·소스 SHA/dirty·APK SHA-256, aapt 실제 badging/ZIP entries, 기기 ABI/번역기/graphics, ADB 명령별 종료 코드·화면·관찰 결과를 별도로 기록합니다. 설치·프로세스 생존만으로 게임 검사를 통과시키지 않습니다. `tools/android/observe.ps1`은 실제 ADB 조작 후 screencap을 가져오고 각 명령을 `-commands.json`에 기록합니다. Unity uGUI 문자는 Android native hierarchy에 노출되지 않으므로 PNG를 직접 열어 한국어와 화면 의미를 검사합니다. `android-observations.json`에는 아래 번호별 절차·기대와 실제 결과·근거 파일을 작성하며 APK hash·소스 SHA·기기 serial이 맞아야 합니다. 미실행·빈 파일·관찰 누락은 Smoke 실패입니다. 원본 상태는 앱 전용 저장 JSON을 ADB로 읽을 수 있을 때 추가 검증하며 release APK에 진단 서버·단계 우회·테스트 제어 기능을 넣지 않습니다. diff --git a/tools/android/compare-audio.ps1 b/tools/android/compare-audio.ps1 new file mode 100644 index 0000000..1abc7b8 --- /dev/null +++ b/tools/android/compare-audio.ps1 @@ -0,0 +1,58 @@ +param([Parameter(Mandatory)][string]$ReferencePath, [Parameter(Mandatory)][string]$CapturedPath, [Parameter(Mandatory)][string]$OutputPath) +$ErrorActionPreference='Stop' +Add-Type -TypeDefinition @' +using System; +using System.IO; +public static class AndroidPcmComparison { + static short[] Read(string path) { + using(var r=new BinaryReader(File.OpenRead(path))) { + if(new string(r.ReadChars(4))!="RIFF") throw new Exception("RIFF WAV required"); + r.ReadInt32(); if(new string(r.ReadChars(4))!="WAVE") throw new Exception("WAVE required"); + bool format=false; int rate=0; + while(r.BaseStream.Position+8<=r.BaseStream.Length) { + string id=new string(r.ReadChars(4)); int size=r.ReadInt32(); long end=r.BaseStream.Position+size; + if(id=="fmt ") { int encoding=r.ReadInt16(),channels=r.ReadInt16(); rate=r.ReadInt32(); r.ReadInt32(); r.ReadInt16(); format=encoding==1 && channels==1 && r.ReadInt16()==16 && rate>0; } + if(id=="data") { + if(!format) throw new Exception("Mono PCM16 required"); + var data=new short[size/2]; for(int i=0;ibestEnergy) { bestEnergy=energy; anchor=offset; } + } + if(bestEnergy<=0 || output.Length0 ? dot/Math.Sqrt(sourceAnchorEnergy*energy) : 0; + if(correlation>best) { best=correlation; bestLag=lag; } + } + double fullDot=0,sourceEnergy=0,outputEnergy=0; int nonzero=0; + for(int j=0;j32) nonzero++; } + double fullCorrelation=fullDot/Math.Sqrt(sourceEnergy*outputEnergy); + return new[]{fullCorrelation,bestLag/24000.0,source.Length/24000.0,output.Length/24000.0,Math.Sqrt(outputEnergy/source.Length),fullDot/sourceEnergy,(double)nonzero}; + } +} +'@ +$values=[AndroidPcmComparison]::Compare((Resolve-Path -LiteralPath $ReferencePath).Path,(Resolve-Path -LiteralPath $CapturedPath).Path) +$passed=$values[0] -ge 0.95 -and $values[4] -gt 32 -and $values[6] -gt 100 +$record=[ordered]@{ reference=$ReferencePath; referenceSha256=(Get-FileHash -LiteralPath $ReferencePath -Algorithm SHA256).Hash; + captured=$CapturedPath; capturedSha256=(Get-FileHash -LiteralPath $CapturedPath -Algorithm SHA256).Hash; + normalizedCorrelation=$values[0]; onsetSeconds=$values[1]; referenceDurationSeconds=$values[2]; capturedDurationSeconds=$values[3]; rmsPcm=$values[4]; gain=$values[5]; nonSilentSamples=$values[6]; thresholdCorrelation=0.95; passed=$passed; auditoryReview=$false } +$record | ConvertTo-Json | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "실제 PCM 대조: 상관 $($values[0]), 비무음 샘플 $($values[6]), 통과 $passed" +if (!$passed) { exit 1 } diff --git a/tools/android/exercise-first-slice.ps1 b/tools/android/exercise-first-slice.ps1 new file mode 100644 index 0000000..04e15a5 --- /dev/null +++ b/tools/android/exercise-first-slice.ps1 @@ -0,0 +1,114 @@ +param([string]$EvidenceDir='artifacts/verification/FirstSlice', [string]$Serial='emulator-5554') +$ErrorActionPreference='Stop' +$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path +$apkPath=(Resolve-Path -LiteralPath 'artifacts/android/FirstSlice/MagnetismHeroes.apk').Path +$checks=[Collections.Generic.List[object]]::new() +$recordings=[Collections.Generic.List[object]]::new() +function Step([string]$Name,[string[]]$Arguments,[int]$Delay=800) { + & (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay +} +function Save-State([string]$Name) { + Step $Name @('pull','/sdcard/Android/data/com.magnetismheroes.game/files/progress.json',(Join-Path $EvidenceDir "$Name.json")) + return (Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json) +} +function Assert-State([string]$Id,[bool]$Passed,[string]$Actual) { + $checks.Add([ordered]@{ id=$Id; passed=$Passed; actual=$Actual }) + if (!$Passed) { throw "저장 검증 실패: $Id ($Actual)" } +} +function Start-Audio([string]$Name,[int]$Seconds) { + $audioPath=Join-Path $EvidenceDir "$Name.wav" + $args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',$audioPath,'-Seconds',"$Seconds") + $process=Start-Process -FilePath 'pwsh' -ArgumentList ($args | ForEach-Object { '"'+$_+'"' }) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru + $recordings.Add(@{ name=$Name; process=$process; command=('pwsh '+($args -join ' ')) }) + Start-Sleep -Seconds 2 +} +function Restart([string]$Prefix) { + Step "$Prefix-pause" @('shell','input','keyevent','3') + Step "$Prefix-force-stop" @('shell','am','force-stop','com.magnetismheroes.game') + Step "$Prefix-home" @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000 +} +$result=[ordered]@{ sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); apkSha256=(Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash; serial=$Serial; status='failed'; recordings=@(); checks=@() } +try { + Assert-State 'clean-source' (!$result.sourceDirty) $result.sourceCommit + Step 'final-00-airplane-off' @('shell','cmd','connectivity','airplane-mode','disable') + Step 'final-00-wifi-on' @('shell','svc','wifi','enable') + Step 'final-00-data-on' @('shell','svc','data','enable') + # 검증 담당자가 이 실행에서 설치한 앱 전용 데이터만 초기화합니다. + Step 'final-00-reset-own-test-app' @('shell','pm','clear','com.magnetismheroes.game') + Step 'final-01-home' @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000 + Step 'final-02-selection' @('shell','input','tap','1200','616') + Start-Audio 'audio-online-guidecollect' 8 + Step 'final-03-stage' @('shell','input','tap','1200','560') 2500 + Step 'final-04-precollect-tap' @('shell','input','swipe','930','510','930','510','200') + $uncollected=Save-State 'final-04-uncollected' + Assert-State 'direct-collection-required' ($uncollected.puzzleStates[0].collectedBallIds.Count -eq 0 -and $uncollected.puzzleStates[0].countedBallIds.Count -eq 0 -and $uncollected.completedStageIds.Count -eq 0) ($uncollected | ConvertTo-Json -Depth 8 -Compress) + Start-Audio 'audio-online-guidecount' 8 + Step 'final-05-direct-collection' @('shell','input','swipe','930','510','1200','510','800') 4000 + Step 'final-06-camera-orbit' @('shell','input','swipe','1720','400','1860','470','600') + $before=Save-State 'final-06-incomplete-before' + Assert-State 'collected-with-empty-count' ($before.puzzleStates[0].collectedBallIds.Count -eq 1 -and $before.puzzleStates[0].countedBallIds.Count -eq 0) ($before | ConvertTo-Json -Depth 8 -Compress) + Restart 'final-07' + Step 'final-07-selection' @('shell','input','tap','1200','616') + Step 'final-07-restored-incomplete' @('shell','input','tap','1200','560') + $after=Save-State 'final-07-incomplete-after' + Assert-State 'incomplete-json-restored' (($before | ConvertTo-Json -Depth 8 -Compress) -eq ($after | ConvertTo-Json -Depth 8 -Compress)) '배치/카메라/그룹/수집/빈 계수 전체 JSON 동일' + Start-Audio 'audio-online-count01' 8 + Step 'final-08-success-same-board' @('shell','input','swipe','1182','514','1182','514','200') 1500 + $success=Save-State 'final-08-success' + Assert-State 'completion-atomic-state' ($success.completedStageIds.Count -eq 1 -and $success.completedStageIds[0] -eq 1 -and $success.puzzleStates.Count -eq 0 -and $success.latestFreePlay.sourceStageId -eq 1) ($success | ConvertTo-Json -Depth 8 -Compress) + Assert-State 'success-preserves-board-camera' (($before.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -and ($before.puzzleStates[0].camera | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.camera | ConvertTo-Json -Depth 6 -Compress)) '성공 전후 balls/camera 동일' + Step 'final-09-free-move' @('shell','input','swipe','1182','514','1380','470','800') + $freeBefore=Save-State 'final-09-free-before' + Assert-State 'free-move-changes-position' (($freeBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '실제 자유 놀이 balls 위치 변경' + Restart 'final-10' + Start-Audio 'audio-free-restore' 8 + Step 'final-10-continue-free' @('shell','input','tap','1200','810') + $freeAfter=Save-State 'final-10-free-after' + Assert-State 'free-json-restored' (($freeBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($freeAfter | ConvertTo-Json -Depth 8 -Compress)) '자유 놀이 전체 JSON 동일' + $restoreRecorder=$recordings | Where-Object name -eq 'audio-free-restore' | Select-Object -First 1 + if (!$restoreRecorder.process.WaitForExit(30000)) { throw '복원 음성 검사 수집 미종료' } + $silence=Get-Content -LiteralPath (Join-Path $EvidenceDir 'audio-free-restore.wav.json') -Raw | ConvertFrom-Json + Assert-State 'restore-no-historical-voice' ($silence.packets -gt 0 -and $silence.pcmBytes -gt 0 -and $silence.nonSilentSamples -eq 0) ($silence | ConvertTo-Json -Compress) + Step 'final-11-airplane-on' @('shell','cmd','connectivity','airplane-mode','enable') + Step 'final-11-wifi-off' @('shell','svc','wifi','disable') + Step 'final-11-data-off' @('shell','svc','data','disable') + Step 'final-11-wifi-state' @('shell','settings','get','global','wifi_on') + Step 'final-11-data-state' @('shell','settings','get','global','mobile_data') + Step 'final-11-connectivity' @('shell','dumpsys','connectivity') + Step 'final-11-airplane-state' @('shell','settings','get','global','airplane_mode_on') + $wifiState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-wifi-state-commands.json') -Raw | ConvertFrom-Json + $dataState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-data-state-commands.json') -Raw | ConvertFrom-Json + $connectivity=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-connectivity-commands.json') -Raw | ConvertFrom-Json + $airplaneState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-airplane-state-commands.json') -Raw | ConvertFrom-Json + Assert-State 'offline-network-disabled' ($wifiState.commands[0].output.Trim() -eq '0' -and $dataState.commands[0].output.Trim() -eq '0' -and $airplaneState.commands[0].output.Trim() -eq '1' -and $connectivity.commands[0].output -match 'Active default network: none') 'airplane=1, wifi=0, mobile_data=0, Active default network: none' + Step 'final-12-home' @('shell','input','tap','310','108') + Step 'final-12-selection' @('shell','input','tap','1200','616') + Start-Audio 'audio-offline' 20 + Step 'final-12-offline-stage' @('shell','input','tap','1200','560') 3000 + Step 'final-12-offline-collect' @('shell','input','swipe','930','510','1200','510','800') 4500 + $offlineCollected=Save-State 'final-12-offline-collected' + Assert-State 'offline-collected-unfinished' ($offlineCollected.puzzleStates.Count -eq 1 -and $offlineCollected.puzzleStates[0].collectedBallIds.Count -eq 1 -and $offlineCollected.puzzleStates[0].countedBallIds.Count -eq 0) '오프라인 실제 수집됨/계수 전 0' + Step 'final-12-offline-count-success' @('shell','input','swipe','1182','510','1182','510','200') 1500 + $offlineSuccess=Save-State 'final-12-offline-success' + Assert-State 'offline-collection-count-actually-success' ($offlineSuccess.completedStageIds.Count -eq 1 -and $offlineSuccess.completedStageIds[0] -eq 1 -and $offlineSuccess.puzzleStates.Count -eq 0 -and $offlineSuccess.latestFreePlay.sourceStageId -eq 1 -and ($offlineCollected.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '완료1/미완료없음/수집 전후 같은 IDs와 배치 자유 전이' + Step 'final-13-offline-free-move' @('shell','input','swipe','1182','510','1350','450','800') + $offlineBefore=Save-State 'final-13-offline-before' + Assert-State 'offline-free-move-changes-position' (($offlineBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '오프라인 실제 자유 놀이 balls 위치 변경' + Restart 'final-14' + Step 'final-14-offline-continue' @('shell','input','tap','1200','810') + $offlineAfter=Save-State 'final-14-offline-after' + Assert-State 'offline-json-restored' (($offlineBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($offlineAfter | ConvertTo-Json -Depth 8 -Compress)) '오프라인 자유 놀이 전체 JSON 동일' + foreach ($recording in $recordings) { + if (!$recording.process.WaitForExit(30000)) { throw "오디오 수집 미종료: $($recording.name)" } + $recording.process.Refresh() + $result.recordings += [ordered]@{ name=$recording.name; command=$recording.command; exitCode=$recording.process.ExitCode } + Assert-State "audio-recorder-$($recording.name)" ($recording.process.ExitCode -eq 0) "$($recording.process.ExitCode)" + } + $result.status='passed' +} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red } +finally { + $result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o') + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-state-checks.json') -Encoding utf8 +} +if ($result.status -ne 'passed') { exit 1 } +Write-Host "실제 Android 저장/복원 검사 $($checks.Count)개 통과. PNG 의미 검수는 별도입니다." diff --git a/tools/android/inspect-apk.ps1 b/tools/android/inspect-apk.ps1 index 46242d6..81765a7 100644 --- a/tools/android/inspect-apk.ps1 +++ b/tools/android/inspect-apk.ps1 @@ -1,4 +1,5 @@ -param([Parameter(Mandatory)][string]$ApkPath, [Parameter(Mandatory)][string]$EvidenceDir) +param([Parameter(Mandatory)][string]$ApkPath, [Parameter(Mandatory)][string]$EvidenceDir, + [string]$SourceCommit=(& git rev-parse HEAD), [bool]$SourceDirty=[bool](& git status --porcelain), [string]$MetadataName='apk-inspection.json') $ErrorActionPreference = 'Stop' $sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' } $aapt = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Filter aapt.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1 @@ -23,5 +24,5 @@ if (!$activity) { throw 'APK 시작 Activity를 찾지 못했습니다.' } if (($badging -join "`n") -match "uses-permission: name='android.permission.INTERNET'") { throw '오프라인 앱에 INTERNET 권한이 있습니다.' } [ordered]@{ apk=$ApkPath; apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash; package=$package; abis=$abis; activity=$activity; aaptCommand=('"' + $aapt.FullName + '" dump badging "' + $ApkPath + '"'); aaptExit=$nativeExit; - sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain) } | - ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Encoding utf8 + sourceCommit=$SourceCommit; sourceDirty=$SourceDirty } | + ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $EvidenceDir $MetadataName) -Encoding utf8 diff --git a/tools/android/record-audio.ps1 b/tools/android/record-audio.ps1 index 2e22a6e..eae0e97 100644 --- a/tools/android/record-audio.ps1 +++ b/tools/android/record-audio.ps1 @@ -1,5 +1,15 @@ param([Parameter(Mandatory)][string]$OutputPath, [int]$Seconds=15, [int]$Port=8554) $ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +public static class AndroidPcmLevels { + public static double[] Measure(byte[] bytes) { + double sum=0, peak=0; int active=0; + for(int i=0;i+132) active++; } + return new[]{peak, bytes.Length>0 ? Math.Sqrt(sum/(bytes.Length/2)) : 0, (double)active}; + } +} +'@ # EmulatorController.streamAudio의 PCM을 직접 기록합니다. 제품 APK에는 포함되지 않습니다. # wire schema: Android SDK emulator/lib/emulator_controller.proto. [AppContext]::SetSwitch('System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport', $true) @@ -19,11 +29,13 @@ $request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]:: $cancel = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($Seconds)) $pcm = [IO.MemoryStream]::new() $packets = 0 +$startedUtc=[DateTime]::UtcNow.ToString('o') +$firstPacketTimestamp=$null; $lastPacketTimestamp=$null function Read-Exactly($Stream,[int]$Length) { $bytes = [byte[]]::new($Length) $offset=0 while ($offset -lt $Length) { - $n=$Stream.ReadAsync($bytes.AsMemory($offset,$Length-$offset),$cancel.Token).AsTask().GetAwaiter().GetResult() + $n=$Stream.ReadAsync($bytes,$offset,$Length-$offset,$cancel.Token).GetAwaiter().GetResult() if ($n -eq 0) { throw 'gRPC 오디오 스트림이 끝났습니다.' } $offset += $n } @@ -42,13 +54,13 @@ try { while (!$cancel.IsCancellationRequested) { $header=Read-Exactly $stream 5 if ($header[0] -ne 0) { throw '압축 gRPC 프레임은 지원하지 않습니다.' } - $length=($header[1] -shl 24) -bor ($header[2] -shl 16) -bor ($header[3] -shl 8) -bor $header[4] + $length=([int]$header[1] -shl 24) -bor ([int]$header[2] -shl 16) -bor ([int]$header[3] -shl 8) -bor [int]$header[4] $message=Read-Exactly $stream $length $i=0 while ($i -lt $message.Length) { $tag=Read-Varint $message ([ref]$i) switch ($tag -band 7) { - 0 { $null=Read-Varint $message ([ref]$i) } + 0 { $value=Read-Varint $message ([ref]$i); if (($tag -shr 3) -eq 2) { if ($null -eq $firstPacketTimestamp) { $firstPacketTimestamp=$value }; $lastPacketTimestamp=$value } } 2 { $size=Read-Varint $message ([ref]$i) if (($tag -shr 3) -eq 3) { $pcm.Write($message,$i,$size); $packets++ } @@ -62,6 +74,7 @@ try { if (!$cancel.IsCancellationRequested) { throw } } finally { $data=$pcm.ToArray() + $levels=[AndroidPcmLevels]::Measure($data) $file=[IO.File]::Create($OutputPath) $writer=[IO.BinaryWriter]::new($file) try { @@ -69,7 +82,7 @@ try { $writer.Write([int]16); $writer.Write([int16]1); $writer.Write([int16]1); $writer.Write([int]24000); $writer.Write([int]48000); $writer.Write([int16]2); $writer.Write([int16]16); $writer.Write([Text.Encoding]::ASCII.GetBytes('data')); $writer.Write([int]$data.Length); $writer.Write($data) } finally { $writer.Dispose(); $file.Dispose(); $client.Dispose(); $cancel.Dispose(); $pcm.Dispose() } - [ordered]@{ method='EmulatorController.streamAudio'; endpoint="127.0.0.1:$Port"; packets=$packets; pcmBytes=$data.Length; sampleRate=24000; channels=1; bitsPerSample=16; auditoryReview=$false } | + [ordered]@{ method='EmulatorController.streamAudio'; endpoint="127.0.0.1:$Port"; sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); startedUtc=$startedUtc; finishedUtc=[DateTime]::UtcNow.ToString('o'); firstDevicePacketTimestampUs=$firstPacketTimestamp; lastDevicePacketTimestampUs=$lastPacketTimestamp; packets=$packets; pcmBytes=$data.Length; sampleRate=24000; channels=1; bitsPerSample=16; peakPcm=$levels[0]; rmsPcm=$levels[1]; nonSilentSamples=$levels[2]; auditoryReview=$false } | ConvertTo-Json | Set-Content -LiteralPath ($OutputPath + '.json') -Encoding utf8 } if ($packets -eq 0) { throw '실제 오디오 패킷을 받지 못했습니다.' } diff --git a/tools/android/smoke.ps1 b/tools/android/smoke.ps1 index 934c074..6dbb127 100644 --- a/tools/android/smoke.ps1 +++ b/tools/android/smoke.ps1 @@ -18,8 +18,12 @@ function Assert-Check([string]$Name,[bool]$Condition,[string]$Actual) { $result = [ordered]@{ milestone=$Milestone; serial=$Serial; sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); status='failed'; commands=@(); checks=@() } try { Assert-Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit + $buildRecord = Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json + $buildMetadata = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Raw | ConvertFrom-Json + $actualApkHash = (Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash + Assert-Check 'verified-build-provenance' ($buildRecord.status -eq 'passed' -and $buildRecord.exitCode -eq 0 -and $buildRecord.sourceDirty -eq $false -and $buildRecord.sourceDirtyEnd -eq $false -and $buildRecord.sourceCommit -eq $result.sourceCommit -and $buildRecord.sourceCommitEnd -eq $result.sourceCommit -and $buildRecord.apkSha256 -eq $actualApkHash -and $buildMetadata.sourceCommit -eq $buildRecord.sourceCommit -and $buildMetadata.apkSha256 -eq $actualApkHash -and $buildMetadata.sourceDirty -eq $false) $actualApkHash & (Join-Path $PSScriptRoot 'inspect-apk.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir - $apk = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Raw | ConvertFrom-Json + $apk = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-inspection.json') -Raw | ConvertFrom-Json $result.apkSha256 = $apk.apkSha256 $supported = (Invoke-Adb @('shell','getprop','ro.product.cpu.abilist')) -split ',' $native = $supported[0] @@ -44,14 +48,40 @@ try { $required = @('landscape-korean-assets','precollect-tap','direct-collection','camera-orbit','incomplete-restore','count-success-same-board','free-move','free-restore','offline-collection-count','offline-restore','guide-collect-playback','guide-count-playback','count01-playback') foreach ($id in $required) { $observations = @($manual.cases | Where-Object id -eq $id) - Assert-Check "observation-$id" ($observations.Count -eq 1 -and $observations[0].passed -eq $true -and $observations[0].actual -and $observations[0].procedure) (($observations | ForEach-Object actual) -join ' ') + Assert-Check "observation-$id" ($observations.Count -eq 1 -and $observations[0].passed -eq $true -and $observations[0].actual -and $observations[0].procedure -and $observations[0].expected) (($observations | ForEach-Object actual) -join ' ') foreach ($file in $observations[0].evidence) { $path = Join-Path $EvidenceDir $file Assert-Check "evidence-$id-$file" ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0) $file } Assert-Check "has-evidence-$id" (@($observations[0].evidence).Count -gt 0) (@($observations[0].evidence) -join ',') } + $voiceHashes = [ordered]@{ + guide_collect='62E15C9E38C36F4094A6C7E9C5148510141290B5F41DB5AAD9DC1D8F1A158594'; + guide_count='DEBE29B89F3E2856F2BF4C5E01707A5F0A0657E5768C03BE385EC88FA2328B40'; + count_01='13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100' + } + $audioMatches = @() + foreach ($voiceId in $voiceHashes.Keys) { + $reference = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$voiceId.wav" + Assert-Check "approved-wave-$voiceId" ((Get-FileHash -LiteralPath $reference -Algorithm SHA256).Hash -eq $voiceHashes[$voiceId]) $voiceHashes[$voiceId] + $comparisonPath = Join-Path $EvidenceDir "audio-offline-$voiceId-match.json" + $capturedPath = Join-Path $EvidenceDir 'audio-offline.wav' + & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath $capturedPath -OutputPath $comparisonPath + $comparisonExit = $LASTEXITCODE + $comparison = Get-Content -LiteralPath $comparisonPath -Raw | ConvertFrom-Json + Assert-Check "actual-pcm-$voiceId" ($comparisonExit -eq 0 -and $comparison.passed -eq $true -and $comparison.normalizedCorrelation -ge 0.95 -and $comparison.rmsPcm -gt 32 -and $comparison.referenceSha256 -eq $voiceHashes[$voiceId]) ($comparison | ConvertTo-Json -Compress) + $audioMatches += $comparison + } + Assert-Check 'offline-voice-order' ($audioMatches[0].onsetSeconds + $audioMatches[0].referenceDurationSeconds -le $audioMatches[1].onsetSeconds -and $audioMatches[1].onsetSeconds + $audioMatches[1].referenceDurationSeconds -le $audioMatches[2].onsetSeconds) (($audioMatches | ForEach-Object onsetSeconds) -join ',') + $result.audioComparisons=$audioMatches $result.manualObservations = $manual + $state = Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-state-checks.json') -Raw | ConvertFrom-Json + Assert-Check 'state-checks-provenance' ($state.sourceCommit -eq $result.sourceCommit -and $state.sourceDirty -eq $false -and $state.apkSha256 -eq $actualApkHash -and $state.serial -eq $Serial) $state.sourceCommit + Assert-Check 'state-checks-passed' ($state.status -eq 'passed' -and @($state.checks).Count -ge 8 -and @($state.checks | Where-Object passed -ne $true).Count -eq 0) "$(@($state.checks).Count)" + foreach ($id in @('direct-collection-required','collected-with-empty-count','incomplete-json-restored','completion-atomic-state','success-preserves-board-camera','free-move-changes-position','free-json-restored','restore-no-historical-voice','offline-network-disabled','offline-collected-unfinished','offline-collection-count-actually-success','offline-free-move-changes-position','offline-json-restored')) { + Assert-Check "required-state-$id" (@($state.checks | Where-Object { $_.id -eq $id -and $_.passed -eq $true }).Count -eq 1) $id + } + $result.stateChecks=$state $result.status='passed' } catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red } finally { diff --git a/tools/verify.ps1 b/tools/verify.ps1 index 9ca7416..aa899c4 100644 --- a/tools/verify.ps1 +++ b/tools/verify.ps1 @@ -45,8 +45,13 @@ try { if ($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $apkPath)) { throw 'Android build failed or produced no APK.' } $record.apk = $apkPath $record.apkSha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash - & (Join-Path $PSScriptRoot 'android/inspect-apk.ps1') -ApkPath $apkPath -EvidenceDir $evidenceDir + & (Join-Path $PSScriptRoot 'android/inspect-apk.ps1') -ApkPath $apkPath -EvidenceDir $evidenceDir -SourceCommit $record.sourceCommit -SourceDirty $record.sourceDirty -MetadataName 'apk-metadata.json' $record.apkMetadata = Join-Path $evidenceDir 'apk-metadata.json' + $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 the Android build or is dirty.' } if ($Milestone -eq 'Final') { Copy-Item -LiteralPath $apkPath -Destination (Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk') } @@ -54,8 +59,9 @@ try { $smokeScript = Join-Path $PSScriptRoot 'android/smoke.ps1' if (!(Test-Path -LiteralPath $smokeScript)) { throw 'Android smoke verification has not been implemented yet.' } $record.command = "pwsh -NoProfile -File tools/android/smoke.ps1 -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir" - & pwsh -NoProfile -File $smokeScript -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir + & pwsh -NoProfile -File $smokeScript -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir *> $logPath $record.exitCode = $LASTEXITCODE + Get-Content -LiteralPath $logPath | Select-Object -Last 5 | Write-Host if ($LASTEXITCODE -ne 0) { throw "Android smoke verification exited $LASTEXITCODE." } } $record.status = 'passed'