"""Offline tests: replay the real EXO-55 capture, synthetic damage, identity failures, lifecycle and renderer."""
from pathlib import Path
import json,struct,sys
from lupa.luajit21 import LuaRuntime
R=Path(__file__).resolve().parents[1]
SNAP=Path(sys.argv[1]) if len(sys.argv)>1 else R/'tests/exo55_snapshot.json'
checks=0
def check(ok,msg=''):
 global checks
 assert ok,msg;checks+=1
def lua():
 L=LuaRuntime(unpack_returned_tuples=True)
 return L
def load(L,name):return L.execute((R/'src'/f'{name}.lua').read_text())
snap=json.loads(SNAP.read_text())
class Mem:
 def __init__(s):s.d={};s.fault=None
 def put(s,a,b):
  for i,v in enumerate(b):s.d[a+i]=v
 def read(s,a,n):
  a,n=int(a),int(n)
  if s.fault and s.fault(a,n):return None
  try:return bytes(s.d[a+i] for i in range(n))
  except KeyError:return None
def replay():
 m=Mem()
 for r in snap['reads']:m.put(int(r['address']),bytes.fromhex(''.join(r['hex_chunks'])))
 return m
def setup(m):
 L=lua();N=load(L,'native');Z=load(L,'zones')
 N.base=int(snap['module_base']);N.ready=True;N.next_check=1e18
 # lupa: raw python bytes -> lua string
 def rd(a,n):
  b=m.read(a,n);return None if b is None else b.decode('latin-1')
 L.globals().__rd=rd
 N.win=L.eval("{read=function(a,n) local s=__rd(a,n); return s end, begin_sample=function() end}")
 Rd=load(L,'mech_reader')(N,Z)
 return L,N,Z,Rd
# latin-1 round trip requires lua strings built from python str with byte semantics
LuaRuntime_encoding=None
def setup_bytes(m):
 L=LuaRuntime(unpack_returned_tuples=True,encoding=None)
 N=L.execute((R/'src/native.lua').read_bytes());Z=L.execute((R/'src/zones.lua').read_bytes())
 N[b'base']=int(snap['module_base']);N[b'ready']=True;N[b'next_check']=1e18
 N[b'win']=L.table_from({b'read':lambda a,n:m.read(a,n),b'begin_sample':lambda:None})
 Rd=L.execute((R/'src/mech_reader.lua').read_bytes())(N,Z)
 return L,N,Z,Rd
