From 0b43d458657ce1ad46847ffb77a59951f79d8a6c Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:12:45 +0900
Subject: [PATCH 1/8] Add Android APK inspection and device verification gates
---
.../Editor/ProjectBootstrap.cs | 13 ++++
tools/android/inspect-apk.ps1 | 27 ++++++++
tools/android/observe.ps1 | 29 +++++++++
tools/android/smoke.ps1 | 63 +++++++++++++++++++
tools/verify.ps1 | 2 +
5 files changed, 134 insertions(+)
create mode 100644 tools/android/inspect-apk.ps1
create mode 100644 tools/android/observe.ps1
create mode 100644 tools/android/smoke.ps1
diff --git a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
index ca15805..34ee5ee 100644
--- a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
+++ b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
@@ -67,6 +67,17 @@ namespace MagnetismHeroes.Editor
Debug.Log("[Integration] Main connected to GameApp.");
}
+ public static void ConfigureAndroid()
+ {
+ PlayerSettings.SetScriptingBackend(UnityEditor.Build.NamedBuildTarget.Android, ScriptingImplementation.IL2CPP);
+ PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64;
+ PlayerSettings.Android.buildApkPerCpuArchitecture = false;
+ PlayerSettings.defaultInterfaceOrientation = UIOrientation.LandscapeLeft;
+ PlayerSettings.Android.forceInternetPermission = false;
+ AssetDatabase.SaveAssets();
+ Debug.Log("[AndroidConfiguration] ARM64 IL2CPP, landscape, no forced Internet permission.");
+ }
+
public static void BuildAndroid()
{
string apkPath = Environment.GetEnvironmentVariable("MAGNETISM_APK_PATH");
@@ -79,6 +90,8 @@ 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));
}
}
}
diff --git a/tools/android/inspect-apk.ps1 b/tools/android/inspect-apk.ps1
new file mode 100644
index 0000000..74d4aef
--- /dev/null
+++ b/tools/android/inspect-apk.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory)][string]$ApkPath, [Parameter(Mandatory)][string]$EvidenceDir)
+$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
+if (!$aapt) { throw 'APK manifest 검사에 필요한 aapt를 찾지 못했습니다.' }
+$badging = & $aapt.FullName dump badging $ApkPath
+$nativeExit = $LASTEXITCODE
+$badging | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-badging.txt') -Encoding utf8
+if ($nativeExit -ne 0) { throw "aapt 종료 코드: $nativeExit" }
+$zip = [IO.Compression.ZipFile]::OpenRead($ApkPath)
+try {
+ $entries = @($zip.Entries | ForEach-Object FullName)
+ $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에 포함되었습니다.' }
+ $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
+$activity = [regex]::Match(($badging -join "`n"), "launchable-activity: name='([^']+)'").Groups[1].Value
+if ($package -ne 'com.magnetismheroes.game') { throw "예상하지 않은 패키지: $package" }
+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
diff --git a/tools/android/observe.ps1 b/tools/android/observe.ps1
new file mode 100644
index 0000000..d344ebe
--- /dev/null
+++ b/tools/android/observe.ps1
@@ -0,0 +1,29 @@
+param(
+ [Parameter(Mandatory)][ValidatePattern('^[a-z0-9-]+$')][string]$Step,
+ [string[]]$AdbArguments = @(), [string]$Serial = 'emulator-5554',
+ [string]$EvidenceDir = 'artifacts/verification/FirstSlice', [int]$DelayMilliseconds = 800,
+ [switch]$Audio
+)
+$ErrorActionPreference = 'Stop'
+$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
+$adb = Join-Path $sdk 'platform-tools/adb.exe'
+New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null
+$commands = [Collections.Generic.List[object]]::new()
+function Invoke-Adb([string[]]$Arguments) {
+ $output = & $adb -s $Serial @Arguments 2>&1
+ $code = $LASTEXITCODE
+ $commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") })
+ if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" }
+ return $output
+}
+try {
+ if ($AdbArguments.Count) { Invoke-Adb $AdbArguments | Out-Null }
+ if ($Audio) { Invoke-Adb @('shell','dumpsys','media.audio_flinger') | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-audio-flinger.txt") -Encoding utf8 }
+ Start-Sleep -Milliseconds $DelayMilliseconds
+ Invoke-Adb @('shell','screencap','-p',"/sdcard/magnetismheroes-$Step.png") | Out-Null
+ Invoke-Adb @('pull',"/sdcard/magnetismheroes-$Step.png",(Join-Path $EvidenceDir "$Step.png")) | Out-Null
+ Invoke-Adb @('shell','rm',"/sdcard/magnetismheroes-$Step.png") | Out-Null
+} finally {
+ [ordered]@{ step=$Step; observedUtc=[DateTime]::UtcNow.ToString('o'); serial=$Serial; sourceCommit=(& git rev-parse HEAD);
+ commands=$commands.ToArray() } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-commands.json") -Encoding utf8
+}
diff --git a/tools/android/smoke.ps1 b/tools/android/smoke.ps1
new file mode 100644
index 0000000..934c074
--- /dev/null
+++ b/tools/android/smoke.ps1
@@ -0,0 +1,63 @@
+param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
+$ErrorActionPreference = 'Stop'
+$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
+$adb = Join-Path $sdk 'platform-tools/adb.exe'
+$commands = [Collections.Generic.List[object]]::new()
+$checks = [Collections.Generic.List[object]]::new()
+function Invoke-Adb([string[]]$Arguments) {
+ $output = & $adb -s $Serial @Arguments 2>&1
+ $code = $LASTEXITCODE
+ $commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") })
+ if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" }
+ return ($output -join "`n").Trim()
+}
+function Assert-Check([string]$Name,[bool]$Condition,[string]$Actual) {
+ $checks.Add([ordered]@{ criterion=$Name; passed=$Condition; actual=$Actual })
+ if (!$Condition) { throw "검사 실패: $Name ($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
+ & (Join-Path $PSScriptRoot 'inspect-apk.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir
+ $apk = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Raw | ConvertFrom-Json
+ $result.apkSha256 = $apk.apkSha256
+ $supported = (Invoke-Adb @('shell','getprop','ro.product.cpu.abilist')) -split ','
+ $native = $supported[0]
+ $bridge = Invoke-Adb @('shell','getprop','ro.dalvik.vm.native.bridge')
+ $intersection = @($apk.abis | Where-Object { $supported -contains $_ })
+ $result.device = [ordered]@{ supportedAbis=$supported; nativeArchitecture=$native; bridge=$bridge; apkSupportedIntersection=$intersection;
+ model=(Invoke-Adb @('shell','getprop','ro.product.model')); api=(Invoke-Adb @('shell','getprop','ro.build.version.sdk'));
+ features=(Invoke-Adb @('shell','pm','list','features')); graphics=(Invoke-Adb @('shell','dumpsys','SurfaceFlinger')) }
+ Assert-Check 'supported-abi-intersection' ($intersection.Count -gt 0) ($intersection -join ',')
+ if ($supported[0] -ne $intersection[0]) { Assert-Check 'arm-native-bridge' ($bridge -match '^lib.+\.so$') $bridge }
+ Invoke-Adb @('install','-r',$ApkPath) | Out-Null
+ Invoke-Adb @('shell','am','start','-W','-n',($apk.package + '/' + $apk.activity)) | Out-Null
+ Start-Sleep -Seconds 3
+ $pidValue = Invoke-Adb @('shell','pidof',$apk.package)
+ Assert-Check 'running-process' ($pidValue -match '^\d+') $pidValue
+ Invoke-Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
+ & (Join-Path $PSScriptRoot 'observe.ps1') -Step 'smoke-home' -Serial $Serial -EvidenceDir $EvidenceDir
+ $manualPath = Join-Path $EvidenceDir 'android-observations.json'
+ Assert-Check 'observations-present' (Test-Path -LiteralPath $manualPath) $manualPath
+ $manual = Get-Content -LiteralPath $manualPath -Raw | ConvertFrom-Json
+ Assert-Check 'observations-match-apk' ($manual.apkSha256 -eq $apk.apkSha256 -and $manual.sourceCommit -eq $result.sourceCommit -and $manual.serial -eq $Serial) $manual.apkSha256
+ $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 ' ')
+ 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 ',')
+ }
+ $result.manualObservations = $manual
+ $result.status='passed'
+} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
+finally {
+ $result.commands=$commands.ToArray(); $result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
+ $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-device-evidence.json') -Encoding utf8
+}
+if ($result.status -ne 'passed') { exit 1 }
+Write-Host "Android 실제 기기 검사와 $($required.Count)개 관찰 검사 통과. 근거: $EvidenceDir"
+exit 0
diff --git a/tools/verify.ps1 b/tools/verify.ps1
index 03974a5..9ca7416 100644
--- a/tools/verify.ps1
+++ b/tools/verify.ps1
@@ -45,6 +45,8 @@ 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
+ $record.apkMetadata = Join-Path $evidenceDir 'apk-metadata.json'
if ($Milestone -eq 'Final') {
Copy-Item -LiteralPath $apkPath -Destination (Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk')
}
From d656efe0dc1335c045659ee1424cafc41f52fe63 Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:22:04 +0900
Subject: [PATCH 2/8] Exclude Android release metadata and record emulator
audio
---
.gitignore | 1 +
.../Editor/AndroidBuildSanitizer.cs | 38 ++++++++++
.../Editor/AndroidBuildSanitizer.cs.meta | 2 +
.../Editor/ProjectBootstrap.cs | 7 +-
Assets/Settings/DefaultVolumeProfile.asset | 3 +
Assets/Settings/Mobile_RPAsset.asset | 24 +++---
...niversalRenderPipelineGlobalSettings.asset | 16 +++-
docs/android-first-slice.md | 36 +++++++++
tools/android/inspect-apk.ps1 | 2 +-
tools/android/record-audio.ps1 | 76 +++++++++++++++++++
10 files changed, 191 insertions(+), 14 deletions(-)
create mode 100644 Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs
create mode 100644 Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs.meta
create mode 100644 docs/android-first-slice.md
create mode 100644 tools/android/record-audio.ps1
diff --git a/.gitignore b/.gitignore
index c50e006..27df77d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ UserSettings
Build
Builds
artifacts/
+.utmp
.vs/
*.csproj
*.sln
diff --git a/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs b/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs
new file mode 100644
index 0000000..71266ff
--- /dev/null
+++ b/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs
@@ -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.");
+ }
+ }
+}
diff --git a/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs.meta b/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs.meta
new file mode 100644
index 0000000..bfe9495
--- /dev/null
+++ b/Assets/MagnetismHeroes/Editor/AndroidBuildSanitizer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5c950257c624bf444a68a989a14c7118
\ No newline at end of file
diff --git a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
index 34ee5ee..2fd0afe 100644
--- a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
+++ b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs
@@ -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.");
}
}
}
diff --git a/Assets/Settings/DefaultVolumeProfile.asset b/Assets/Settings/DefaultVolumeProfile.asset
index 9e4bbfd..66d98de 100644
--- a/Assets/Settings/DefaultVolumeProfile.asset
+++ b/Assets/Settings/DefaultVolumeProfile.asset
@@ -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
diff --git a/Assets/Settings/Mobile_RPAsset.asset b/Assets/Settings/Mobile_RPAsset.asset
index 521491e..199dc88 100644
--- a/Assets/Settings/Mobile_RPAsset.asset
+++ b/Assets/Settings/Mobile_RPAsset.asset
@@ -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:
diff --git a/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset b/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
index 3cc7fb3..5064e25 100644
--- a/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
+++ b/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
@@ -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:
diff --git a/docs/android-first-slice.md b/docs/android-first-slice.md
new file mode 100644
index 0000000..2bf22a1
--- /dev/null
+++ b/docs/android-first-slice.md
@@ -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을 가져오고 각 명령을 `-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, 배포, 실기기 성능 합격을 뜻하지 않습니다.
diff --git a/tools/android/inspect-apk.ps1 b/tools/android/inspect-apk.ps1
index 74d4aef..46242d6 100644
--- a/tools/android/inspect-apk.ps1
+++ b/tools/android/inspect-apk.ps1
@@ -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
diff --git a/tools/android/record-audio.ps1 b/tools/android/record-audio.ps1
new file mode 100644
index 0000000..2e22a6e
--- /dev/null
+++ b/tools/android/record-audio.ps1
@@ -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"
From 4936180e453b985107ddd0630f47224f6d8867fd Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:46:17 +0900
Subject: [PATCH 3/8] 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'
From 92cbb52b73a6e723644c12db36837296f5952743 Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:49:06 +0900
Subject: [PATCH 4/8] Require final source provenance for captured Android PCM
---
tools/android/smoke.ps1 | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tools/android/smoke.ps1 b/tools/android/smoke.ps1
index 6dbb127..2ffca12 100644
--- a/tools/android/smoke.ps1
+++ b/tools/android/smoke.ps1
@@ -61,6 +61,8 @@ try {
count_01='13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100'
}
$audioMatches = @()
+ $audioMetadata = Get-Content -LiteralPath (Join-Path $EvidenceDir 'audio-offline.wav.json') -Raw | ConvertFrom-Json
+ Assert-Check 'actual-pcm-provenance' ($audioMetadata.sourceCommit -eq $result.sourceCommit -and $audioMetadata.sourceDirty -eq $false -and $audioMetadata.packets -gt 0 -and $audioMetadata.pcmBytes -gt 0 -and [DateTime]$audioMetadata.startedUtc -ge [DateTime]$buildRecord.finishedUtc) $audioMetadata.sourceCommit
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]
From f3f1f14fb1a4521cf8b257b1c9dedb4e45982edf Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:51:27 +0900
Subject: [PATCH 5/8] Add negative Android provenance checks and short tap
exercise
---
docs/android-first-slice.md | 2 ++
tools/android/exercise-first-slice.ps1 | 6 +++---
tools/android/test-provenance.ps1 | 26 ++++++++++++++++++++++++++
3 files changed, 31 insertions(+), 3 deletions(-)
create mode 100644 tools/android/test-provenance.ps1
diff --git a/docs/android-first-slice.md b/docs/android-first-slice.md
index 6874b5a..8db0097 100644
--- a/docs/android-first-slice.md
+++ b/docs/android-first-slice.md
@@ -17,6 +17,8 @@ pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone FirstSlice
`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 검사 복사본은 실제 기기 조작 전 실패해야 합니다.
+그 거부 동작은 실제 Build 뒤 `pwsh -NoProfile -File tools/android/test-provenance.ps1`로 검사합니다. 원본 근거를 손상시키지 않고 별도 복사본 두 개의 소스 SHA/APK hash만 바꿔 Smoke의 실제 종료 1 및 기기 명령 0개를 확인합니다. 음성은 raw WAV뿐 아니라 수집 당시 소스 SHA/dirty·시각 메타데이터도 최종 Build와 맞아야 합니다. 입력 수정 전 ADB 짧은 `input tap` 두 번은 구 탭을 놓쳤고 같은 좌표 200ms 접촉은 정상 반응했습니다. 수정 전 원시 자료는 `pre-fix/`에 보존하고 수정 후 최종 절차는 짧은 ADB tap으로 다시 확인합니다. 물리 기기의 실제 손가락 동작이나 성능 합격으로 확대하지 않습니다.
+
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/exercise-first-slice.ps1 b/tools/android/exercise-first-slice.ps1
index 04e15a5..88b4a39 100644
--- a/tools/android/exercise-first-slice.ps1
+++ b/tools/android/exercise-first-slice.ps1
@@ -39,7 +39,7 @@ try {
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')
+ Step 'final-04-precollect-tap' @('shell','input','tap','930','510')
$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
@@ -53,7 +53,7 @@ try {
$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
+ Step 'final-08-success-same-board' @('shell','input','tap','1182','514') 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 동일'
@@ -88,7 +88,7 @@ try {
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
+ Step 'final-12-offline-count-success' @('shell','input','tap','1182','510') 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')
diff --git a/tools/android/test-provenance.ps1 b/tools/android/test-provenance.ps1
new file mode 100644
index 0000000..f8d7918
--- /dev/null
+++ b/tools/android/test-provenance.ps1
@@ -0,0 +1,26 @@
+param([string]$EvidenceDir='artifacts/verification/FirstSlice')
+$ErrorActionPreference='Stop'
+$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
+$apkPath=(Resolve-Path -LiteralPath 'artifacts/android/FirstSlice/MagnetismHeroes.apk').Path
+$source=& git rev-parse HEAD
+$build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
+if ($build.sourceCommit -ne $source -or $build.sourceDirty -ne $false -or $build.sourceDirtyEnd -ne $false -or $build.apkSha256 -ne (Get-FileHash -LiteralPath $apkPath).Hash) { throw '현재 clean 소스의 실제 Build 근거가 있어야 부정 검사를 실행할 수 있습니다.' }
+$results=@()
+foreach ($field in @('sourceCommit','apkSha256')) {
+ $caseDir=Join-Path $EvidenceDir "red/provenance-$field"
+ New-Item -ItemType Directory -Path $caseDir -Force | Out-Null
+ $copy=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
+ $copy.$field='deliberately-invalid-verification-copy'
+ $copy | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $caseDir 'androidbuild-evidence.json') -Encoding utf8
+ Copy-Item -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Destination (Join-Path $caseDir 'apk-metadata.json')
+ $log=Join-Path $caseDir 'negative-smoke.log'
+ $smoke=Join-Path $PSScriptRoot 'smoke.ps1'
+ & pwsh -NoProfile -File $smoke -ApkPath $apkPath -Milestone FirstSlice -EvidenceDir $caseDir *> $log
+ $code=$LASTEXITCODE
+ $outcome=Get-Content -LiteralPath (Join-Path $caseDir 'android-device-evidence.json') -Raw | ConvertFrom-Json
+ $passed=$code -eq 1 -and $outcome.status -eq 'failed' -and @($outcome.commands).Count -eq 0 -and $outcome.checks[-1].criterion -eq 'verified-build-provenance' -and $outcome.checks[-1].passed -eq $false
+ $results += [ordered]@{ field=$field; command="pwsh -NoProfile -File $smoke -ApkPath $apkPath -Milestone FirstSlice -EvidenceDir $caseDir"; actualExitCode=$code; deviceCommandCount=@($outcome.commands).Count; passed=$passed; log=$log }
+}
+[ordered]@{ sourceCommit=$source; apkSha256=$build.apkSha256; cases=$results } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'negative-provenance-results.json') -Encoding utf8
+if (@($results | Where-Object passed -ne $true).Count) { throw '출처가 틀린 검사 복사본을 기기 조작 전에 거부하지 못했습니다.' }
+Write-Host '소스 SHA/APK hash 손상 복사본 2개를 실제 종료 코드 1로 기기 조작 전 거부했습니다.'
From e37b6af9844f74e2eeb46350b48e31177aae48bc Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 03:54:53 +0900
Subject: [PATCH 6/8] Preserve short touch events through official
EnhancedTouch history
---
.../Tests/PlayMode/FirstPuzzlePlayTests.cs | 73 ++++++++
.../UnityRuntime/Interaction/BoardInput.cs | 169 +++++++++++-------
2 files changed, 181 insertions(+), 61 deletions(-)
diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs
index 5da96fa..f6854da 100644
--- a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs
+++ b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs
@@ -60,6 +60,79 @@ namespace MagnetismHeroes.Tests
}
private void Drag(Vector2 start, Vector2 end) { _app.Input.Begin(start, false); _app.Input.Move(end); _app.Input.End(end); }
private void Tap(Vector2 point) { _app.Input.Begin(point, false); _app.Input.End(point); }
+ private void QueueTouch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) =>
+ InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = id, phase = phase, position = position });
+
+ [UnityTest] public IEnumerator SameUpdateShortTapRespondsBeforeCollectionAndCountsOnceAfterCollection()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
+ var point = BallPoint(); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ yield return null; yield return null;
+ Assert.That(_app.Instruction, Does.Contain("먼저")); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
+ Drag(point, point + new Vector2(70, 20)); yield return null; Physics.SyncTransforms(); point = BallPoint();
+ QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ yield return null; yield return null;
+ Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
+ Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
+ }
+
+ [UnityTest] public IEnumerator SameUpdateFastDragMovesWithoutCountingAnAlreadyCollectedFriend()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
+ var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
+ var original = _app.Session.Board.Balls.Single().position;
+ QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(40, 15));
+ QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(40, 15)); yield return null; yield return null;
+ Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
+ Assert.That(_app.Session.Board.Balls.Single().position, Is.Not.EqualTo(original));
+ }
+
+ [UnityTest] public IEnumerator SameUpdateCanceledAndOutsideDragsPreserveCommittedState()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms(); _app.Session.SaveCurrent();
+ var point = BallPoint(); var original = _app.Session.Board.Balls.Single().position; var saved = JsonUtility.ToJson(_store.Saved);
+ foreach (var phase in new[] { UnityEngine.InputSystem.TouchPhase.Canceled, UnityEngine.InputSystem.TouchPhase.Ended })
+ {
+ QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(50, 10));
+ QueueTouch(121, phase, phase == UnityEngine.InputSystem.TouchPhase.Ended ? new Vector2(-1, point.y) : point + new Vector2(50, 10));
+ yield return null; yield return null;
+ Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(original)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
+ Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
+ }
+ }
+
+ [UnityTest] public IEnumerator SameUpdateTouchBeginningOnUiNeverCountsTheFriendAtRelease()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
+ var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
+ var button = _app.transform.Find("Interface/SafeArea/Play/HomeButton").GetComponent();
+ Vector2 uiPoint = button.TransformPoint(button.rect.center);
+ QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Began, uiPoint); QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ yield return null; yield return null;
+ Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
+ }
+
+ [UnityTest] public IEnumerator SameUpdateSequentialShortTapsCompleteOnlyOnce()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
+ var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
+ QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ yield return null; yield return null;
+ Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
+ Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
+ }
+
+ [UnityTest] public IEnumerator SameUpdateConcurrentShortPinchNeverCountsTheFriend()
+ {
+ _touchscreen = InputSystem.AddDevice(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
+ var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
+ QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Began, point + new Vector2(80, 0));
+ QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(160, 0)); QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Ended, point);
+ QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(160, 0)); yield return null; yield return null;
+ Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
+ Assert.That(_app.Session.Camera.distance, Is.LessThan(8));
+ }
private void DragTo(Vector3 destination)
{
Physics.SyncTransforms(); var start = BallPoint();
diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs
index 7db4790..fb2b8da 100644
--- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs
+++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs
@@ -5,6 +5,7 @@ using MagnetismHeroes.UnityRuntime.Presentation;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
+using UnityEngine.InputSystem.EnhancedTouch;
namespace MagnetismHeroes.UnityRuntime.Interaction
{
@@ -25,6 +26,17 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private int _fingerId = -1;
private bool _waitForAllTouchesReleased;
private int _pinchFirstId = -1, _pinchSecondId = -1;
+ private readonly struct TouchContact
+ {
+ public readonly int Id;
+ public readonly UnityEngine.InputSystem.TouchPhase Phase;
+ public readonly Vector2 Position;
+ public TouchContact(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position)
+ { Id = id; Phase = phase; Position = position; }
+ public bool InProgress => Phase == UnityEngine.InputSystem.TouchPhase.Began || Phase == UnityEngine.InputSystem.TouchPhase.Moved || Phase == UnityEngine.InputSystem.TouchPhase.Stationary;
+ }
+ private readonly Queue _touchEvents = new Queue();
+ private readonly Dictionary _touches = new Dictionary();
public bool AcceptInput => _app.Session != null && _app.Session.Board != null;
public void Begin(Vector2 point, bool uiBlocked)
@@ -102,77 +114,99 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private static bool IsInside(Vector2 point) => point.x >= 0 && point.x < Screen.width && point.y >= 0 && point.y < Screen.height;
- private static UnityEngine.InputSystem.Controls.TouchControl FindTouch(Touchscreen screen, int id)
+ private TouchContact? FindTouch(int id) => _touches.TryGetValue(id, out var touch) ? touch : (TouchContact?)null;
+
+ private void FinishSingleTouch()
{
- foreach (var touch in screen.touches) if (touch.touchId.ReadValue() == id) return touch;
- return null;
+ var touch = FindTouch(_fingerId);
+ if (!touch.HasValue || touch.Value.Phase != UnityEngine.InputSystem.TouchPhase.Ended) { Cancel(); return; }
+ var point = touch.Value.Position; Move(point); End(point);
}
- private void FinishSingleTouch(Touchscreen screen)
+ private void RecordTouch(Finger finger)
{
- var touch = FindTouch(screen, _fingerId);
- if (touch == null || touch.phase.ReadValue() != UnityEngine.InputSystem.TouchPhase.Ended) { Cancel(); return; }
- var point = touch.position.ReadValue(); Move(point); End(point);
+ if (!AcceptInput || finger.screen != Touchscreen.current) return;
+ // Copy each official history callback before later events overwrite the native touch slot.
+ var touch = finger.lastTouch;
+ _touchEvents.Enqueue(new TouchContact(touch.touchId, touch.phase, touch.screenPosition));
+ }
+
+ private void ProcessTouches()
+ {
+ var active = new List();
+ foreach (var touch in _touches.Values) if (touch.InProgress) active.Add(touch);
+ if (_fingerId != -1 && !active.Exists(touch => touch.Id == _fingerId))
+ {
+ // Finish the original pointer before any new pointer can inherit its preview.
+ FinishSingleTouch(); _waitForAllTouchesReleased = active.Count > 0; return;
+ }
+ if (_gesture == Gesture.Pinch)
+ {
+ Vector2 release = _last; bool ended = false, canceled = false;
+ foreach (int id in new[] { _pinchFirstId, _pinchSecondId })
+ {
+ var touch = FindTouch(id);
+ if (!touch.HasValue) { canceled = true; continue; }
+ var phase = touch.Value.Phase;
+ if (phase == UnityEngine.InputSystem.TouchPhase.Canceled) canceled = true;
+ if (phase == UnityEngine.InputSystem.TouchPhase.Ended)
+ { release = touch.Value.Position; ended = true; canceled |= !IsInside(release); }
+ }
+ if (canceled || ended)
+ { End(release, canceled); _waitForAllTouchesReleased = active.Count > 0; return; }
+ }
+ // A canceled/finished pinch cannot restart when a third finger joins a remaining finger.
+ if (_waitForAllTouchesReleased && _gesture == Gesture.None && active.Count > 0) return;
+ if (active.Count >= 2)
+ {
+ var a = active[0].Position; var b = active[1].Position;
+ if (_gesture != Gesture.Pinch && _gesture != Gesture.Blocked)
+ {
+ BeginPinch(Vector2.Distance(a, b), IsUI(a) || IsUI(b));
+ _pinchFirstId = active[0].Id; _pinchSecondId = active[1].Id;
+ }
+ else Pinch(Vector2.Distance(a, b));
+ _waitForAllTouchesReleased = true;
+ return;
+ }
+ if (_waitForAllTouchesReleased)
+ {
+ if (active.Count == 0) { _waitForAllTouchesReleased = false; _gesture = Gesture.None; _fingerId = -1; }
+ return;
+ }
+ if (_gesture == Gesture.Pinch || (active.Count == 0 && _gesture == Gesture.Blocked))
+ { End(_last); _fingerId = -1; return; }
+ if (active.Count == 1)
+ {
+ var touch = active[0]; var point = touch.Position;
+ if (_fingerId == -1)
+ {
+ if (touch.Phase != UnityEngine.InputSystem.TouchPhase.Began) { _waitForAllTouchesReleased = true; return; }
+ Begin(point, IsUI(point)); _fingerId = touch.Id;
+ }
+ else Move(point);
+ return;
+ }
}
private void Update()
{
- if (!AcceptInput) return;
- var touchscreen = Touchscreen.current;
- if (touchscreen != null)
+ if (!AcceptInput) { _touchEvents.Clear(); _touches.Clear(); return; }
+ bool hadTouches = _touchEvents.Count > 0, suppressRestOfFrame = false;
+ while (_touchEvents.Count > 0)
{
- var active = new List();
- foreach (var touch in touchscreen.touches) if (touch.press.isPressed) active.Add(touch);
- if (_fingerId != -1 && !active.Exists(touch => touch.touchId.ReadValue() == _fingerId))
+ var touch = _touchEvents.Dequeue(); _touches[touch.Id] = touch;
+ if (!suppressRestOfFrame)
{
- // Finish the original pointer before any new pointer can inherit its preview.
- FinishSingleTouch(touchscreen); _waitForAllTouchesReleased = active.Count > 0; return;
- }
- if (_gesture == Gesture.Pinch)
- {
- Vector2 release = _last; bool ended = false, canceled = false;
- foreach (int id in new[] { _pinchFirstId, _pinchSecondId })
- {
- var touch = FindTouch(touchscreen, id);
- if (touch == null) { canceled = true; continue; }
- var phase = touch.phase.ReadValue();
- if (phase == UnityEngine.InputSystem.TouchPhase.Canceled) canceled = true;
- if (phase == UnityEngine.InputSystem.TouchPhase.Ended)
- { release = touch.position.ReadValue(); ended = true; canceled |= !IsInside(release); }
- }
- if (canceled || ended)
- { End(release, canceled); _waitForAllTouchesReleased = active.Count > 0; return; }
- }
- // A canceled/finished pinch cannot restart when a third finger joins a remaining finger.
- if (_waitForAllTouchesReleased && _gesture == Gesture.None && active.Count > 0) return;
- if (active.Count >= 2)
- {
- var a = active[0].position.ReadValue(); var b = active[1].position.ReadValue();
- if (_gesture != Gesture.Pinch && _gesture != Gesture.Blocked)
- {
- BeginPinch(Vector2.Distance(a, b), IsUI(a) || IsUI(b));
- _pinchFirstId = active[0].touchId.ReadValue(); _pinchSecondId = active[1].touchId.ReadValue();
- }
- else Pinch(Vector2.Distance(a, b));
- _waitForAllTouchesReleased = true;
- return;
- }
- if (_waitForAllTouchesReleased)
- {
- if (active.Count == 0) { _waitForAllTouchesReleased = false; _gesture = Gesture.None; _fingerId = -1; }
- return;
- }
- if (_gesture == Gesture.Pinch || (active.Count == 0 && _gesture == Gesture.Blocked))
- { End(_last); _fingerId = -1; return; }
- if (active.Count == 1)
- {
- var touch = active[0]; var point = touch.position.ReadValue();
- if (_fingerId == -1)
- { Begin(point, IsUI(point)); _fingerId = touch.touchId.ReadValue(); }
- else Move(point);
- return;
+ bool ownsGesture = touch.Id == _fingerId || (_gesture == Gesture.Pinch && (touch.Id == _pinchFirstId || touch.Id == _pinchSecondId));
+ bool canceled = touch.Phase == UnityEngine.InputSystem.TouchPhase.Canceled ||
+ (touch.Phase == UnityEngine.InputSystem.TouchPhase.Ended && !IsInside(touch.Position));
+ ProcessTouches(); suppressRestOfFrame = ownsGesture && canceled;
}
+ if (!touch.InProgress) _touches.Remove(touch.Id);
}
+ if (suppressRestOfFrame) _waitForAllTouchesReleased = _touches.Count > 0;
+ if (hadTouches || _touches.Count > 0) return;
var mouse = Mouse.current;
if (mouse == null) return;
var position = mouse.position.ReadValue();
@@ -182,7 +216,20 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
if (mouse.scroll.ReadValue().y != 0 && !IsUI(position))
{ _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); }
}
- private void OnApplicationPause(bool paused) { if (paused) Cancel(); }
- private void OnDisable() => Cancel();
+ private void OnEnable()
+ {
+ EnhancedTouchSupport.Enable();
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown += RecordTouch;
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += RecordTouch;
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += RecordTouch;
+ }
+ private void OnApplicationPause(bool paused) { if (paused) { Cancel(); _touchEvents.Clear(); _touches.Clear(); } }
+ private void OnDisable()
+ {
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= RecordTouch;
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= RecordTouch;
+ UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= RecordTouch;
+ EnhancedTouchSupport.Disable(); Cancel(); _touchEvents.Clear(); _touches.Clear();
+ }
}
}
From b172c776a86665d457bf76e185e705702a49fc94 Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 04:01:11 +0900
Subject: [PATCH 7/8] Keep Android build provenance clean after unchanged Unity
settings rewrite
---
docs/android-first-slice.md | 2 +-
tools/verify.ps1 | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/docs/android-first-slice.md b/docs/android-first-slice.md
index 8db0097..82e7d61 100644
--- a/docs/android-first-slice.md
+++ b/docs/android-first-slice.md
@@ -11,7 +11,7 @@ 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의 안내를 닫고 실제 화면을 다시 캡처합니다.
+첫 API 37 headless 기동의 GPU auto는 SwiftShader GLES 3.0/Lavapipe Vulkan 1.3을 선택했으며 즉시 찍은 화면은 비어 있었습니다. 최종 검증 AVD는 `-gpu host -no-window -no-snapshot-save -grpc-use-token`으로 실행하며, 실제 선택된 렌더러는 최종 SurfaceFlinger와 `android-device-evidence.json`에 기록합니다. 이전 재기동의 SwiftShader 로그와 현재 호스트 렌더러를 구분하며 GPU 성능 합격은 주장하지 않습니다. 처음 시작 직후 프로세스만 생존하는 시점과 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`을 확인한 시간에 세 발화를 기록합니다.
diff --git a/tools/verify.ps1 b/tools/verify.ps1
index aa899c4..038d7f8 100644
--- a/tools/verify.ps1
+++ b/tools/verify.ps1
@@ -47,6 +47,18 @@ try {
$record.apkSha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash
& (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'
+ # Unity rewrites these files with LF; restore only when Git proves their content is unchanged.
+ $record.normalizedUnchangedSettings = @()
+ if (!$record.sourceDirty) {
+ foreach ($settingsPath in @('ProjectSettings/GraphicsSettings.asset','ProjectSettings/ProjectAuditorSettings.asset','ProjectSettings/ProjectSettings.asset')) {
+ & git diff --quiet HEAD -- $settingsPath
+ if ($LASTEXITCODE -eq 0 -and [bool](& git status --porcelain -- $settingsPath)) {
+ & git restore --worktree -- $settingsPath
+ if ($LASTEXITCODE -ne 0) { throw "Cannot restore unchanged generated setting: $settingsPath" }
+ $record.normalizedUnchangedSettings += $settingsPath
+ }
+ }
+ }
$record.sourceCommitEnd = (& git rev-parse HEAD)
& git update-index --refresh | Out-Null
$record.sourceDirtyEnd = [bool](& git status --porcelain)
From e20f64ff1f2a1af2a9b26e2a5ccddc7d2bfb0f6c Mon Sep 17 00:00:00 2001
From: NINI
Date: Mon, 5 Oct 2026 04:02:03 +0900
Subject: [PATCH 8/8] Record exact Git blob equality before restoring generated
line endings
---
tools/verify.ps1 | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/tools/verify.ps1 b/tools/verify.ps1
index 038d7f8..22cbf16 100644
--- a/tools/verify.ps1
+++ b/tools/verify.ps1
@@ -49,11 +49,16 @@ try {
$record.apkMetadata = Join-Path $evidenceDir 'apk-metadata.json'
# Unity rewrites these files with LF; restore only when Git proves their content is unchanged.
$record.normalizedUnchangedSettings = @()
+ $record.settingsContentChecks = @()
if (!$record.sourceDirty) {
foreach ($settingsPath in @('ProjectSettings/GraphicsSettings.asset','ProjectSettings/ProjectAuditorSettings.asset','ProjectSettings/ProjectSettings.asset')) {
- & git diff --quiet HEAD -- $settingsPath
- if ($LASTEXITCODE -eq 0 -and [bool](& git status --porcelain -- $settingsPath)) {
- & git restore --worktree -- $settingsPath
+ $workingBlob = & git hash-object --path=$settingsPath -- $settingsPath
+ $workingExit = $LASTEXITCODE
+ $sourceBlob = & git rev-parse "HEAD:$settingsPath"
+ $sourceExit = $LASTEXITCODE
+ $record.settingsContentChecks += @{ path=$settingsPath; workingBlob=$workingBlob; sourceBlob=$sourceBlob; hashObjectExit=$workingExit; revParseExit=$sourceExit }
+ if ($workingExit -eq 0 -and $sourceExit -eq 0 -and $workingBlob -eq $sourceBlob -and [bool](& git status --porcelain -- $settingsPath)) {
+ & git restore --source=HEAD --worktree -- $settingsPath
if ($LASTEXITCODE -ne 0) { throw "Cannot restore unchanged generated setting: $settingsPath" }
$record.normalizedUnchangedSettings += $settingsPath
}