Verify all approved voice PCM directly in actual APK with negative controls

This commit is contained in:
NINI
2026-10-06 23:33:24 +09:00
parent 8b2f2d6489
commit 931b5f42e7
4 changed files with 181 additions and 2 deletions
+6 -2
View File
@@ -13,12 +13,16 @@ try {
Check 'final-and-deliverable-apk-identical' ((Get-FileHash -LiteralPath $standard).Hash-eq$record.apkSha256) $standard
$adoption=Get-Content -LiteralPath (Join-Path $projectRoot 'SourceAssets/Audio/Voice/active-adoption.json') -Raw|ConvertFrom-Json
Check 'approved44-active-voices' ($adoption.approvedCount-eq44-and$adoption.draftCount-eq0-and$adoption.clips.Count-eq44-and@($adoption.clips.id|Sort-Object -Unique).Count-eq44) 'All active voices approved, no draft'
$packed=@(Get-Content -LiteralPath ($ApkPath+'.packed-assets.txt'))
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'inspect-voice-payload.ps1') -ApkPath $ApkPath -EvidenceDir (Join-Path $EvidenceDir 'VoicePayload')
$voiceExit=$LASTEXITCODE;$voicePayload=Get-Content -LiteralPath (Join-Path $EvidenceDir 'VoicePayload/apk-voice-payload.json') -Raw|ConvertFrom-Json
Check 'actual-apk44-voice-payloads' ($voiceExit-eq0-and$voicePayload.status-eq'passed'-and$voicePayload.apkSha256-eq$record.apkSha256-and$voicePayload.actualClipCount-eq44-and$voicePayload.checks.Count-eq44-and@($voicePayload.checks|Where-Object passed -ne $true).Count-eq0) 'Exact actual APK PCM bytes for all44, independent of incremental BuildReport'
& (Join-Path $projectRoot 'artifacts/tools/apk-inspector/Scripts/python.exe') -I (Join-Path $PSScriptRoot 'test-voice-payload-negative.py') --apk $ApkPath --project $projectRoot --evidence (Join-Path $EvidenceDir 'VoicePayloadNegative')
Check 'actual-apk-payload-negative-controls' ($LASTEXITCODE-eq0) 'Same inspector accepts actual44; rejects missing serialized clips and altered actual PCM copies'
foreach($clip in $adoption.clips){
foreach($entry in @(@{kind='source';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.sourceFile));expected=$clip.sourceSha256},@{kind='edited';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.editedFile));expected=$clip.editedSha256},@{kind='runtime';path=(Join-Path $projectRoot $clip.runtimeFile);expected=$clip.runtimeSha256})){
Check "voice-$($clip.id)-$($entry.kind)-exact-sha" ((Get-FileHash -LiteralPath $entry.path).Hash-eq$entry.expected) $entry.expected
}
Check "voice-$($clip.id)-packed-in-built-apk" ($packed-contains$clip.runtimeFile) 'BuildReport packedAssets contains adopted runtime clip; Unity imported representation, not original WAV byte identity'
Check "voice-$($clip.id)-packed-in-built-apk" (@($voicePayload.checks|Where-Object {$_.id-eq$clip.id-and$_.passed-and$_.runtimeSha256-eq$clip.runtimeSha256}).Count-eq1) 'Deserialized actual APK AudioClip PCM and format exactly match approved runtime WAV'
}
$zip=[IO.Compression.ZipFile]::OpenRead($ApkPath)
try {
+23
View File
@@ -0,0 +1,23 @@
param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir)
$ErrorActionPreference='Stop'
$projectRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$toolRoot=Join-Path $projectRoot 'artifacts/tools/apk-inspector'
$python=Join-Path $toolRoot 'Scripts/python.exe'
New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null
$commands=[Collections.Generic.List[object]]::new()
try {
if(!(Test-Path -LiteralPath $python)){
& python -m venv $toolRoot *> (Join-Path $EvidenceDir 'venv-create.log');$code=$LASTEXITCODE
$commands.Add(@{command='python -m venv '+$toolRoot;exitCode=$code});if($code-ne0){throw 'QA inspector environment creation failed'}
}
$version=& $python -c 'import importlib.metadata; print(importlib.metadata.version("UnityPy"))' 2>$null
if($LASTEXITCODE-ne0-or$version-ne'1.25.4'){
& $python -m pip install UnityPy==1.25.4 *> (Join-Path $EvidenceDir 'tool-install.log');$code=$LASTEXITCODE
$commands.Add(@{command='isolated QA venv pip install UnityPy==1.25.4';exitCode=$code});if($code-ne0){throw 'Pinned APK voice inspector installation failed'}
}
& $python -m pip freeze | Set-Content -LiteralPath (Join-Path $EvidenceDir 'tool-packages.txt')
& $python -I (Join-Path $PSScriptRoot 'inspect-voice-payload.py') --apk $ApkPath --project $projectRoot --evidence $EvidenceDir *> (Join-Path $EvidenceDir 'inspection.log')
$code=$LASTEXITCODE;$commands.Add(@{command=('inspect-voice-payload.py --apk '+$ApkPath+' --project '+$projectRoot+' --evidence '+$EvidenceDir);exitCode=$code})
if($code-ne0){throw 'Actual APK voice payload verification failed'}
}finally{@{sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);commands=$commands.ToArray()}|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $EvidenceDir 'inspection-commands.json')}
exit 0
+95
View File
@@ -0,0 +1,95 @@
"""Read actual APK AudioClip payloads; never modify the APK or project assets."""
import argparse
import hashlib
import importlib.metadata
import io
import json
from pathlib import Path, PurePosixPath
import re
import wave
import uuid
import zipfile
import UnityPy
def sha(data):
return hashlib.sha256(data).hexdigest()
def require(condition, message):
if not condition:
raise ValueError(message)
def pcm(wav_bytes):
with wave.open(io.BytesIO(wav_bytes)) as wav:
return ((wav.getnchannels(), wav.getsampwidth(), wav.getframerate(), wav.getnframes()),
wav.readframes(wav.getnframes()))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--apk', required=True)
parser.add_argument('--project', required=True)
parser.add_argument('--evidence', required=True)
args = parser.parse_args()
project, apk, evidence = Path(args.project), Path(args.apk), Path(args.evidence)
evidence.mkdir(parents=True, exist_ok=True)
version = importlib.metadata.version('UnityPy')
require(version == '1.25.4', f'Expected pinned QA inspector1.25.4, got {version}')
adoption = json.loads((project / 'SourceAssets/Audio/Voice/active-adoption.json').read_text(encoding='utf-8-sig'))
require(len(adoption['clips']) == 44 and len({c['id'] for c in adoption['clips']}) == 44
and adoption['approvedCount'] == 44 and adoption['draftCount'] == 0, '44 unique approved adoption IDs required')
adopted = {c['id']: c for c in adoption['clips']}
extracted = evidence / ('apk-serialized-data-' + uuid.uuid4().hex)
extracted.mkdir()
record = {'apkSha256': sha(apk.read_bytes()), 'tool': 'UnityPy', 'toolVersion': version,
'method': 'Join actual APK split assets; deserialize AudioClips; decode PCM16; exact PCM SHA and format comparison to adopted WAV',
'status': 'failed', 'checks': [], 'extractedEntries': []}
try:
with zipfile.ZipFile(apk) as archive:
entries = [n for n in archive.namelist() if n.startswith('assets/bin/Data/')
and not n.endswith('/') and '/Managed/' not in n]
names = sorted({re.sub(r'\.split\d+$', '', n) for n in entries})
for name in names:
parts = sorted([n for n in entries if re.sub(r'\.split\d+$', '', n) == name],
key=lambda n: int(n.rsplit('.split', 1)[1]) if '.split' in n else -1)
if '.split' in parts[0]:
require([int(n.rsplit('.split', 1)[1]) for n in parts] == list(range(len(parts))), 'Contiguous split asset parts required')
data = b''.join(archive.read(n) for n in parts)
path = extracted / PurePosixPath(name).name
path.write_bytes(data)
record['extractedEntries'].append({'parts': parts, 'joinedFile': str(path), 'sha256': sha(data)})
clips = [obj.read() for obj in UnityPy.load(str(extracted)).objects if obj.type.name == 'AudioClip']
record['actualClipCount'] = len(clips)
require(len(clips) == 44 and len({clip.m_Name for clip in clips}) == 44, 'Actual APK must contain44 unique AudioClips')
require({clip.m_Name for clip in clips} == set(adopted), 'Actual APK clip IDs must exactly equal adoption IDs')
for clip in sorted(clips, key=lambda c: c.m_Name):
adoption_clip = adopted[clip.m_Name]
runtime = (project / adoption_clip['runtimeFile']).read_bytes()
require(sha(runtime) == adoption_clip['runtimeSha256'].lower(), 'Adopted runtime SHA mismatch: ' + clip.m_Name)
samples = clip.samples
require(len(samples) == 1, 'Exactly one PCM payload required: ' + clip.m_Name)
packed_wave = next(iter(samples.values()))
packed_format, packed_pcm = pcm(packed_wave)
runtime_format, runtime_pcm = pcm(runtime)
passed = (packed_format == runtime_format and packed_format[:3] == (1, 2, 44100)
and sha(packed_pcm) == sha(runtime_pcm) and clip.m_CompressionFormat == 0)
record['checks'].append({'id': clip.m_Name, 'passed': passed,
'runtimeSha256': sha(runtime), 'apkPcmSha256': sha(packed_pcm),
'runtimePcmSha256': sha(runtime_pcm), 'format': packed_format,
'resource': clip.m_Resource.m_Source, 'offset': clip.m_Resource.m_Offset,
'resourceSize': clip.m_Resource.m_Size})
require(passed, f'Actual packed voice PCM mismatch: {clip.m_Name}')
record['status'] = 'passed'
except Exception as exc:
record['error'] = str(exc)
raise
finally:
(evidence / 'apk-voice-payload.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'Actual APK voice PCM exact matches: {len(record["checks"])}/44')
if __name__ == '__main__':
main()
@@ -0,0 +1,57 @@
"""Actual inspector rejects missing serialized clips and changed PCM in QA-only APK copies."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import zipfile
parser = argparse.ArgumentParser()
parser.add_argument('--apk', required=True)
parser.add_argument('--project', required=True)
parser.add_argument('--evidence', required=True)
args = parser.parse_args()
apk, evidence = Path(args.apk), Path(args.evidence)
evidence.mkdir(parents=True, exist_ok=True)
inspector = Path(__file__).with_name('inspect-voice-payload.py')
checks = []
for kind in ('positive', 'missing-clips', 'altered-pcm'):
case = evidence / kind
case.mkdir(exist_ok=True)
target = apk if kind == 'positive' else case / 'qa-mutated.apk'
mutation = None
if kind != 'positive':
with zipfile.ZipFile(apk) as source, zipfile.ZipFile(target, 'w') as output:
for entry in source.infolist():
if not entry.filename.startswith('assets/bin/Data/'):
continue
if kind == 'missing-clips' and entry.filename.startswith('assets/bin/Data/sharedassets0.assets'):
continue
data = source.read(entry)
if kind == 'altered-pcm' and entry.filename in ('assets/bin/Data/sharedassets0.resource.split0', 'assets/bin/Data/sharedassets0.resource'):
data = bytearray(data)
offset = 194000 # Inside first known PCM16 AudioClip resource, outside its FSB header.
data[offset] ^= 32
data = bytes(data)
mutation = {'entry': entry.filename, 'offset': offset, 'xor': 32}
output.writestr(entry, data)
if kind == 'altered-pcm' and mutation is None:
raise RuntimeError('Expected actual PCM split resource is absent')
command = [sys.executable, '-I', str(inspector), '--apk', str(target), '--project', args.project, '--evidence', str(case)]
process = subprocess.run(command, capture_output=True, text=True)
(case / 'stdout.log').write_text(process.stdout, encoding='utf-8')
(case / 'stderr.log').write_text(process.stderr, encoding='utf-8')
record = json.loads((case / 'apk-voice-payload.json').read_text(encoding='utf-8'))
expected = kind == 'positive'
passed = process.returncode == (0 if expected else 1) and (record['status'] == 'passed') == expected
if kind == 'missing-clips':
passed = passed and record.get('actualClipCount') == 0
if kind == 'altered-pcm':
passed = passed and any(not check['passed'] for check in record['checks']) and 'PCM mismatch' in record.get('error', '')
checks.append({'case': kind, 'command': command, 'exitCode': process.returncode, 'passed': passed,
'mutation': mutation, 'inspectorRecord': record})
result = {'originalApkSha256': hashlib.sha256(apk.read_bytes()).hexdigest(), 'scope': 'QA synthetic negative copies only; never installed',
'checks': checks, 'passed': all(check['passed'] for check in checks)}
(evidence / 'regression.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
sys.exit(0 if result['passed'] else 1)