avatar=snap['avatar']['goid']
# 1. Real EXO-55 capture replays into a full, verified reading.
m=replay();L,N,Z,Rd=setup_bytes(m)
v,why,kind=Rd[b'sample'](avatar,0)
check(v is not None,(why,kind))
check(v[b'model'][b'name']==b'EXO-55 BREACHER');check(v[b'hull']==1800 and v[b'hull_max']==1800)
zones=[v[b'zones'][i+1] for i in range(len(v[b'zones']))]
check(len(zones)==8)
exp={b'cockpit_front_left':400,b'cockpit_front_right':400,b'cockpit_rear':400,b'hips':400,b'leg_right':550,b'leg_left':550,b'light_right_front':10,b'light_left_front':10}
got={z[b'info'][b'id']:(z[b'hp'],z[b'max']) for z in zones}
for k,mx in exp.items():check(got[k]==(mx,mx),k)
check(v[b'calls']<960 and v[b'bytes']<=65536)
# locate runtime record and config in replay memory
runtime=int(snap['runtime_address']);cfg=int(snap['config_address'])
# 2. Damage is read live from runtime fields (hull +0x14, zones +0xF8+4i).
m.put(runtime+0x14,struct.pack('<i',912));m.put(runtime+0xF8+4*5,struct.pack('<i',137));m.put(runtime+0xF8+4*6,struct.pack('<i',0))
v,_,_=Rd[b'sample'](avatar,0);zs={v[b'zones'][i+1][b'info'][b'id']:v[b'zones'][i+1] for i in range(8)}
check(v[b'hull']==912);check(zs[b'leg_left'][b'hp']==137);check(zs[b'light_right_front'][b'hp']==0 and zs[b'light_right_front'][b'valid'])
# 2b. Negative runtime HP (seen live just before destruction) reads as 0, still valid.
m.put(runtime+0x14,struct.pack('<i',-333));m.put(runtime+0xF8+4*4,struct.pack('<i',-12))
v,_,_=Rd[b'sample'](avatar,0);check(v[b'hull']==0 and v[b'hull_valid']);check(v[b'zones'][5][b'hp']==0 and v[b'zones'][5][b'valid'])
m.put(runtime+0x14,struct.pack('<i',912));m.put(runtime+0xF8+4*4,struct.pack('<i',550))
# 3. Out-of-range zone value is flagged invalid, not clamped into a fake reading.
m.put(runtime+0xF8,struct.pack('<i',9999));v,_,_=Rd[b'sample'](avatar,0);check(v[b'zones'][1][b'valid']==False)
m.put(runtime+0xF8,struct.pack('<i',400))
# 4. Unknown zone hash => UNVERIFIED (no guessed labels).
orig=m.read(cfg+0x208+0x60,4);m.put(cfg+0x208+0x60,b'\x11\x22\x33\x44')
v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind==b'UNVERIFIED',kind)
m.put(cfg+0x208+0x60,orig)
# 5. Short read => TRANSIENT; identity corruption => INVALID.
m.fault=lambda a,n:a==runtime;v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind==b'TRANSIENT',(why,kind));m.fault=None
vd=snap['vehicle']['address'];base=int(snap['module_base'])
hm=struct.unpack('<Q',m.read(base+0x3326688,8))[0];arr=struct.unpack('<Q',m.read(hm+0x1048,8))[0]
slot=arr+snap['health_index']*8;good=m.read(slot,8);m.put(slot,struct.pack('<Q',snap['avatar']['address']))
v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind==b'INVALID',(why,kind));m.put(slot,good)
# 6. Change identity during the sample (after config read) => rejected by validate().
state={'n':0}
def flip(a,n):
 if a==cfg and state['n']==0:
  state['n']=1;m.put(vd+12,struct.pack('<I',777))
 return False
m.fault=flip;v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind==b'INVALID',(why,kind));m.fault=None
m.put(vd+12,struct.pack('<I',snap['vehicle']['unit']))
# 7. Non-mech vehicle resource => NOT_MECH.
m.put(vd,struct.pack('<Q',0x1111222233334444));
v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind in (b'NOT_MECH',b'INVALID'),kind)
m=replay();L,N,Z,Rd=setup_bytes(m);v,_,_=Rd[b'sample'](avatar,0);check(v is not None)
# 7b. Other exosuits: same zone layout under another model resource is shown with that model's name.
for res,code in ((b'79e4b3d2da5e45e3',b'EXO-45'),(b'c2d449ecf7facab1',b'EXO-49'),(b'7b2326f6fd9c8069',b'EXO-51')):
 m=replay();L,N,Z,Rd=setup_bytes(m)
 mod=Z[b'models'][res];check(mod is not None and mod[b'arm_labels'] is not None,code)
 Z[b'models'][b'35dbf54f016f3624']=mod  # same capture, routed through the other model entry
 v,why,kind=Rd[b'sample'](avatar,0)
 check(v is not None and v[b'model'][b'code']==code and v[b'hull_max']==1800 and len(v[b'zones'])==8,(code,why,kind))
# 7c. Unknown hash on another model is reported with hash:max for the log.
m=replay();L,N,Z,Rd=setup_bytes(m);m.put(cfg+0x208+0x60,b'\x11\x22\x33\x44')
v,why,kind=Rd[b'sample'](avatar,0);check(kind==b'UNVERIFIED' and b'44332211:' in why,why)
m=replay();L,N,Z,Rd=setup_bytes(m);v,_,_=Rd[b'sample'](avatar,0);check(v is not None)
# 8. Native not ready => UNAVAILABLE.
N[b'ready']=False;N[b'disabled']=True;N[b'reason']=b'x';v,why,kind=Rd[b'sample'](avatar,0);check(v is None and kind==b'UNAVAILABLE')
# 9. Zone hashes equal murmur64 upper-32 of their names.
sys.path.insert(0,str(R/'tools'));from archive_writer import murmur64
L2=LuaRuntime(unpack_returned_tuples=True);Z2=L2.execute((R/'src/zones.lua').read_text())
for h,info in Z2.names.items():check('%08x'%(murmur64(info.id.encode())>>32)==h,info.id)
check(len(list(Z2.order.values()))==6,'lights removed from display order')

