Verify all approved voice PCM directly in actual APK with negative controls
This commit is contained in:
@@ -13,12 +13,16 @@ try {
|
|||||||
Check 'final-and-deliverable-apk-identical' ((Get-FileHash -LiteralPath $standard).Hash-eq$record.apkSha256) $standard
|
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
|
$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'
|
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($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})){
|
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)-$($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)
|
$zip=[IO.Compression.ZipFile]::OpenRead($ApkPath)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user