#!/usr/bin/env python3 # Dogfood — eat our own dogfood: run ONE full match attempt end-to-end and emit a single # machine-readable verdict of how far it got. Correlates client-side (frida hooks) with the # Photon server log. Keeps the working ai=1/role fix so bots actually spawn. # # Final line is always: DOGFOOD VERDICT stage= playable= detail=<...> # Exit code 0 iff playable (a thief took damage). Built to be looped by a subagent. import frida, time, subprocess, sys, os ADB="/opt/android-sdk/platform-tools/adb" PHOTON_LOG="/root/ants/phudp2.log" DEBUG_LOG="/root/ants/dogfood_debug.log" # ---- milestone ladder (a set of what was HIT; verdict picks the best) ---- MILESTONES=["MENU","MATCHMAKING","CREATEBOTMATCH","PHOTON_MASTER","PHOTON_ROOM", "LOADLEVEL","GAMECONTROLLER","SPAWNPLAYERS","THIEF_SPAWNED","BOTS_WIRED","SHOOTABLE"] JS=r""" var B=null; var cfn,gmn,rinv,unbox,dg,dga,aimg,cffn,fsgv,getClass,clsName; function E(m,n){var p=m.findExportByName(n);if(!p)throw new Error("no "+n);return p;} function cstr(s){return Memory.allocUtf8String(s);} var GP=ptr(0), playersFld=ptr(0), isYouM=ptr(0); function findClass(ns,nm){var d=dg();var c=Memory.alloc(8);var a=dga(d,c);var n=c.readU32();var np=cstr(ns),mp=cstr(nm); for(var i=0;i32)return; for(var i=0;i EnterGame(). That bus event is not reliably delivered in this forced-boot path, so the // splash hangs forever (confirmed via live inspection: _warmedUp stays 0, and full disasm of // every TitleScreen method shows _warmedUp is dead state -- nothing reads it, so it is NOT // the gate). OnCanExitTitles (RVA 0xDD0586) is public, has no gating preconditions, and is // this class's designed single entry point for "leave titles now" (it calls EnterGame(), which // null-checks its own dependencies, then the base UiScreen dismiss vtable slot). We hook // TitleScreen::Start (0xDCF5E8) to capture the live `this` (x86 cdecl, first stack arg at // esp+4 -- same convention already used for the pvCache sanitizer below), then invoke // OnCanExitTitles via il2cpp_runtime_invoke a couple seconds later, once. var titleThis=ptr(0), titleStartTs=0, titleFired=false, TSClass=ptr(0), onCanExitM=ptr(0); try{Interceptor.attach(B.add(0xDCF5E8),{onEnter:function(){ titleThis=this.context.esp.add(4).readPointer(); titleStartTs=Date.now(); send({ms:"TITLESCREEN_START"}); }});}catch(e){} // TitleScreen.EnterGame (0xDD04A9) is just `this.eventListener?.OnEvent(Noteable.TitlesCompleted)` // (null-conditional dispatch via UiScreen.eventListener @+0x14). Our synthetic path never wires // eventListener, so OnCanExitTitles->EnterGame "succeeds" (no IL2CPP exception) but silently no-ops. // The real handler is FE.Director.instance.OnEvent(Noteable) (case 2 = TitlesCompleted), reached // normally via Director's own screen machinery. Call it directly, bypassing the unwired eventListener. function fireDirectorEvent(){ try{ if(!resolveDirInstFld())return false; var o=Memory.alloc(4);o.writePointer(ptr(0));fsgv(dirInstFld,o);var d=o.readPointer(); if(d.isNull())return false; var DirClass=findClass("FE","Director"); if(DirClass.isNull())return false; var onEventM=gmn(DirClass,cstr("OnEvent"),1); if(onEventM.isNull())return false; var argVal=Memory.alloc(4);argVal.writeS32(2); // Noteable.TitlesCompleted var argsArr=Memory.alloc(Process.pointerSize);argsArr.writePointer(argVal); var e=Memory.alloc(4);e.writePointer(ptr(0)); rinv(onEventM,d,argsArr,e); send({ms:"DIRECTOR_EVENT_FIRED", exc: e.readPointer().isNull()?0:1}); return true; }catch(x){ send({ms:"DIRECTOR_EVENT_ERR", err: ""+x}); return false; } } function exitTitles(){ try{ if(titleFired||titleThis.isNull())return; if(Date.now()-titleStartTs<2000)return; var firedDirector=fireDirectorEvent(); if(TSClass.isNull()){ TSClass=findClass("FE","TitleScreen"); if(TSClass.isNull())return; } if(onCanExitM.isNull()){ onCanExitM=gmn(TSClass,cstr("OnCanExitTitles"),0); if(onCanExitM.isNull())return; } var e=Memory.alloc(4);e.writePointer(ptr(0)); rinv(onCanExitM,titleThis,ptr(0),e); titleFired=true; send({ms:"TITLE_EXIT_FIRED", exc: e.readPointer().isNull()?0:1, director: firedDirector?1:0}); }catch(x){ send({ms:"TITLE_EXIT_ERR", err: ""+x}); } } setInterval(exitTitles,500); // stubs (analytics/LootSafe/StartThiefIntro) + master force + pvCache sanitizer [0xd53c49,0xd3ce0b,0xd494ee,0xd4dcda,0xd29beb,0xd299a4,0xd2a24a,0xd29aea,0xe4a362].forEach(function(a){try{var p=B.add(a);Memory.protect(p,16,'rwx');p.writeU8(0xc3);}catch(e){}}); try{Interceptor.attach(B.add(0xd6fede),{onLeave:function(r){r.replace(ptr(1));}});}catch(e){} function badpv(v){var u=v.toUInt32();if(u==0)return false;if((u&3)!=0)return true;if(u>=0xc0000000&&u<0xd8000000)return false;try{v.add(8).readPointer();return false;}catch(e){return true;}} try{Interceptor.attach(B.add(0xd6485b),{onEnter:function(){var s=this.context.esp.add(4).readPointer();if(badpv(s.add(0xc).readPointer()))s.add(0xc).writePointer(ptr(0));}});}catch(e){} // milestone markers function MS(a,name,fix){try{Interceptor.attach(B.add(a),{onEnter:function(){if(fix)markBots();send({ms:name});}});}catch(e){}} MS(0xd31a4f,"MATCHMAKING",false); MS(0xd34da2,"CREATEBOTMATCH",false); MS(0xd3cb37,"LOADLEVEL",false); MS(0xe47cab,"GAMECONTROLLER",false); MS(0xe4bd6a,"SPAWNPLAYERS",true); MS(0xe5a928,"ASSEMBLE",true); // fix point (not a milestone, drives markBots early) var awoke=0; try{Interceptor.attach(B.add(0xdf9152),{onEnter:function(){awoke++;markBots();send({ms:"THIEF_SPAWNED",n:awoke});}});}catch(e){} MS(0x103094a,"BOTS_WIRED",false); MS(0xe0b90c,"SHOOTABLE",false); var nc=0; Process.setExceptionHandler(function(d){nc++;if(nc<=1){var a=d.address;var mm=Process.findModuleByAddress(a);send({crash:(mm?mm.name+"+0x"+a.sub(mm.base).toString(16):(""+a))});}return false;}); send({ms:"READY"}); } main(); """ def sh(*a): return subprocess.run([ADB]+list(a),capture_output=True,text=True) def tap(x,y): subprocess.run([ADB,"shell","input","tap",str(x),str(y)],capture_output=True) def cap(): return subprocess.run([ADB,"exec-out","screencap","-p"],capture_output=True).stdout def photon_hits(mark_line): """Scan the photon server log from mark_line for server-side milestones.""" hits=set() try: lines=open(PHOTON_LOG,errors="ignore").read().splitlines()[mark_line:] except Exception: return hits blob="\n".join(lines) if "Master auth success" in blob: hits.add("PHOTON_MASTER") if "joined ActorNr" in blob or "JoinGame" in blob: hits.add("PHOTON_ROOM") return hits def main(): hits=set(); crash=[None]; thieves=[0] dbg=open(DEBUG_LOG,"a") def on_msg(mm,d): p=mm.get("payload") if isinstance(mm.get("payload"),dict) else None # Log every raw message (not just recognized milestones) -- this is the only # visibility into whether the TitleScreen/vault-unstick hooks (which send ms # values outside MILESTONES, e.g. TITLESCREEN_START/TITLE_EXIT_FIRED/ # TITLE_EXIT_ERR/DIR_RESOLVED/UNSTUCK/READY) actually fired on this run -- # previously those were silently dropped and every NOMENU run was indistinguishable. try: dbg.write("%.3f %r\n"%(time.time(),mm)); dbg.flush() except Exception: pass if not p: return if "ms" in p: v=p["ms"] if v in MILESTONES: hits.add(v) if v=="THIEF_SPAWNED": thieves[0]=max(thieves[0],p.get("n",1)) elif "crash" in p and crash[0] is None: crash[0]=p["crash"] # boot # Attach frida EARLY (before the menu-detection wait), not after: the client can get # stuck at the EnterVault screen with mainContainer inactive (see unstickVault in JS), # and that stall must be broken *during* the wait or the screenshot-size heuristic # below will never see a real menu and this always reports NOMENU. dev=frida.get_usb_device(timeout=10) # Cold-boot SIGABRT retry: some launches die ~150ms after Unity's "ApplicationInfo" # log line (tid UnityMain, signal 6, no abort message, unwinder can't get past # linker/vdso in the tombstone) -- confirmed via tombstone_04/tombstone_05 on two # separate runs. It happens before frida even attaches, so it's not anything our # hooks touch; it's an intermittent launch-time race in the environment (iterations # 1 and 3 launched clean). Detect it (pid appears then vanishes within ~2s) and # retry the whole launch instead of burning the run on one unlucky boot. pid=None; mark=0 for attempt in range(2): sh("shell","am","force-stop","se.foglo.svt"); time.sleep(1) sh("shell","input","keyevent","3"); time.sleep(1) try: mark=len(open(PHOTON_LOG,errors="ignore").read().splitlines()) except Exception: mark=0 sh("shell","monkey","-p","se.foglo.svt","-c","android.intent.category.LAUNCHER","1") cand=None; t_pid0=time.time() while time.time()-t_pid0<20: try: cand=int(sh("shell","pidof","se.foglo.svt").stdout.split()[0]); break except Exception: time.sleep(1) if cand is None: continue time.sleep(2) still=sh("shell","pidof","se.foglo.svt").stdout.split() if str(cand) in still: pid=cand; break if pid is None: print("DOGFOOD VERDICT stage=NOPID playable=no detail=pidof-failed",flush=True); sys.exit(3) s=dev.attach(pid); sc=s.create_script(JS); sc.on("message",on_msg); sc.load() t0=time.time(); menu=False while time.time()-t0<140: time.sleep(4) if len(cap())>600000: menu=True; break if not menu: # Detach cleanly before bailing -- leaving the script/session attached in the # target process on early exit is a leak that can wedge later diagnostic attaches. try: sc.unload(); s.detach() except Exception: pass print("DOGFOOD VERDICT stage=NOMENU playable=no detail=app-never-reached-menu",flush=True); sys.exit(3) hits.add("MENU"); time.sleep(3) tap(700,730); time.sleep(6); tap(1355,960) # SNIPER -> GO # watch up to 110s, early-exit on SHOOTABLE for _ in range(55): time.sleep(2) hits|=photon_hits(mark) if "SHOOTABLE" in hits: break hits|=photon_hits(mark) open("/root/ants/shots/dogfood.png","wb").write(cap()) try: sc.unload(); s.detach() except Exception: pass # verdict: best milestone reached best="MENU" for mstone in MILESTONES: if mstone in hits: best=mstone playable = "SHOOTABLE" in hits detail=[] detail.append("thieves=%d"%thieves[0]) detail.append("hits=%s"%("|".join(m for m in MILESTONES if m in hits))) if crash[0]: detail.append("crash=%s"%crash[0]) if not playable and not crash[0]: detail.append("stalled") print("DOGFOOD VERDICT stage=%s playable=%s detail=%s"%(best,"yes" if playable else "no"," ".join(detail)),flush=True) sys.exit(0 if playable else 1) if __name__=="__main__": main()