Files
verstack/plugins/media_extract.py
Verstack Local f73167de85 Add downloadable VMs and improve catalog processing
Package Apple Silicon VM exports and cached runtime switching with local cabinet controls and conagent provisioning. Preserve experimental SPIKE 2 emulation.

Improve preparation concurrency, Ghidra checkpoints, signature importing, sound indexing and client-side spectrograms. Include regression tests and validation notes.
2026-09-14 20:03:59 -05:00

299 lines
18 KiB
Python

#!/usr/bin/env python3
"""Bounded static media extraction. Profiles adapted from the local triage wiki.
Original containers stay in the parent snapshot; exact entry bytes and ranges accompany
all derivatives. Unsupported layouts are recorded, never interpreted as empty media.
"""
import hashlib, json, mmap, pathlib, shutil, struct, sys, wave, zlib
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from parallelism import worker_count
REVISION = 4
KNOWN_PCM_RATES = {
'2f1a958c1f859c339c716e53cc9ee752412373c486ea2a1bfd116eefce4617a3': 44100,
'a7df00dbbfb75838fd19d57ada30f4247d2ef1bc4b357ca0a64ab60f3a679af0': 44100,
}
def bounds(data, start, size):
if start < 0 or size < 0 or start > len(data) or size > len(data)-start:
raise ValueError(f'truncated/out-of-bounds range {start}+{size} of {len(data)}')
def safe_name(name):
if name.startswith('res://'): name=name[6:]
p=pathlib.PurePosixPath(name)
if not name or '\x00' in name or '\\' in name or p.is_absolute() or any(x in ('','.','..') for x in name.split('/')):
raise ValueError('unsafe container entry name')
return p.as_posix()
class Output:
def __init__(self, root, budget): self.root=root; self.left=budget; self.records=[]
def write(self, name, data):
path=self.root/safe_name(name)
if path.exists(): raise ValueError('duplicate output path')
if len(data)>self.left: raise ValueError('output budget exceeded')
self.left-=len(data);path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(data)
return name
def record(self, **row): self.records.append({'decoder_revision':REVISION,'name_confidence':'structural',**row})
def sniff(head):
if head.startswith(b'\x89PNG\r\n\x1a\n'): return '.png'
if head.startswith(b'\xff\xd8\xff'): return '.jpg'
if head[:4]==b'RIFF' and head[8:12]==b'WEBP': return '.webp'
if head[:4]==b'RIFF' and head[8:12]==b'WAVE': return '.wav'
if head[:4]==b'OggS': return '.ogg'
if head[:4]==b'fLaC': return '.flac'
if head[4:8]==b'ftyp': return '.mp4'
if head[:4]==b'\x1aE\xdf\xa3': return '.webm'
return None
def pck_entries(data):
bounds(data,0,88)
magic,version,major,minor,patch=struct.unpack_from('<5I',data)
if magic!=0x43504447 or version not in (1,2): raise ValueError('unsupported Godot PCK version')
base=0; pos=84
if version==2:
bounds(data,0,100)
flags,base=struct.unpack_from('<IQ',data,20)
if flags: raise ValueError('encrypted or relative PCK directory unsupported')
pos=96
count=struct.unpack_from('<I',data,pos)[0];pos+=4
if count>1000000: raise ValueError('PCK entry count exceeds bound')
entries=[];seen=set()
for _ in range(count):
bounds(data,pos,4); n=struct.unpack_from('<I',data,pos)[0];pos+=4
if not 0<n<=16384: raise ValueError('invalid PCK path length')
bounds(data,pos,n+32+(4 if version==2 else 0))
name=safe_name(data[pos:pos+n].rstrip(b'\0').decode('utf-8'));pos+=n
offset,size=struct.unpack_from('<QQ',data,pos);md5=data[pos+16:pos+32];pos+=32
flags=0
if version==2: flags=struct.unpack_from('<I',data,pos)[0];pos+=4
if flags: raise ValueError('encrypted PCK entry unsupported')
start=base+offset; bounds(data,start,size)
if name in seen: raise ValueError('duplicate normalized PCK path')
seen.add(name);entries.append((name,start,size,md5))
for _,start,_,_ in entries:
if start<pos: raise ValueError('PCK payload overlaps directory')
return entries
def decompress_resource(blob,limit):
if blob[:4]!=b'RSCC':return blob
bounds(blob,0,16);mode,block_size,total=struct.unpack_from('<3I',blob,4)
if not block_size or total>limit or total>64*1024*1024:raise ValueError('compressed resource exceeds bound')
count=(total+block_size-1)//block_size;bounds(blob,16,count*4)
sizes=struct.unpack_from(f'<{count}I',blob,16);pos=16+count*4;decoded=bytearray()
for size in sizes:
bounds(blob,pos,size);block=blob[pos:pos+size];pos+=size
expected=min(block_size,total-len(decoded))
if mode==2:
from compression.zstd import ZstdDecompressor
decoder=ZstdDecompressor();part=decoder.decompress(block,max_length=expected+1)
if not decoder.eof or decoder.unused_data:raise ValueError('invalid zstd resource block')
elif mode in (1,3):
decoder=zlib.decompressobj(15 if mode==1 else 31);part=decoder.decompress(block,expected+1)
if not decoder.eof or decoder.unused_data:raise ValueError('invalid deflate resource block')
else:raise ValueError('unsupported compressed resource codec')
if len(part)!=expected:raise ValueError('resource decompression length mismatch')
decoded.extend(part)
return bytes(decoded)
def embedded_font(blob):
for signature in (b'\x00\x01\x00\x00',b'OTTO'):
start=0
while (i:=blob.find(signature,start))>=0:
start=i+1
if i+12>len(blob):continue
count=struct.unpack_from('>H',blob,i+4)[0]
if not 1<=count<=256 or i+12+count*16>len(blob):continue
end=i+12+count*16;valid=True
for n in range(count):
offset,length=struct.unpack_from('>II',blob,i+12+n*16+8)
if offset<12+count*16 or i+offset+length>len(blob):valid=False;break
end=max(end,i+offset+length)
if valid:return blob[i:end]
return None
def extract_pck(data,out,prefix,source):
entries=pck_entries(data)
for name,start,size,md5 in entries:
blob=data[start:start+size]
if hashlib.md5(blob).digest()!=md5: raise ValueError('PCK entry checksum mismatch')
original=out.write(f'{prefix}/original/{name}',blob)
preview=None;status='preserved'
if blob[:4]==b'RSCC':
blob=decompress_resource(blob,out.left)
out.write(f'{prefix}/decompressed/{name}',blob)
if name.lower().endswith('.fontdata'):
font=embedded_font(blob)
if font is None:status='unsupported_font_resource'
else:preview=out.write(f'{prefix}/fonts/{name}.ttf',font);status='decoded'
elif name.lower().endswith('.ctex'):
# Godot compressed texture payload. Preserve the entire source record;
# only a fully bounded RIFF chunk is eligible for this preview path.
if blob[:4]!=b'GST2': status='unsupported_texture_header'
else:
start_webp=blob.find(b'RIFF',32)
if start_webp>=0 and blob[start_webp+8:start_webp+12]==b'WEBP':
length=struct.unpack_from('<I',blob,start_webp+4)[0]+8
bounds(blob,start_webp,length)
preview=out.write(f'{prefix}/previews/{name}.webp',blob[start_webp:start_webp+length]);status='decoded'
else: status='unsupported_texture_codec'
elif sniff(blob[:16]):
preview=out.write(f'{prefix}/previews/{name}{sniff(blob[:16])}',blob);status='decoded'
out.record(source=source,entry=name,offset=start,length=size,original=original,preview=preview,status=status,name_confidence='confirmed_container_path')
def png(width,height,pixels):
def chunk(tag,payload):return struct.pack('>I',len(payload))+tag+payload+struct.pack('>I',zlib.crc32(tag+payload)&0xffffffff)
gray=pixels.translate(bytes(v*17 if v<=15 else v for v in range(256)))
alpha=pixels.translate(bytes(0 if v==255 else 255 for v in range(256)))
stride=width*4+1;raw=bytearray(stride*height)
for y in range(height):
row=bytearray(width*4);values=gray[y*width:(y+1)*width]
row[0::4]=values;row[1::4]=values;row[2::4]=values;row[3::4]=alpha[y*width:(y+1)*width]
raw[y*stride+1:(y+1)*stride]=row
return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',width,height,8,6,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b'')
def radium_header(data):
bounds(data,0,104);h=struct.unpack_from('<13Q',data)
if h[0]<104 or any(x>=len(data) for x in h[:10]): raise ValueError('unrecognized Radium qword layout')
count=h[12]&0xffffffff
if count>1000000 or h[12]>>32: raise ValueError('unsupported Radium sound count')
length=(count*24+15)&~15; bounds(data,h[8],length+4)
expected=struct.unpack_from('<I',data,h[8]+length)[0]
if zlib.crc32(data[h[8]:h[8]+length])&0xffffffff!=expected: raise ValueError('Radium sound table CRC mismatch')
return h
def extract_radium(data,out,prefix,source,source_hash,settings):
h=radium_header(data);count=h[12]&0xffffffff
profiles=settings.get('pcm_profiles',{})
profile=profiles.get(source_hash,{})
rate=KNOWN_PCM_RATES.get(source_hash) or profile.get('sample_rate')
if rate is not None and (not isinstance(rate,int) or not 8000<=rate<=192000): raise ValueError('invalid PCM profile rate')
for i in range(count):
start,flags,samples=struct.unpack_from('<3Q',data,h[8]+i*24)
channels=(((flags>>25)&1)<<3)|(((flags>>30)&1)<<4)|(((flags>>17)&1)<<1)|(((flags>>31)&1)<<2)|((flags>>7)&1)
length=channels*(samples&0xffffffff)*2
bounds(data,start,length)
if start+length>h[8]: raise ValueError('PCM overlaps sound directory')
next_start=struct.unpack_from('<Q',data,h[8]+(i+1)*24)[0] if i+1<count else start+length
if next_start!=start+length: raise ValueError('PCM range does not match next record')
original=out.write(f'{prefix}/original/sound-{i:05}.pcm',data[start:start+length])
preview=None;status='unsupported_pcm_flags'
if channels in (1,2):
status='missing_verified_sample_rate'
if rate:
import io
wav=io.BytesIO()
with wave.open(wav,'wb') as w:w.setnchannels(channels);w.setsampwidth(2);w.setframerate(rate);w.writeframes(data[start:start+length])
preview=out.write(f'{prefix}/sounds/sound-{i:05}.wav',wav.getvalue());status='decoded'
out.record(source=source,section=8,index=i,offset=start,length=length,channels=channels,sample_rate=rate,duration_seconds=(samples&0xffffffff)/rate if rate else None,original=original,preview=preview,status=status)
def extract_bitmaps(data,out,prefix,source,request=None):
from dmd_bitmap import decode
bounds(data,0,56);header=struct.unpack_from('<7Q',data)
start,end=header[3],header[2]
if not 56<=start<end<len(data) or (end-start)%8 or (end-start)//8>1000000:
raise ValueError('unrecognized DMD pointer table')
count=(end-start)//8;bounds(data,start,count*8)
pointers=struct.unpack_from(f'<{count}Q',data,start)
if any(p<end or p+13>len(data) or p%8 for p in pointers):raise ValueError('DMD pointer outside asset area')
ordered=sorted(set(pointers));nexts=dict(zip(ordered,ordered[1:]+[len(data)]));frames={}
workers=worker_count(request or {'cpu_threads':1},max(1,count//16));pending=deque()
def save_next():
i,offset,decoded,future=pending.popleft()
try:
original=out.write(f'{prefix}/original/bitmap-{i:05}.bin',data[offset:offset+decoded['consumed']])
indices=out.write(f'{prefix}/indices/bitmap-{i:05}.idx',decoded['pixels'])
preview=out.write(f'{prefix}/images/bitmap-{i:05}.png',future.result())
metadata={k:v for k,v in decoded.items() if k not in ('pixels','consumed')}
out.record(source=source,section=3,index=i,offset=offset,length=decoded['consumed'],original=original,indices=indices,preview=preview,status='decoded',profile='dmd-indexed/1',preview_accuracy='approximate_palette',**metadata)
except (ValueError,struct.error) as error:out.record(source=source,section=3,index=i,offset=offset,status='coverage_failure',error=str(error))
report('Decoding DMD bitmaps',i+1,count,'frames',f'{workers} image workers · {source}')
with ThreadPoolExecutor(max_workers=workers) as pool:
for i,offset in enumerate(pointers):
try:
# Delta frames depend on the preceding keyframe. Keep index decoding
# ordered; compress the independent PNG previews concurrently.
decoded=decode(memoryview(data)[offset:nexts[offset]],frames)
pending.append((i,offset,decoded,pool.submit(png,decoded['width'],decoded['height'],decoded['pixels'])))
if len(pending)>=workers:save_next()
except (ValueError,struct.error) as error:
while pending:save_next()
out.record(source=source,section=3,index=i,offset=offset,status='coverage_failure',error=str(error))
while pending:save_next()
def extract_spike1(data,out,prefix,source):
from vendor.spike1_parser import parse_master
import io
parsed=parse_master(data)
for record in parsed['records']:
for index,(offset,frames,channels,divisor) in enumerate(record['tracks']):
length=frames*channels*2
bounds(data,offset+8,length)
stem=f"sound-{record['idx']:05}-track-{index+1}"
original=out.write(f'{prefix}/original/{stem}.pcm',data[offset+8:offset+8+length])
buf=io.BytesIO()
with wave.open(buf,'wb') as w:
w.setnchannels(channels);w.setsampwidth(2);w.setframerate(44100//divisor);w.writeframes(data[offset+8:offset+8+length])
preview=out.write(f'{prefix}/sounds/{stem}.wav',buf.getvalue())
out.record(source=source,index=record['idx'],track=index+1,offset=offset+8,length=length,
source_header_offset=offset,channels=channels,sample_rate=44100//divisor,original=original,preview=preview,status='decoded',profile='spike1-plaintext-pcm')
out.record(source=source,status='partial_container',profile='spike1-plaintext-pcm',reason='Audio master records decoded; non-audio sections remain unverified')
from progress import report
def run(request):
if request['protocol']!=1: raise ValueError('unsupported protocol')
source=pathlib.Path(request['input_dir']);output=pathlib.Path(request['output_dir']);settings=request.get('settings') or {}
out=Output(output,request['workspace_bytes']//2)
paths=[p for p in sorted(source.rglob('*')) if p.is_file() and not p.is_symlink()]
for index,path in enumerate(paths):
report('Decoding assets',index,len(paths),'files',path.name)
if not path.is_file() or path.is_symlink(): continue
rel=path.relative_to(source).as_posix()
with path.open('rb') as f: head=f.read(104)
ext=sniff(head); relevant=ext or head[:4]==b'GDPC' or path.name.startswith('image') and path.suffix=='.bin' or path.suffix in ('.asset','.radium')
if not relevant: continue
with path.open('rb') as f: source_hash=hashlib.file_digest(f,'sha256').hexdigest()
prefix=f'assets/{rel}'
first_record=len(out.records)
try:
with path.open('rb') as f, mmap.mmap(f.fileno(),0,access=mmap.ACCESS_READ) as data:
if head[:4]==b'GDPC':extract_pck(data,out,prefix,rel)
elif ext:
original=out.write(f'{prefix}/original{ext}',data)
out.record(source=rel,source_sha256=source_hash,offset=0,length=len(data),original=original,preview=original,status='decoded',name_confidence='confirmed_container_path')
elif path.name.startswith('image') and path.suffix=='.bin':
try:extract_bitmaps(data,out,prefix,rel,request)
except (ValueError,struct.error) as error:out.record(source=rel,section=3,status='coverage_failure',error=str(error))
from vendor.spike1_parser import find_header2, Spike1Error
try:find_header2(data);spike1=True
except Spike1Error:spike1=False
if spike1:extract_spike1(data,out,prefix,rel)
else:
effective=dict(settings)
if source_hash not in KNOWN_PCM_RATES and source_hash not in settings.get('pcm_profiles',{}):
from pcm_profile import alsa_rate
binary=path.parent/'game'
profile=alsa_rate(binary) if binary.is_file() else None
if profile:
effective['pcm_profiles']={**settings.get('pcm_profiles',{}),source_hash:profile}
out.record(source=rel,status='profile_evidence',profile=profile)
extract_radium(data,out,prefix,rel,source_hash,effective)
else:
from scene_media import extract_scene
extract_scene(path,data,out,prefix,rel)
except (ValueError,struct.error,UnicodeError,ImportError) as error:out.record(source=rel,source_sha256=source_hash,status='coverage_failure',error=str(error))
for record in out.records[first_record:]:record.setdefault('source_sha256',source_hash)
failures=[r for r in out.records if r['status'] not in ('decoded','preserved','scene_metadata_preserved','profile_evidence')]
out.write('media-evidence.json',json.dumps({'schema':1,'processing_revision':REVISION,'coverage':'partial' if failures else 'complete','assets':out.records},indent=2).encode())
files=[p.relative_to(output).as_posix() for p in sorted(output.rglob('*')) if p.is_file()]
return {'protocol':1,'layer':'derived','coverage':'partial' if failures else 'complete','files':files,'warnings':[f'{len(failures)} media coverage failures or approximate previews; original containers retained.'] if failures else []}
if __name__=='__main__':
req=json.loads(pathlib.Path(sys.argv[1]).read_text())
try:result=run(req)
except Exception as error:
pathlib.Path(req['result_file']).write_text(json.dumps({'protocol':1,'error':str(error)}));raise
pathlib.Path(req['result_file']).write_text(json.dumps(result))