# 10. Lifecycle + renderer with a fake stingray.
def fake_env(rect_ok=True):
 L=LuaRuntime(unpack_returned_tuples=True)
 L.execute(r'''
 calls={rect=0,text=0,destroy=0,created=0,prev=0,shutdown=0}
 local id=0
 stingray={Application={worlds=function() return {'MAIN','UI'} end,main_world=function() return 'MAIN' end},
  Network={game_session=function() return 'S' end,peer_id=function() return 'P' end},
  GameSession={in_session=function() return true end,objects_owned_by=function() return {5} end,
   game_object_is_type=function(s,id,t) return id==5 and t=='un6y1d' end,
   game_object_field=function(s,id,f) if id==5 and f=='baegche' then return AVATAR end end,
   game_object_exists=function() return true end},
  World={create_screen_gui=function() calls.created=calls.created+1;return 'GUI' end,destroy_gui=function() end},
  Gui={resolution=function() return 1920,1080 end,
   rect=function(g,p,s,c) assert(type(p)=='table' and #p==3 and type(s)=='table'); if not RECT_OK then error('no rect') end calls.rect=calls.rect+1;id=id+1;return id end,
   text=function(g,s,...) calls.text=calls.text+1;texts[#texts+1]=s;id=id+1;return id end,
   destroy_rect=function() calls.destroy=calls.destroy+1 end,destroy_text=function() calls.destroy=calls.destroy+1 end},
  Vector2=function(x,y) return {x,y} end,Vector3=function(x,y,z) return {x,y,z} end,Color=function(a,r,g,b) return {a,r,g,b} end}
 texts={}
 CowboyBingusModLoader={api=1,version=16}
 function update(dt) calls.prev=calls.prev+1 end
 function shutdown() calls.shutdown=calls.shutdown+1 end
 ''')
 L.globals().RECT_OK=rect_ok;L.globals().AVATAR=avatar
 return L
def install(L,reader):
 G=L.globals();sr=G.stingray
 N=L.eval('{version="test"}')
 Z=L.execute((R/'src/zones.lua').read_text());Render=L.execute((R/'src/render.lua').read_text());cfg=L.execute((R/'src/config.lua').read_text())
 hud=L.execute((R/'src/hud.lua').read_text())
 ART=L.execute((R/'src/art.lua').read_text());RM=L.execute((R/'src/render_mech.lua').read_text())
 return hud(sr,N,reader,Render,Z,cfg,ART,RM,None,None)
sample_result={'mode':'ok'}
def make_reader(L):
 def sample(av,now):
  if sample_result['mode']=='ok':
   t=L.eval(('''function(Z) local zs={} local ids={{'ca47a7a9',400,400},{'2739dfac',400,120},{'e1b844f5',400,CRIT},{'668f6a68',400,0},{'87b05ff4',550,550},{'64a3fa1d',550,260},{'2be9516e',10,10},{'657ffa09',10,0}}
    for i,v in ipairs(ids) do zs[i]={index=i-1,hash=v[1],info=Z.names[v[1]],hp=v[3],max=v[2],valid=true} end
    return {model={name='EXO-55 BREACHER',code='EXO-55',cn='突破'},vehicle={goid=368,entity=123,unit=456,resource="35dbf54f016f3624"},hull=900,hull_max=1800,hull_valid=true,zones=zs} end''').replace('CRIT',str(sample_result.get('crit',400))))
   return t(L.execute((R/'src/zones.lua').read_text()))
  return (None,b'x' if False else 'EMPTY','NOT_SEATED')
 return L.table_from({'sample':sample})
