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.
82 lines
2.7 KiB
Python
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)
|