96 lines
4.9 KiB
Python
96 lines
4.9 KiB
Python
"""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()
|