import os,tempfile
os.environ['APPDATA']=tempfile.mkdtemp()
os.makedirs(os.environ['APPDATA']+'/Arrowhead/Helldivers2',exist_ok=True)
# Release default: debug logging off when no mech_hud.cfg exists.
_L0=LuaRuntime(unpack_returned_tuples=True);_C0=_L0.execute((R/'src/config.lua').read_text())(os.environ['APPDATA']+'/Arrowhead/Helldivers2/')
check(_C0.debug==False,'release default debug=false')
# This lifecycle test inspects the log, so opt in explicitly like a user reporting a problem.
(Path(os.environ['APPDATA'])/'Arrowhead/Helldivers2/mech_hud.cfg').write_text('debug=true\n')
for rect_ok in (True,False):
 L=fake_env(rect_ok);st=install(L,make_reader(L));G=L.globals()
 check(st.installed if hasattr(st,'installed') else True)
 dup=install(L,make_reader(L));check(dup.duplicate==True)
 sample_result['mode']='ok'
 for i in range(30):G.update(0.05)
 check(G.calls.prev==30,'previous update forwarded')
 texts=[G.texts[i+1] for i in range(len(G.texts))]
 if rect_ok:
  check(G.calls.rect>250,'mech schematic drawn')
  check(G.calls.text==0,'all text drawn with rect glyphs')
  check(G.calls.created==1)
 else:
  check('EXO-55' in texts and 'LEG L' in texts and 'HIPS' in texts,'text fallback labels')
  check('900' in texts and '260' in texts and 'X' in texts)
  check(any(t.startswith('[') for t in texts),'ascii bar fallback')
 n=G.calls.text;nr=G.calls.rect
 for i in range(10):G.update(0.01)
 check(G.calls.text==n and G.calls.rect==nr,'no redraw when unchanged')
 sample_result['mode']='off'
 for i in range(20):G.update(0.05)
 check(G.calls.destroy>0,'hidden after leaving mech')
 G.shutdown();check(G.calls.shutdown==1)
log=Path(os.environ['APPDATA'])/'Arrowhead/Helldivers2/mech_hud.log'
check(log.exists() and 'SHOW EXO-55' in log.read_text())
# 10b. Blink redraws dynamic layer only; damage change keeps static layer.
L=fake_env(True);st=install(L,make_reader(L));G=L.globals();sample_result['mode']='ok';sample_result['crit']=80
for i in range(5):G.update(0.05)
created=G.calls.created;d0=G.calls.destroy
for i in range(40):G.update(0.05)
check(G.calls.created==created,'gui surface reused')
check(G.calls.destroy>d0,'critical part blink redraws dynamic layer')
# 10c. Glyph coverage: every Chinese label in the renderer exists in art.lua.
import re as _re
Lg=LuaRuntime(unpack_returned_tuples=True);ART=Lg.execute((R/'src/art.lua').read_text())
src=(R/'src/render_mech.lua').read_text()
need=set(ch for ch in ''.join(_re.findall(r"'([^'\x00-\x7f]+)'",src)) if ord(ch)>0x2e80)
Zg=Lg.execute((R/'src/zones.lua').read_text())
for _,md in Zg.models.items():
 need|=set(md.cn or '')
 if md.arm_labels: need|=set((md.arm_labels.L or '')+(md.arm_labels.R or ''))
missing=[ch for ch in need if ART.glyphs[ch] is None]
check(not missing,missing)
# 12. Arm discovery link rules on synthetic Health tables.
def arm_env(owners):
 L=LuaRuntime(unpack_returned_tuples=True,encoding=None)
 N=L.execute((R/'src/native.lua').read_bytes());Z=L.execute((R/'src/zones.lua').read_bytes())
 m=Mem();g=0x10000000;net=0x20000000;hm=0x30000000
 m.put(g+0x346bf98,struct.pack('<Q',net));m.put(g+0x3326688,struct.pack('<Q',hm))
 cap=16;ent=0x60000000;descs=0x61000000
 m.put(hm+0x1030,struct.pack('<QIII',ent,cap,0xffffffff,1));m.put(ent,b'\xff'*cap*8);m.put(hm+0x1048,struct.pack('<Q',descs))
 for j,(res,e,unit) in enumerate(owners):
  m.put(ent+j*8,struct.pack('<II',e,j));dp=0x62000000+j*32
  m.put(descs+j*8,struct.pack('<Q',dp));m.put(dp,struct.pack('<QIIII',int(res,16),e,unit,100+j,1))
 N[b'base']=g;N[b'ready']=True
 N[b'win']=L.table_from({b'read':lambda a,n:m.read(a,n),b'begin_sample':lambda:None})
 A=L.execute((R/'src/arms.lua').read_bytes())(N,Z)
 model=Z[b'models'][b'35dbf54f016f3624']
 mech=L.table_from({b'resource':b'35dbf54f016f3624',b'entity':1,b'unit':77})
 return A,mech,model
