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.
This commit is contained in:
81
bundle/unitybundle.py
Normal file
81
bundle/unitybundle.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user