Bind Android smoke to build provenance and actual PCM assertions

This commit is contained in:
NINI
2026-10-05 03:46:17 +09:00
parent d656efe0dc
commit 4936180e45
7 changed files with 239 additions and 11 deletions
+6
View File
@@ -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을 가져오고 각 명령을 `<step>-commands.json`에 기록합니다. Unity uGUI 문자는 Android native hierarchy에 노출되지 않으므로 PNG를 직접 열어 한국어와 화면 의미를 검사합니다. `android-observations.json`에는 아래 번호별 절차·기대와 실제 결과·근거 파일을 작성하며 APK hash·소스 SHA·기기 serial이 맞아야 합니다. 미실행·빈 파일·관찰 누락은 Smoke 실패입니다. 원본 상태는 앱 전용 저장 JSON을 ADB로 읽을 수 있을 때 추가 검증하며 release APK에 진단 서버·단계 우회·테스트 제어 기능을 넣지 않습니다.
+58
View File
@@ -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;i<data.Length;i++) data[i]=r.ReadInt16();
if(rate==24000) return data;
var resampled=new short[(int)((long)data.Length*24000/rate)];
for(int i=0;i<resampled.Length;i++) { double index=(double)i*rate/24000; int left=(int)index; double fraction=index-left; resampled[i]=(short)(data[left]*(1-fraction)+data[Math.Min(left+1,data.Length-1)]*fraction); }
return resampled;
}
r.BaseStream.Position=end+(size%2);
}
}
throw new Exception("WAV data missing");
}
public static double[] Compare(string original,string captured) {
var source=Read(original); var output=Read(captured); int length=Math.Min(4096,source.Length);
double bestEnergy=-1; int anchor=0;
for(int offset=0;offset+length<=source.Length;offset+=512) {
double energy=0; for(int j=0;j<length;j++) energy+=(double)source[offset+j]*source[offset+j];
if(energy>bestEnergy) { bestEnergy=energy; anchor=offset; }
}
if(bestEnergy<=0 || output.Length<source.Length) throw new Exception("Non-silent complete reference/capture required");
double sourceAnchorEnergy=0; for(int j=0;j<length;j+=8) sourceAnchorEnergy+=(double)source[anchor+j]*source[anchor+j];
double best=-1; int bestLag=0;
for(int lag=0;lag+source.Length<=output.Length;lag++) {
double dot=0, energy=0;
for(int j=0;j<length;j+=8) { double value=output[lag+anchor+j]; dot+=source[anchor+j]*value; energy+=value*value; }
double correlation=energy>0 ? 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;j<source.Length;j++) { double s=source[j],o=output[bestLag+j]; fullDot+=s*o; sourceEnergy+=s*s; outputEnergy+=o*o; if(Math.Abs(o)>32) 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 }
+114
View File
@@ -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 의미 검수는 별도입니다."
+4 -3
View File
@@ -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
+17 -4
View File
@@ -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+1<bytes.Length;i+=2) { int sample=(short)(bytes[i] | (bytes[i+1]<<8)); sum+=(double)sample*sample; peak=Math.Max(peak,Math.Abs(sample)); if(Math.Abs(sample)>32) 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 '실제 오디오 패킷을 받지 못했습니다.' }
+32 -2
View File
@@ -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 {
+8 -2
View File
@@ -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'