MECH='35dbf54f016f3624';ARM='65489809a8181b96'
A,mech,model=arm_env([(MECH,1,77),(ARM,2,77),(ARM,3,77),(ARM,9,55)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SAME_UNIT' and len(out)==2,link)
A,mech,model=arm_env([(MECH,1,77),(ARM,2,5),(ARM,3,6)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SOLE_MECH' and len(out)==2,link)
A,mech,model=arm_env([(MECH,1,77),(MECH,4,78),(ARM,2,5),(ARM,3,6)])
out,link,meta=A[b'discover'](mech,model);check(link==b'AMBIGUOUS' and len(out)==0,link)
A,mech,model=arm_env([(MECH,1,77)])
out,link,meta=A[b'discover'](mech,model);check(link==b'NONE',link)
# 12b. Unknown neighbouring owners are never promoted into arm ownership.
A,mech,model=arm_env([(MECH,1,77),(ARM,2,5),('1234567890abcdef',3,6),('fedcba0987654321',40,9)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SOLE_MECH' and len(out)==1,'unknown neighbour must not be promoted to an arm')
A,mech,model=arm_env([(MECH,1,77),(ARM,2,5),('1234567890abcdef',3,6),('1111111111111111',0,6)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SOLE_MECH' and len(out)==1,'two unknown neighbours -> no guess')
L2=LuaRuntime(unpack_returned_tuples=True);Z2=L2.execute((R/'src/zones.lua').read_text())
check(Z2.arm_side('824b7e0c4c879eb5',None)=='L' and Z2.arm_side('08f6089289c83d22',None)=='R' and Z2.arm_side('0736bee2d6328726',None)=='L' and Z2.arm_side('17c5d12d8d5dee2c',None)=='R');check(Z2.arm_side('65489809a8181b96',None)=='L' and Z2.arm_side('e5f64dcc3bfe9dd1',None)=='L' and Z2.arm_side('x',L2.eval("{{hash='b6f2baf5'}}"))=='R' and Z2.arm_side('x',L2.eval('{}')) is None)
# 13. Renderer: arms shown, lights absent, numbered labels when side unknown.
def rm_ops(arms):
 L=LuaRuntime(unpack_returned_tuples=True)
 L.execute("""n=0;sr={World={},Gui={rect=function() n=n+1 return n end,text=function() n=n+1 return n end,destroy_rect=function() end,destroy_text=function() end},
  Vector2=function(x,y) return {x,y} end,Vector3=function(x,y,z) return {x,y,z} end,Color=function(a,r,g,b) return {a,r,g,b} end}""")
 C=L.execute((R/'src/config.lua').read_text())(None);ART=L.execute((R/'src/art.lua').read_text())
 D=L.execute((R/'src/render_mech.lua').read_text())(L.globals().sr,C,ART,L.eval('{clear=function() end}'));D.gui='G'
 v=L.eval("{title={code='EXO-55',cn='突破'},hull=900,hull_max=1800,zones={hips={hp=400,max=400}},arms=%s,rows={},clock=0}"%arms)
 D.draw(1920,1080,v);return v,D,L
v,D,L=rm_ops('{{hp=100,max=600,side="L"},{hp=600,max=600,side="R"}}')
check(v.zones.arm_left.hp==100 and v.zones.arm_right.hp==600 and v.arms_numbered==False and D.mode=='MECH')
check(ART.zones['light_left_front'] is None and ART.zones['arm_left'] is not None,'lights not drawn as zones')
v,D,L=rm_ops('{{hp=100,max=600},{hp=600,max=600}}');check(v.arms_numbered==True)
v,D,L=rm_ops('nil');check(v.zones.arm_left is None and D.mode=='MECH','arms absent -> shown as --')
# 13b. v1.6: part percentages are drawn inside the icon; 100% only when full.
v,D,L=rm_ops('{{hp=799,max=800,side="L"}}');check(D.mode=='MECH')
Lp=LuaRuntime(unpack_returned_tuples=True)
Lp.execute('''sr={World={},Gui={rect=function() return 1 end,destroy_rect=function() end},Vector2=function(x,y) return {x,y} end,Vector3=function(x,y,z) return {x,y,z} end,Color=function(a,r,g,b) return {a,r,g,b} end}''')
ARTp=Lp.execute((R/'src/art.lua').read_text());check(all(ARTp.models[c] is not None and ARTp.models[c].arm_left is not None and ARTp.models[c].arm_right is not None for c in ('default','EXO-45','EXO-49','EXO-51','EXO-55')),'per-model arm art');check(all(ARTp.small[c] is not None for c in '0123456789%-/'),'small digits present')
# 12c. v1.10: EXO-55 shotgun resource catalogued; per-exosuit neighbours excluded from guessing.
A,mech,model=arm_env([(MECH,1,77),(ARM,2,5),('df51fe8d62f294be',3,6),('75be82ed8592a6b3',0,6),('8365609b35ef6672',4,6)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SOLE_MECH' and len(out)==2,link)
A,mech,model=arm_env([(MECH,1,77),(ARM,2,5),('75be82ed8592a6b3',0,6),('8365609b35ef6672',4,6)])
out,link,meta=A[b'discover'](mech,model);check(link==b'SOLE_MECH' and len(out)==1,'known non-arm neighbours never guessed')
# 12d. HD font sets exist for 1x/1.5x/2x and cover every label.
Lf=LuaRuntime(unpack_returned_tuples=True);ARTf=Lf.execute((R/'src/art.lua').read_text())
for k in ('1','1.5','2'):
 fs=ARTf.fonts[k];check(fs is not None and fs.big.g['驾'] is not None and fs.small.g['%'] is not None and fs.small.g['毁'] is not None,k)
# 13c. Destroyed part shows the 损毁 label (small glyphs exist) and panel sits bottom-right of centre.
check(ARTp.small['损'] is not None and ARTp.small['毁'] is not None)
Ls=LuaRuntime(unpack_returned_tuples=True)
Ls.execute('''ops={};sr={World={},Gui={rect=function(g,p,s,c) ops[#ops+1]={p[1],p[2],s[1],s[2]} return #ops end,destroy_rect=function() end},Vector2=function(x,y) return {x,y} end,Vector3=function(x,y,z) return {x,y,z} end,Color=function(a,r,g,b) return {a,r,g,b} end}''')
Cs=Ls.execute((R/'src/config.lua').read_text())(None);ARTs=Ls.execute((R/'src/art.lua').read_text())
Ds=Ls.execute((R/'src/render_mech.lua').read_text())(Ls.globals().sr,Cs,ARTs,Ls.eval('{clear=function() end}'));Ds.gui='G'
Ds.draw(1920,1080,Ls.eval("{title={code='EXO-55',cn='突破'},hull=900,hull_max=1800,zones={hips={hp=0,max=400}},arms={{hp=800,max=800,side='L'},{hp=0,max=800,side='R'}},rows={},clock=0}"))
xs=[Ls.globals().ops[i][1] for i in range(1,len(Ls.globals().ops)+1)]
check(min(xs)>960 and max(xs)<1920*0.8,('bottom-right toward centre',min(xs),max(xs)))
# 11. Config clamps values.
cfgdir=Path(os.environ['APPDATA'])/'Arrowhead/Helldivers2/'
(cfgdir/'mech_hud.cfg').write_text('x=5\nscale=0.1\nalpha=0.5\ndebug=false\nfoo=os.exit()\n')
L=LuaRuntime(unpack_returned_tuples=True);C=L.execute((R/'src/config.lua').read_text())(str(cfgdir)+'/')
check(C.x==1 and C.right==0.27 and C.plate==0.28 and C.scale==0.5 and C.alpha==0.5 and C.debug==False)
print(json.dumps({'checks':checks,'snapshot':SNAP.name,'real_capture_replayed':True}))
