Exclude Android release metadata and record emulator audio

This commit is contained in:
NINI
2026-10-05 03:22:04 +09:00
parent 0b43d45865
commit d656efe0dc
10 changed files with 191 additions and 14 deletions
+1
View File
@@ -8,6 +8,7 @@ UserSettings
Build
Builds
artifacts/
.utmp
.vs/
*.csproj
*.sln
@@ -0,0 +1,38 @@
using System.IO;
using System.Linq;
using System.Xml;
using UnityEditor;
using UnityEditor.Android;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace MagnetismHeroes.Editor
{
public sealed class AndroidBuildSanitizer : IPreprocessBuildWithReport, IPostGenerateGradleAndroidProject
{
public int callbackOrder => int.MaxValue;
public void OnPreprocessBuild(BuildReport report)
{
if (report.summary.platform != BuildTarget.Android || (report.summary.options & BuildOptions.IncludeTestAssemblies) != 0) return;
foreach (string path in new[] { "Assets/Resources/PerformanceTestRunInfo.json", "Assets/Resources/PerformanceTestRunSettings.json" })
if (File.Exists(path)) AssetDatabase.DeleteAsset(path);
if (Directory.Exists("Assets/Resources") && !Directory.EnumerateFileSystemEntries("Assets/Resources").Any())
AssetDatabase.DeleteAsset("Assets/Resources");
Debug.Log("[AndroidBuild] Generated performance-test metadata excluded from release assets.");
}
public void OnPostGenerateGradleAndroidProject(string path)
{
string manifestPath = Path.Combine(path, "src/main/AndroidManifest.xml");
var manifest = new XmlDocument(); manifest.Load(manifestPath);
var namespaces = new XmlNamespaceManager(manifest.NameTable);
namespaces.AddNamespace("android", "http://schemas.android.com/apk/res/android");
foreach (XmlNode permission in manifest.SelectNodes("/manifest/uses-permission[@android:name='android.permission.INTERNET']", namespaces))
permission.ParentNode.RemoveChild(permission);
manifest.Save(manifestPath);
Debug.Log("[AndroidBuild] Offline manifest: generated INTERNET permission removed.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c950257c624bf444a68a989a14c7118
@@ -90,8 +90,11 @@ namespace MagnetismHeroes.Editor
});
Debug.Log($"[AndroidBuild] {report.summary.result}: {report.summary.totalSize} bytes, {report.summary.totalErrors} errors.");
if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Android build did not succeed.");
File.WriteAllLines(apkPath + ".packed-assets.txt", report.packedAssets
.SelectMany(asset => asset.contents).Select(asset => asset.sourceAssetPath).Distinct().OrderBy(path => path));
var packedPaths = report.packedAssets.SelectMany(asset => asset.contents)
.Select(asset => asset.sourceAssetPath).Distinct().OrderBy(path => path).ToArray();
File.WriteAllLines(apkPath + ".packed-assets.txt", packedPaths);
if (packedPaths.Any(path => path.Contains("PerformanceTestRun")))
throw new InvalidOperationException("Generated performance-test metadata entered the release APK.");
}
}
}
@@ -379,6 +379,9 @@ MonoBehaviour:
highQualityFiltering:
m_OverrideState: 1
m_Value: 0
filter:
m_OverrideState: 1
m_Value: 0
downscale:
m_OverrideState: 1
m_Value: 0
+14 -10
View File
@@ -84,6 +84,7 @@ MonoBehaviour:
m_SupportScreenSpaceLensFlare: 1
m_GPUResidentDrawerMode: 0
m_SmallMeshScreenPercentage: 0
m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
m_ShadowType: 1
m_LocalShadowsSupported: 0
@@ -100,26 +101,26 @@ MonoBehaviour:
m_Keys: []
m_Values:
m_PrefilteringModeMainLightShadows: 3
m_PrefilteringModeAdditionalLight: 4
m_PrefilteringModeAdditionalLight: 3
m_PrefilteringModeAdditionalLightShadows: 0
m_PrefilterXRKeywords: 1
m_PrefilteringModeForwardPlus: 1
m_PrefilteringModeForwardPlus: 0
m_PrefilteringModeDeferredRendering: 0
m_PrefilteringModeScreenSpaceOcclusion: 0
m_PrefilteringModeScreenSpaceReflection: 1
m_PrefilteringModeScreenSpaceReflection: 0
m_PrefilterWriteSmoothness: 1
m_PrefilterDebugKeywords: 1
m_PrefilterWriteRenderingLayers: 1
m_PrefilterHDROutput: 1
m_PrefilterAlphaOutput: 0
m_PrefilterAlphaOutput: 1
m_PrefilterSSAODepthNormals: 1
m_PrefilterSSAOSourceDepthLow: 1
m_PrefilterSSAOSourceDepthMedium: 0
m_PrefilterSSAOSourceDepthMedium: 1
m_PrefilterSSAOSourceDepthHigh: 1
m_PrefilterSSAOInterleaved: 0
m_PrefilterSSAOInterleaved: 1
m_PrefilterSSAOBlueNoise: 1
m_PrefilterSSAOSampleCountLow: 1
m_PrefilterSSAOSampleCountMedium: 0
m_PrefilterSSAOSampleCountMedium: 1
m_PrefilterSSAOSampleCountHigh: 1
m_PrefilterDBufferMRT1: 1
m_PrefilterDBufferMRT2: 1
@@ -129,14 +130,17 @@ MonoBehaviour:
m_PrefilterSoftShadowsQualityHigh: 1
m_PrefilterSoftShadows: 0
m_PrefilterScreenCoord: 1
m_PrefilterScreenSpaceIrradiance: 0
m_PrefilterScreenSpaceIrradiance: 1
m_PrefilterNativeRenderPass: 1
m_PrefilterUseLegacyLightmaps: 0
m_PrefilterBicubicLightmapSampling: 0
m_PrefilterBicubicLightmapSampling: 1
m_PrefilterReflectionProbeRotation: 0
m_PrefilterReflectionProbeBlending: 0
m_PrefilterReflectionProbeBoxProjection: 0
m_PrefilterReflectionProbeAtlas: 0
m_PrefilterReflectionProbeAtlas: 1
m_PrefilterPointSamplingUpsampling: 1
m_Light2DPrefilteringMode: 0
m_Light2DKeptVariantCombos: []
m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0
m_Textures:
@@ -72,7 +72,21 @@ MonoBehaviour:
- rid: 6588237625532874761
- rid: 6588237625532874762
m_RuntimeSettings:
m_List: []
m_List:
- rid: 6852985685364965378
- rid: 6852985685364965380
- rid: 6852985685364965381
- rid: 6852985685364965384
- rid: 6852985685364965392
- rid: 6852985685364965394
- rid: 8712630790384254976
- rid: 4284062438471237637
- rid: 5747374308340793348
- rid: 6588237625532874753
- rid: 6588237625532874756
- rid: 6588237625532874757
- rid: 6588237625532874759
- rid: 6588237625532874761
m_AssetVersion: 11
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames:
+36
View File
@@ -0,0 +1,36 @@
# 첫 퍼즐 Android 검증
Unity 6000.6.3f1의 실제 Android 빌드는 ARM64 IL2CPP, 개발 모드 꺼짐, `BuildOptions.None`, Main 씬 하나로 구성합니다. Unity 6.5부터 Android x86_64 플레이어가 제거되었습니다([공식 변경 안내](https://docs.unity.com/ja-jp/engine/6000.5/manual/upgrade-guides/upgrade-guide-unity65)). 현재 API 37 Google Play AVD는 기본 시스템 ABI가 x86_64이며 지원 목록에 arm64-v8a, 번역기에 libndk_translation.so를 광고합니다. APK/기기 지원 ABI의 교집합과 실제 프로세스 실행을 검사하며 x86 네이티브 실행·실기기 성능 합격을 뜻하지 않습니다. ARM 번역의 원리는 [Google 공식 안내](https://android-developers.googleblog.com/2020/03/run-arm-apps-on-android-emulator.html)에 설명되어 있습니다.
Android Editor 콜백은 오프라인 앱에 자동 추가된 INTERNET 권한을 생성 manifest에서 제거합니다. Performance Testing 패키지의 order 0 콜백이 release에도 자동 생성하는 정확히 두 시험 JSON을 최후 순서 콜백에서 AssetDatabase API로 제거하며, 생성된 Resources 폴더가 비었을 때만 제거합니다. 다른 Resources 자산을 삭제하지 않습니다. URP가 실제 빌드에서 생성한 직렬화·shader prefilter 필드만 보존하며 YAML을 손으로 편집하지 않습니다. 전역 UPM/Gradle/Bee 캐시는 유지하고 Android 도구의 `.utmp` 캐시는 제외 규칙으로 처리합니다.
전용 프로젝트의 절대 경로에서 다음 두 명령을 실행합니다. 같은 프로젝트에 Editor와 batch를 동시에 열지 않습니다.
```powershell
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidBuild -Milestone FirstSlice
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone FirstSlice
```
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에 진단 서버·단계 우회·테스트 제어 기능을 넣지 않습니다.
| 번호 / 관찰 ID | 실제 절차와 기대 결과 |
| --- | --- |
| 1 landscape-korean-assets | 시작→친구 찾기→1. 가로 화면에 한글·숫자·자석 구 하나·얼굴 하나·번호 하나·기본 3D 소품과 재질을 확인합니다. |
| 2 precollect-tap | 자석을 수집 전에 탭합니다. 미완료 반응이 나오고 센 친구 0/1이며 계속 조작할 수 있어야 합니다. |
| 3 direct-collection | 구를 한 손가락으로 직접 끌어 놓습니다. 위치가 바뀌고 안내가 세기 단계로 바뀌며 수집됨·계수 0입니다. |
| 4 camera-orbit | 구·UI를 피한 빈 곳을 쓸어 카메라를 회전합니다. 같은 구가 유지되고 뷰가 달라집니다. |
| 5 incomplete-restore | 수집·이동·카메라 변경 후 Home/back으로 pause→force-stop→재실행→친구 찾기→1. 위치·카메라·수집 상태가 유지되고 0/1입니다. N=1에서는 첫 계수 즉시 성공하므로 부분 양수 계수 복원은 후속 N≥2 범위입니다. |
| 6 count-success-same-board | 복원된 구를 한 번 탭합니다. 같은 보드 배치로 자유 놀이터에 전환되고 친구 1이며 숫자 1 음성이 끝까지 재생됩니다. |
| 7 free-move | 성공한 같은 보드에서 구를 다시 끌어 위치를 바꿉니다. 새 화면으로 옮기지 않고 자유 조작이 됩니다. |
| 8 free-restore | pause→force-stop→재실행→자유 놀이 이어하기. 성공 후 위치·카메라·친구 1이 복원되고 과거 계수 음성을 다시 읽지 않습니다. |
| 9 offline-collection-count | Wi-Fi와 mobile data를 끈 실제 상태를 기록한 뒤 친구 찾기→1을 다시 플레이합니다. 직접 끌기와 탭 성공, 세 음성 출력이 네트워크 없이 동작합니다. |
| 10 offline-restore | 오프라인 자유 놀이 배치를 바꾼 뒤 pause→force-stop→재실행→이어하기. 배치·카메라가 유지됩니다. |
| 11 guide-collect-playback | 1 진입 시 실제 에뮬레이터 PCM을 받아 비무음 구간과 승인 guide_collect 원본 파형을 대조합니다. |
| 12 guide-count-playback | 직접 수집 시 실제 PCM을 받아 승인 guide_count 원본과 대조합니다. |
| 13 count01-playback | 한 번 계수 성공 시 실제 PCM을 받아 승인 count_01 원본과 대조하며 자유 놀이 전환 뒤에도 마지막 읽기가 유지되는지 확인합니다. |
`tools/android/record-audio.ps1`은 localhost EmulatorController.streamAudio의 24kHz mono S16 PCM을 WAV로 기록합니다. SDK에 설치된 공식 `emulator_controller.proto`의 wire format을 사용하고 외부 패키지 설치를 하지 않습니다([공식 서비스 정의](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/emulator/proto/emulator_controller.proto)). 관찰을 위해 AVD를 `-grpc-use-token`으로 시작하고 기본 localhost 바인딩을 확인합니다. 도구는 로컬 discovery 파일의 토큰을 메모리에서만 읽고 공개 근거에 인증키·비밀을 쓰지 않습니다. 패킷 수만으로 음성 재생을 통과시키지 않고 실제 비무음 신호·승인 WAV 대비 파형/시간을 평가합니다. 사람의 Android 출력 청취 검수와는 구분합니다.
승인·최종 음성 주장은 count_01·guide_collect·guide_count 세 파일만 대상으로 합니다. 그 밖의 로컬 숫자 파일은 미검수 초안이며 count_05는 런타임에서 제외되어 있습니다. 모델/conda/SourceAssets는 APK 제작 입력에 포함되지 않습니다. 효과음·BGM은 사용자가 나중에 추가하기로 했으며 아직 없습니다. 이 검증은 첫 퍼즐이며 30개 게임 완성, iOS, 배포, 실기기 성능 합격을 뜻하지 않습니다.
+1 -1
View File
@@ -13,7 +13,7 @@ try {
$abis = @($entries | Where-Object { $_ -match '^lib/([^/]+)/libil2cpp\.so$' } | ForEach-Object { ($_ -split '/')[1] })
if ($abis.Count -eq 0) { throw 'APK에 IL2CPP 라이브러리가 없습니다.' }
foreach ($abi in $abis) { if ($entries -notcontains "lib/$abi/libunity.so") { throw "$abi Unity 런타임 누락" } }
if ($entries | Where-Object { $_ -match '(?i)(SourceAssets|conda|Qwen|safetensors)' }) { throw '제작 환경 파일이 APK에 포함되었습니다.' }
if ($entries | Where-Object { $_ -match '(?i)(^|/)(SourceAssets|conda|Qwen[^/]*)(/|$)|\.safetensors$' }) { throw '제작 환경 파일이 APK에 포함되었습니다.' }
$entries | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-entries.txt') -Encoding utf8
} finally { $zip.Dispose() }
$package = [regex]::Match(($badging -join "`n"), "package: name='([^']+)'").Groups[1].Value
+76
View File
@@ -0,0 +1,76 @@
param([Parameter(Mandatory)][string]$OutputPath, [int]$Seconds=15, [int]$Port=8554)
$ErrorActionPreference = 'Stop'
# EmulatorController.streamAudio의 PCM을 직접 기록합니다. 제품 APK에는 포함되지 않습니다.
# wire schema: Android SDK emulator/lib/emulator_controller.proto.
[AppContext]::SetSwitch('System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport', $true)
$client = [Net.Http.HttpClient]::new()
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post, "http://127.0.0.1:$Port/android.emulation.control.EmulatorController/streamAudio")
$request.Version = [Version]::new(2,0)
$request.VersionPolicy = [Net.Http.HttpVersionPolicy]::RequestVersionExact
$request.Headers.Add('TE','trailers')
$discovery = Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'Temp/avd/running') -Filter 'pid_*.ini' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (!$discovery) { throw 'AVD gRPC discovery 파일을 찾지 못했습니다.' }
$tokenLine = Get-Content -LiteralPath $discovery.FullName | Where-Object { $_ -match '^grpc.token=' } | Select-Object -First 1
if (!$tokenLine) { throw 'AVD를 -grpc-use-token으로 시작해야 합니다. 토큰을 로그에 기록하지 않습니다.' }
$request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $tokenLine.Substring('grpc.token='.Length))
# AudioFormat: samplingRate=24000, channels=Mono(default), format=AUD_FMT_S16.
$request.Content = [Net.Http.ByteArrayContent]::new([byte[]]@(0,0,0,0,6,8,192,187,1,24,1))
$request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/grpc')
$cancel = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($Seconds))
$pcm = [IO.MemoryStream]::new()
$packets = 0
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()
if ($n -eq 0) { throw 'gRPC 오디오 스트림이 끝났습니다.' }
$offset += $n
}
return ,$bytes
}
function Read-Varint([byte[]]$Bytes,[ref]$Index) {
[long]$value=0; $shift=0
do { $b=$Bytes[$Index.Value]; $Index.Value++; $value=$value -bor (($b -band 127) -shl $shift); $shift+=7 } while ($b -band 128)
return $value
}
$status='failed'
try {
$response=$client.SendAsync($request,[Net.Http.HttpCompletionOption]::ResponseHeadersRead,$cancel.Token).GetAwaiter().GetResult()
$response.EnsureSuccessStatusCode() | Out-Null
$stream=$response.Content.ReadAsStream()
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]
$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) }
2 {
$size=Read-Varint $message ([ref]$i)
if (($tag -shr 3) -eq 3) { $pcm.Write($message,$i,$size); $packets++ }
$i += $size
}
default { throw "예상하지 않은 protobuf wire type: $tag" }
}
}
}
} catch {
if (!$cancel.IsCancellationRequested) { throw }
} finally {
$data=$pcm.ToArray()
$file=[IO.File]::Create($OutputPath)
$writer=[IO.BinaryWriter]::new($file)
try {
$writer.Write([Text.Encoding]::ASCII.GetBytes('RIFF')); $writer.Write([int](36+$data.Length)); $writer.Write([Text.Encoding]::ASCII.GetBytes('WAVEfmt '));
$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 } |
ConvertTo-Json | Set-Content -LiteralPath ($OutputPath + '.json') -Encoding utf8
}
if ($packets -eq 0) { throw '실제 오디오 패킷을 받지 못했습니다.' }
Write-Host "에뮬레이터 실제 PCM $packets 패킷, $($data.Length)바이트: $OutputPath"