Files
SandTools/bundle/unitybundle.py
DownloadPizza a44e4db1c3 refactor: group scripts into walker/ wikigen/ bundle/
Organize the 16 loose scripts by concern:
  walker/  -- .wbt save tooling (sand, build_wbt, walker_hashes,
              harvest_hashes, recover_key)
  wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
  bundle/  -- Unity/Odin asset extraction (unitybundle, odin_read,
              extract_*, loot_probe, dump_loot_bytes)

The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
2026-06-11 14:49:33 +02:00

82 lines
2.7 KiB
Python

"""Minimal UnityFS bundle extractor (LZ4/LZ4HC + uncompressed blocks)."""
import struct
def lz4_decompress(src, dst_size):
out=bytearray(); i=0; n=len(src)
while i<n:
tok=src[i]; i+=1
lit=tok>>4
if lit==15:
while True:
bb=src[i]; i+=1; lit+=bb
if bb!=255: break
out+=src[i:i+lit]; i+=lit
if i>=n: break
off=src[i]|(src[i+1]<<8); i+=2
ml=tok&15
if ml==15:
while True:
bb=src[i]; i+=1; ml+=bb
if bb!=255: break
ml+=4
start=len(out)-off
for j in range(ml):
out.append(out[start+j])
return bytes(out[:dst_size])
def extract(path):
"""Return (nodes, data_bytes). nodes=[(off,size,flags,name)]."""
b=open(path,'rb').read()
if b[:7]!=b'UnityFS': raise ValueError('not UnityFS')
p=8
ver,=struct.unpack_from('>I',b,p); p+=4
def rstr(p):
e=b.index(b'\x00',p); return b[p:e].decode(), e+1
uver,p=rstr(p); ueng,p=rstr(p)
size,=struct.unpack_from('>q',b,p); p+=8
cblk,=struct.unpack_from('>I',b,p); p+=4
ublk,=struct.unpack_from('>I',b,p); p+=4
flags,=struct.unpack_from('>I',b,p); p+=4
if flags & 0x80: # blocksInfo at end
bi=b[len(b)-cblk:]
else:
if flags & 0x200:
p=(p+15)&~15
bi=b[p:p+cblk]; p+=cblk
comp=flags&0x3f
blocks_info=bi if comp==0 else lz4_decompress(bi,ublk)
q=16
bc,=struct.unpack_from('>I',blocks_info,q); q+=4
blocks=[]
for _ in range(bc):
u,c,f=struct.unpack_from('>IIH',blocks_info,q); q+=10
blocks.append((u,c,f))
ncount,=struct.unpack_from('>I',blocks_info,q); q+=4
nodes=[]
for _ in range(ncount):
off,sz,fl=struct.unpack_from('>qqI',blocks_info,q); q+=20
e=blocks_info.index(b'\x00',q); name=blocks_info[q:e].decode(); q=e+1
nodes.append((off,sz,fl,name))
if not (flags & 0x80):
pass
data=bytearray()
for (u,c,f) in blocks:
blk=b[p:p+c]; p+=c
data += blk if (f&0x3f)==0 else lz4_decompress(blk,u)
return nodes, bytes(data)
if __name__=='__main__':
import sys,glob
needle=sys.argv[1].encode() if len(sys.argv)>1 else b'actualVersion'
d='/mnt/d/SteamLibrary/steamapps/common/Sand Playtest/Sand_Data/StreamingAssets/aa/StandaloneWindows64'
for f in sorted(glob.glob(d+'/*.bundle')):
import os
if os.path.getsize(f) > 30_000_000: # skip huge ones in quick pass
continue
try:
nodes,data=extract(f)
if needle in data:
print('HIT', os.path.basename(f), 'at', data.find(needle))
except Exception as e:
print('ERR', os.path.basename(f), e)