#!/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,fssv,getClass,clsName,objGetClass,isnFn; 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;ik__BackingField")); if(f.isNull())return false; dirInstFld=f; send({ms:"DIR_RESOLVED"}); return true; }catch(e){return false;} } function unstickVault(){ try{ if(!resolveDirInstFld())return; var o=Memory.alloc(4);o.writePointer(ptr(0));fsgv(dirInstFld,o);var d=o.readPointer(); if(d.isNull())return; var cs=d.add(0x38).readPointer(); if(cs.isNull())return; if(cs.add(0x34).readU8()==1){ cs.add(0x34).writeU8(0); send({ms:"UNSTUCK"}); } }catch(e){} } setInterval(unstickVault,500); // TitleScreen unstick: the bare logo/version/playerID splash (class FE.TitleScreen) is // gated to advance only when a global "Noteable" bus event (case 0x39 in OnNoteableEvent) // fires, which starts the WarmUp() coroutine chain that eventually calls OnCanExitTitles() // -> 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. // Disasm of OnEvent's TitlesCompleted case (0xFADA7C, subagent-verified) shows it's just // `this._titlesHidden(+0x84)=1; jmp sharedEpilogue` -- currentScreen (+0x38) is NEVER touched by // this case, so that was a dead-end theory. The flag is only consumed by Director.Start()'s boot // coroutine (Director.c__Iterator0::MoveNext), which polls _titlesHidden once per frame at a // specific yield point and, once true, calls SnapCameraMove(_camDataPostTitles, false) (RVA 0xFC42D2) // to perform the actual title->menu camera transition. Our synthetic harness never pumps that // coroutine (no Unity frame loop driving it the normal way), so OnEvent(2) sets the flag but nothing // is alive to observe it -- exc=0, screen never moves. Fix: skip the coroutine dependency entirely // and call SnapCameraMove directly (CameraData `to` = Director._camDataPostTitles field @+0xB0, // read as the live CameraData object pointer since CameraData is a class/reference type). function snapCameraMove(d){ try{ var camData=d.add(0xB0).readPointer(); if(camData.isNull()){ send({ms:"SNAP_CAMERA_ERR", err:"camDataPostTitles null"}); return false; } var DirClass=findClass("FE","Director"); if(DirClass.isNull())return false; var m=gmn(DirClass,cstr("SnapCameraMove"),2); if(m.isNull())return false; var raiseLights=Memory.alloc(4); raiseLights.writeU8(0); var argsArr=Memory.alloc(Process.pointerSize*2); argsArr.writePointer(camData); argsArr.add(Process.pointerSize).writePointer(raiseLights); var e=Memory.alloc(4);e.writePointer(ptr(0)); rinv(m,d,argsArr,e); send({ms:"SNAP_CAMERA_FIRED", exc: e.readPointer().isNull()?0:1}); return true; }catch(x){ send({ms:"SNAP_CAMERA_ERR", err: ""+x}); return false; } } // SnapCameraMove alone only tweens the camera -- disasm of the real boot coroutine // (Director.c__Iterator0::MoveNext, subagent-verified) shows the call immediately // following it in the same synchronous block is Director.RequestChangeFeState(FlowState, // EnterSceneCallback) @0xFC9BB9 (dump.cs: private instance method, args are both reference // types). That's what actually does the FE.Director screen-transition bookkeeping and, when // not already mid-transition, calls Director.EnterMenuScene(...) + StartCoroutine on the // result -- a properly-started Unity coroutine that the engine's own live frame loop will // pump from here on (unlike Director.Start()'s own iterator, which our forced-boot path // never started via StartCoroutine at all, hence needing manual driving up to this point). // FlowState arg: subagent-verified disasm of the real boot coroutine (Director.c__Iterator0 // ::MoveNext, call site 0xFD3195) shows it does NOT pass back the current feState (that's a // guaranteed no-op -- RequestChangeFeState's own disasm at 0xFC9BB9 does an op_Equality check at // 0xFC9C35 between the new-state arg and current feState@+0x88, and short-circuits to the epilogue // when they match, skipping all scene-load logic entirely -- exactly the symptom we saw: exc=0, // fires clean, no visible transition). The real arg is Director._feDefinition (@+0x20, a Definition // object) -> Definition._registeredUserBoot (@+0x14), gated in the real code on accountEnabled==true // (our gu3 profile is a registered account, so this is the correct branch, not the sibling // feDefinition+0x94 "disabled account" FlowState). // EnterSceneCallback cb = null (nullable, matches disasm null-check guards on that path). // Iterations 16+17 both hit a native "access violation accessing 0x0" INSIDE the // il2cpp_runtime_invoke call below (not a JS-level null check -- feDef/feState were both // non-null), roughly 50% of recent runs -- previously assessed as a rare 1/6 flake but now // reproducing back-to-back. Since RequestChangeFeState is what actually starts the real // EnterMenuScene coroutine (see comment above), a crash here means TITLE_EXIT_FIRED still // logs (the JS try/catch swallows the SEGV and the outer exitTitles() flow continues // regardless) but the client never truly leaves the title screen -- consistent with the // NOMENU verdicts on both crashing runs. Likely a narrow init-order race. Retry the call // itself a few times with a short delay before giving up, instead of failing permanently // on the first hit. function requestChangeFeState(d,attempt){ attempt=attempt||0; try{ var feDef=d.add(0x20).readPointer(); if(feDef.isNull()){ send({ms:"FESTATE_ERR", err:"feDefinition null"}); return false; } var feState=feDef.add(0x14).readPointer(); // Definition._registeredUserBoot if(feState.isNull()){ send({ms:"FESTATE_ERR", err:"registeredUserBoot null"}); return false; } var DirClass=findClass("FE","Director"); if(DirClass.isNull())return false; var m=gmn(DirClass,cstr("RequestChangeFeState"),2); if(m.isNull())return false; var argsArr=Memory.alloc(Process.pointerSize*2); argsArr.writePointer(feState); argsArr.add(Process.pointerSize).writePointer(ptr(0)); var e=Memory.alloc(4);e.writePointer(ptr(0)); rinv(m,d,argsArr,e); send({ms:"FESTATE_FIRED", exc: e.readPointer().isNull()?0:1, attempt: attempt}); return true; }catch(x){ if(attempt<3){ // Subagent-verified (disasm of RequestChangeFeState 0xFC9BB9 + EnterMenuScene // MoveNext 0xFCF188): the crash happens INSIDE the synchronously-pumped first // MoveNext of the EnterMenuScene coroutine (started via StartCoroutine at the // tail of a clean run), which is what actually writes Director.feState@+0x88 -- // NOT RequestChangeFeState itself. So a crashed first call still leaves +0x88 // matching the target state by the time of the retry, and RequestChangeFeState's // own op_Equality short-circuit (0xFC9C35, old feState vs new-state arg) then // silently no-ops the retry (cb is always null in our call, so the equal-state // branch returns immediately without ever calling EnterMenuScene/StartCoroutine // again) -- exactly matching the observed symptom: FESTATE_FIRED exc=0 attempt=1 // but no ENTERVAULT_ENABLED / menu ever follows. Null the field before retrying // so the equality check reports not-equal and the real path re-fires. Nulling a // reference-type field is always GC-safe, and nothing else runs between this // write and the immediate retry call in this synchronous single-threaded harness. // Iteration 20: nulling feState alone got the retry PAST the op_Equality short-circuit // (FESTATE_FIRED now reliably fires on attempt=1, exc=0) but ENTERVAULT_ENABLED still // never followed on any post-retry run -- live logs (dogfood_debug.log) show every // attempt=1 run dead-ends right after FESTATE_FIRED, while the handful of attempt=0 // (no-crash, no-retry) runs DO reach ENTERVAULT_ENABLED and even MATCHMAKING once. // dump.cs-confirmed second guard: FE.Director._inScreenTransition (bool @+0x72, same // class as feState@+0x88, plain non-backing-field bool -- RequestChangeFeState/ // EnterMenuScene set this true on entry and only clear it at the end of a successful // coroutine pump). The crashed attempt=0 call would have set it true before dying, // and RequestChangeFeState's real logic (per the op_Equality comment above) also // gates entering EnterMenuScene on not-already-mid-transition -- so even after // unsticking the feState equality check, a stuck _inScreenTransition=true would still // silently skip the real EnterMenuScene/StartCoroutine call. Clear it alongside feState. // iter42: the retry's own native re-invoke of RequestChangeFeState is what's now // fatally crashing the WHOLE process (not just this JS call) -- live run confirmed // via dogfood_debug.log: TITLE_EXIT_FIRED logs fine (attempt=0 crash caught cleanly // here), then EVERY setInterval tick (ensureMask/exitTitles, previously firing every // 500ms without fail) goes dead forever within ~1.2s, i.e. exactly the scheduled // retry delay below -- consistent with attempt=1's native call segfaulting in a way // Frida's exception wrapper can't catch this time (unlike attempt=0's clean catch). // Per iter20's own notes this retry NEVER once produced forward progress anyway (no // ENTERVAULT_ENABLED ever followed an attempt=1 run in any iteration's history) -- // only attempt=0-clean runs reached ENTERVAULT_ENABLED/MATCHMAKING. So the retry is a // pure liability now: zero observed benefit, and it can take down the entire process // (destroying all telemetry for the rest of the 140s menu-detection window). Keep the // field-nulling (cheap, GC-safe, may still let Unity's own natural frame loop recover // the transition on its own) but drop the native re-invoke retry entirely. try{ d.add(0x88).writePointer(ptr(0)); }catch(e2){} try{ d.add(0x72).writeU8(0); }catch(e3){} send({ms:"FESTATE_ERR", err: ""+x, attempt: attempt, skippedRetry: 1}); return false; } send({ms:"FESTATE_ERR", err: ""+x, attempt: attempt}); return false; } } 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}); snapCameraMove(d); requestChangeFeState(d); return true; }catch(x){ send({ms:"DIRECTOR_EVENT_ERR", err: ""+x}); return false; } } // ROOT CAUSE (subagent-verified disasm of MenuCharacter::ConfigureFromPlayerProfile @0xd437c9): // it reads PlayerProfile.instance.mask (get_mask @0xd91f2b, field k__BackingField @+0x134) // and throws NRE if null. That field is only populated by PlayerProfile.LinkInventory, which runs // asynchronously off the gu3 "fullSync" reply -- a separate network round-trip from the fixed // 2000ms wall-clock timer that used to gate exitTitles(). FE.Director+c__IteratorB // calls MenuCharacter.OnEnable -> ConfigureFromPlayerProfile as soon as the title->menu transition // fires; if that races ahead of the gu3 sync, the coroutine throws and dies silently, freezing the // menu-scene load (observed live: loading bar stuck ~33%, gu3pub.log shows the exact NRE). Fix: // gate on PlayerProfile.instance.mask actually being non-null before firing the transition, with a // generous timeout fallback (so a genuinely-broken sync doesn't hang the run forever, just degrades // back to the old timing behavior). // Subagent-verified disasm (0xd437c9 ConfigureFromPlayerProfile + 0xe9ea40 // SignalUnlocksToTutorial): TWO independent PlayerProfile fields gate the // EnterMenuScene/EnterVault init chain, not just mask -- k__BackingField // @0x134 (confirmed correct, matches get_mask's own disasm) AND // lootSafeSlotIndices @0x124 (direct field, no getter; SignalUnlocksToTutorial // indexes it right after its null check). Gate on both. var PPClass=ptr(0), getInstM=ptr(0); function profileReady(){ try{ if(PPClass.isNull()){ PPClass=findClass("","PlayerProfile"); if(PPClass.isNull())return false; } if(getInstM.isNull()){ getInstM=gmn(PPClass,cstr("get_instance"),0); if(getInstM.isNull())return false; } var e=Memory.alloc(4);e.writePointer(ptr(0)); var inst=rinv(getInstM,ptr(0),ptr(0),e); if(!e.readPointer().isNull()||inst.isNull())return false; return !inst.add(0x134).readPointer().isNull() && !inst.add(0x124).readPointer().isNull(); }catch(x){return false;} } // ROOT CAUSE (iter26, two subagent disasm passes): the "mask" key we send in the gu3sync // profile section is DEAD DATA -- PlayerProfile.LinkInventory (0xd9086e) never reads the // _maskIndex field LoadProfile (0xd8b417) stores it into. Mask is instead meant to // auto-equip purely by ownership: LinkInventory scans the owned `inventory` dict // (populated by LoadInventoryItem/Constants.FetchCommodity from our "gadget_N" sections) // for a mask-category Commodity and writes `this.mask = item` directly (0xd90d94). That // apparently never fires for our synthetic "mask_1" gadget, so PlayerProfile.instance.mask // (offset 0x134) stays permanently null and MenuCharacter::ConfigureFromPlayerProfile // (0xd437c9+0x9c, confirmed via NRETHROW_HIT ra=0xd43865 caller trace) hard-crashes doing a // real IL2CPP interface-vtable dispatch through it. First attempt (construct a fresh // PlayerProfile.MetaInventoryItem via FetchCommodity+il2cpp_object_new) failed because the // nested private class isn't resolvable via a plain findClass("","MetaInventoryItem") // namespace/name lookup (MASK_METACLASS_NULL every tick, live-verified). Our own // FetchCommodity("mask_1") call DID succeed though (no MASK_FETCH_NULL was ever logged), // which means LoadInventoryItem's identical call almost certainly also succeeded when it // processed our gadget_0 section -- so a real, correctly-typed mask item should already // exist in PlayerProfile.instance.ownedMasks (IList @0x144, populated by // LoadInventoryItem specifically for mask-category commodities per subagent disasm). Fix // v2: read that list back out via its own get_ownedMasks()/get_Count()/get_Item(int) // accessors (resolving the list's concrete class dynamically via il2cpp_object_get_class, // so we never need to name-resolve the nested class at all) and poke element 0 directly // into the mask backing field -- same proven direct-field-patch pattern already used for // MatchPersona ai/role, but using an object the game itself already constructed instead of // building a new one. // Fix v3 (iter27, revision 2): v3-rev1 called PlayerProfile.SetThiefMask(Commodity,bool) // @0xD9B47E -- it ran with NO exception (e3 null) but LIVE-DISPROVEN: mask stayed null every // tick (MASK_ENSURED ok:0 throughout). dump.cs shows why: SetThiefMask has private nested // closure classes (c__AnonStorey1D/1E) with a `Json json` callback parameter -- // it's an ASYNC server round-trip (persists the equip server-side, assigns `this.mask` only // inside the response callback), not a synchronous setter. Our gu3server has no handler for // whatever request it sends, so the callback never fires. // Fix v3-rev2: use Constants.Commodity.GetPlayerInventoryItem() @0x240915 instead -- a plain // synchronous instance method directly on Commodity (dump.cs-confirmed, returns InventoryItem) // that looks the owned item up from the player's raw inventory. v2 already proved the // CATEGORIZED ownedMasks list is empty, but GetPlayerInventoryItem may hit a different/raw // owned-inventory store that LoadInventoryItem populated correctly even if categorization into // ownedMasks specifically is what's broken -- worth testing before assuming inventory is // empty entirely. Resolve the method off the commodity's own runtime class via // il2cpp_object_get_class (same pattern already proven for the ownedMasks list class), so no // namespace/nested-name lookup is needed for Constants.Commodity itself. var maskEnsured=false, ConstClass=ptr(0), fetchCommM=ptr(0); function ensureMask(){ if(maskEnsured)return true; try{ if(PPClass.isNull()){ PPClass=findClass("","PlayerProfile"); if(PPClass.isNull())return false; } if(getInstM.isNull()){ getInstM=gmn(PPClass,cstr("get_instance"),0); if(getInstM.isNull())return false; } var e=Memory.alloc(4);e.writePointer(ptr(0)); var inst=rinv(getInstM,ptr(0),ptr(0),e); if(!e.readPointer().isNull()||inst.isNull())return false; if(!inst.add(0x134).readPointer().isNull()){ maskEnsured=true; return true; } if(!isnFn||!objGetClass)return false; if(ConstClass.isNull()){ ConstClass=findClass("","Constants"); if(ConstClass.isNull())return false; } if(fetchCommM.isNull()){ fetchCommM=gmn(ConstClass,cstr("FetchCommodity"),1); if(fetchCommM.isNull())return false; } var strp=isnFn(cstr("mask_1")); if(strp.isNull())return false; var a1=Memory.alloc(Process.pointerSize); a1.writePointer(strp); var e2=Memory.alloc(4);e2.writePointer(ptr(0)); var comm=rinv(fetchCommM,ptr(0),a1,e2); if(!e2.readPointer().isNull()||comm.isNull()){ send({ms:"MASK_FETCH_NULL"}); return false; } var commClass=objGetClass(comm); if(commClass.isNull())return false; var getInvM=gmn(commClass,cstr("GetPlayerInventoryItem"),0); if(getInvM.isNull()){ send({ms:"MASK_GETINVM_NULL"}); return false; } var e3=Memory.alloc(4);e3.writePointer(ptr(0)); var item=rinv(getInvM,comm,ptr(0),e3); if(!e3.readPointer().isNull()){ send({ms:"MASK_GETINV_EXC"}); return false; } if(item.isNull()){ send({ms:"MASK_GETINV_NULL"}); return false; } inst.add(0x134).writePointer(item); maskEnsured=!inst.add(0x134).readPointer().isNull(); send({ms:"MASK_ENSURED", ok:maskEnsured?1:0}); return maskEnsured; }catch(x){ send({ms:"MASK_ENSURE_ERR", err:""+x}); return false; } } setInterval(ensureMask,500); function exitTitles(){ try{ if(titleFired||titleThis.isNull())return; if(Date.now()-titleStartTs<2000)return; var ready=profileReady(); // Prior runs NEVER observed ready=1 within the old 20s window even though the gu3 // fullSync/profile-push data was confirmed sent by the server 8-13s earlier -- give // real client-side processing much more room (60s) before degrading to the old // fire-anyway behavior; the outer harness's menu-detection budget is 140s so this // still leaves ample slack. if(!ready && Date.now()-titleStartTs<60000){ return; } send({ms:"PROFILE_GATE", ready: ready?1:0}); 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); // EnterVault role-select is unreachable by touch: adb `input tap` events land on the // focused Unity window (verified via logcat -- ACTION_DOWN/UP injected fine, app is // mFocusedApp) but nothing in the UI reacts to any of ~8 grid coordinates tried live on // the emulator, including dead-center on the SNIPER/THIEF buttons. Our forced-boot path // never goes through Unity's normal Update loop wiring, so EventSystem/GraphicRaycaster // never gets a working UI camera -- same class of problem as the TitleScreen/Director // coroutine gaps already fixed above by calling the C# handlers directly instead of // relying on synthetic input. Subagent-traced disasm of EnterVault's button handlers // (OnPressedThief/OnSelectThiefMode/OnSelectTopThiefButton/OnSelectBottomThiefButton, // RVAs 0xEA2113/0xEA22C1/0xEA2A15/0xEA2E17) confirms NONE of them call Matchmaking.Initiate // (0xD31A4F) directly or indirectly -- they only set Matchmaking's static matchRole/soloMode // fields and play a sub-menu reveal animation; Initiate() itself is fired further downstream // (MMVan screen) which we don't need to chase. Bot-vs-human is resolved server-side once // Initiate()'s bmm round-trip lands (matches CLAUDE.md ground truth), so it's safe to set // matchRole=Thief(2)/soloMode=true(1) and call Initiate() ourselves, on a live Matchmaking // instance obtained via Core.instance._matchmaking (Core is the persistent root object; // k__BackingField is a static field, same access pattern as FE.Director's above; // _matchmaking is an instance field at +0x28, dump.cs-verified). var matchFired=false, MMClass=ptr(0), initiateM=ptr(0), matchRoleFld=ptr(0), soloModeFld=ptr(0), CoreClass=ptr(0), coreInstFld=ptr(0); function triggerMatch(){ if(matchFired)return; try{ if(MMClass.isNull()){ MMClass=findClass("","Matchmaking"); if(MMClass.isNull())return; } if(initiateM.isNull()){ initiateM=gmn(MMClass,cstr("Initiate"),0); if(initiateM.isNull())return; } if(matchRoleFld.isNull()){ matchRoleFld=cffn(MMClass,cstr("matchRole")); if(matchRoleFld.isNull())return; } if(soloModeFld.isNull()){ soloModeFld=cffn(MMClass,cstr("soloMode")); if(soloModeFld.isNull())return; } if(CoreClass.isNull()){ CoreClass=findClass("FE","Core"); if(CoreClass.isNull())return; } if(coreInstFld.isNull()){ coreInstFld=cffn(CoreClass,cstr("k__BackingField")); if(coreInstFld.isNull())return; } var o=Memory.alloc(4);o.writePointer(ptr(0));fsgv(coreInstFld,o);var coreInst=o.readPointer(); if(coreInst.isNull())return; var mm=coreInst.add(0x28).readPointer(); if(mm.isNull())return; var rv=Memory.alloc(4);rv.writeU32(2);fssv(matchRoleFld,rv); // GameRole.Thief var sv=Memory.alloc(4);sv.writeU32(1);fssv(soloModeFld,sv); // solo bot match var e=Memory.alloc(4);e.writePointer(ptr(0)); rinv(initiateM,mm,ptr(0),e); matchFired=true; send({ms:"INITIATE_FIRED", exc: e.readPointer().isNull()?0:1}); }catch(x){ send({ms:"INITIATE_ERR", err: ""+x}); } } // iter23 ROOT CAUSE (iter22 subagent disasm): Matchmaking.Initiate -> GetLocalPersona reads // PlayerProfile.get_instance(); if that's still null (gu3 fullSync/profile push hasn't landed // yet), it hits an unpatched NRE-throw guard (0xd31f7e, same class as the already-patched // 0xbd06f4 sites) which segfaults under il2cpp_runtime_invoke instead of throwing catchably -- // this IS the "access violation accessing 0x40" crash. 29+ more guard sites downstream are all // gated on this same null instance pointer, so waiting for it to become non-null (reusing the // same get_instance() check profileReady() already uses for the title->menu gate) sidesteps the // whole cascade instead of bulk-NOPing 30 crash sites, which would just relocate the crash to // the first null-field dereference after each skipped guard. Poll instead of a blind fixed delay // -- prior runs showed profile population can lag well past a fixed few seconds. var matchGateStartTs=0, matchGateTimer=null; function playerProfileInstanceReady(){ try{ if(PPClass.isNull()){ PPClass=findClass("","PlayerProfile"); if(PPClass.isNull())return false; } if(getInstM.isNull()){ getInstM=gmn(PPClass,cstr("get_instance"),0); if(getInstM.isNull())return false; } var e=Memory.alloc(4);e.writePointer(ptr(0)); var inst=rinv(getInstM,ptr(0),ptr(0),e); return e.readPointer().isNull() && !inst.isNull(); }catch(x){return false;} } function pollTriggerMatch(){ if(matchFired){ if(matchGateTimer){clearInterval(matchGateTimer);matchGateTimer=null;} return; } var elapsed=Date.now()-matchGateStartTs; var ready=playerProfileInstanceReady(); if(!ready && elapsed<20000){ return; } send({ms:"MATCH_GATE", ready: ready?1:0, waited: elapsed}); if(matchGateTimer){clearInterval(matchGateTimer);matchGateTimer=null;} triggerMatch(); } try{Interceptor.attach(B.add(0xE9C68E),{onEnter:function(){ send({ms:"ENTERVAULT_ENABLED"}); matchGateStartTs=Date.now(); matchGateTimer=setInterval(pollTriggerMatch,500); }});}catch(e){} // stubs (analytics/LootSafe/StartThiefIntro) + master force + pvCache sanitizer // iter41: 0xB3C65F added -- UnityEngine.Advertisements.Android.Platform$$Initialize (lookup.py- // confirmed). iter33's uaInit replace at 0xB4030C targets the IOS Platform class's native bridge, // which is dead code on this Android build (UNITYADS_INIT_BLOCKED has NEVER fired in any run's // debug log, across every iteration) -- explains why the ~22s-post-TITLESCREEN_START crash at an // unmapped 0xf346xxxx address (same class as the earlier MAIN_RETRY access-violation addresses) // kept recurring even with that hook installed: the real Android ad-init path never went through // it. Platform.Initialize(string,bool) is the actual entrypoint that drives the AndroidJavaObject/ // JNI calls into the embedded WebView (com.unity3d.ads.webview.WebViewApp, iter33's tombstone // trace). Stubbing it with the same proven ret-byte-patch pattern as the other 9 void-returning // analytics/LootSafe stubs below (rather than a NativeCallback replace, which would need to match // IL2CPP's exact native ABI for a 2-arg instance method and risks a worse crash on a wrong guess) // skips the JNI/WebView init entirely; the managed-side event wiring around it doesn't need it. [0xd53c49,0xd3ce0b,0xd494ee,0xd4dcda,0xd29beb,0xd299a4,0xd2a24a,0xd29aea,0xe4a362,0xb3c65f].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){} // Matchmaking.Initiate(): OnPersonaAdded delegate is never subscribed in our headless // bypass (no UI screen wires it up before we force Initiate()), so its null-check throw // (0xd31bea jne->call 0xbd06f4) crashes with a raw SIGSEGV -- il2cpp_runtime_invoke calls // this method outside the normal managed call stack, and the shared NRE-throw helper's // stack-walk/exception-construction machinery (0xbd06f4) segfaults on the corrupted // context instead of raising a catchable exception (disasm-verified via subagent + objdump). // Fix: force the jne unconditional (skip the throw) and NOP the subsequent Invoke() call // on the still-null delegate (0xd31c15, 5 bytes) -- neither depends on eax from a prior // call, so this just silently skips the "notify persona added" UI hook, which we don't need. // iter22: Matchmaking::Initiate has three EARLIER null-check throw sites (same shared // helper 0xbd06f4, same crash class) before the already-patched 0xd31bea one. objdump- // reverified (iter22): 0xd31a9e/0xd31ae5/0xd31b12 are each `jne +5` guarding `call 0xbd06f4`, // falling through with esi=0/null into the next call exactly like the proven p1 site -- // "access violation accessing 0x40" fires immediately on Initiate() entry (before p1/p2/p3 // are ever reached), so these three, not the already-patched ones, are the real blocker. try{ [0xd31a9e,0xd31ae5,0xd31b12].forEach(function(a){ var p=B.add(a); if(p.readU8()===0x75){ Memory.protect(p,1,'rwx'); p.writeU8(0xeb); } }); }catch(e){} try{ var p1=B.add(0xd31bea); if(p1.readU8()===0x75){ Memory.protect(p1,1,'rwx'); p1.writeU8(0xeb); } var p2=B.add(0xd31c15); if(p2.readU8()===0xe8){ Memory.protect(p2,5,'rwx'); for(var i=0;i<5;i++)p2.add(i).writeU8(0x90); } // 0xd31c51: call UnityEngine.MonoBehaviour::StartCoroutine (icall trampoline @0x786fca, // outside libil2cpp.so) -- disasm-confirmed (iter 20 subagent + iter 21 objdump re-check) // this is the unguarded boundary behind the "access violation accessing 0x40" SIGSEGV in // Matchmaking::Initiate under our headless bypass (no real Unity scene/scheduler context // for the coroutine to attach to). Its return value (eax) is dead here -- the very next // instruction (0xd31c56) stores esi (=0), not eax, into the tracked slot -- so NOPing the // call is register-safe; we just skip actually scheduling the coroutine, which is fine // since nothing in this headless path pumps the Unity coroutine scheduler anyway. var p3=B.add(0xd31c51); if(p3.readU8()===0xe8){ Memory.protect(p3,5,'rwx'); for(var i=0;i<5;i++)p3.add(i).writeU8(0x90); } }catch(e){} // iter24 DIAGNOSTIC (queued by iter23): log the caller (return address, as an RVA) of every // call into the shared NRE-throw/stack-unwind helper 0xbd06f4, so we know EXACTLY which // guard site fires at "access violation accessing 0x40" crash time instead of guessing from // static disasm. x86 cdecl: return address is the DWORD at [esp] on function entry. Purely // additive (no behavior change) -- every message is already logged raw to dogfood_debug.log // by on_msg() regardless of MILESTONES membership, so no Python-side change needed. try{Interceptor.attach(B.add(0xbd06f4),{onEnter:function(){ try{var ra=this.context.esp.readPointer();send({ms:"NRETHROW_HIT", ra:"0x"+ra.sub(B).toString(16)});}catch(e){} }});}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); // iter43 DIAGNOSTIC (additive, no behavior change -- still returns false, still lets the // process die): this exact crash (address 0xf346dae5, mm=null) has now fired byte-identical // across two separate runs (iter42 and iter43), right after TITLE_EXIT_FIRED, regardless of // whether the requestChangeFeState retry is present (iter42 removed it -- crash persisted // unchanged). That rules out the retry as the cause and revives iter33's two live theories: // (a) a genuine wild jump/dangling-pointer SIGSEGV, or (b) the breakpad/Chromium-WebView // SIGABRT tombstone iter33 found in logcat. d.type distinguishes these outright ('abort' vs // 'access-violation'); Process.findRangeByAddress (unlike findModuleByAddress) also resolves // anonymous/unnamed mappings (JIT code, WebView native heap) so a null result here means // truly unmapped memory, not just an unnamed one. d.memory/d.context give the exact faulting // op and register state so the next iteration can act on data instead of guessing. var nc=0; Process.setExceptionHandler(function(d){nc++;if(nc<=1){ var a=d.address;var mm=Process.findModuleByAddress(a); var rg=null;try{rg=Process.findRangeByAddress(a);}catch(e){} var info={crash:(mm?mm.name+"+0x"+a.sub(mm.base).toString(16):(""+a)), type:(""+d.type), range:(rg?("base="+rg.base+" size="+rg.size+" prot="+rg.protection):"unmapped")}; try{if(d.memory){info.memop=""+d.memory.operation;info.memaddr="0x"+d.memory.address.toString(16); var mrg=null;try{mrg=Process.findRangeByAddress(d.memory.address);}catch(e){} info.memrange=mrg?("base="+mrg.base+" size="+mrg.size+" prot="+mrg.protection):"unmapped";}}catch(e){} try{var c=d.context;info.eip="0x"+c.eip.toString(16);info.esp="0x"+c.esp.toString(16);}catch(e){} send(info); }return false;}); // iter33: tombstone_22 (06:16:08, ~25s post-READY, right at TITLE_EXIT_FIRED/DIRECTOR_EVENT_FIRED) // is a SIGABRT preceded by "google-breakpad: Failed to generate minidump" in logcat -- Chromium's // own crash handler aborting after ITS dump attempt failed, not our game code. classes.dex has // Lcom/unity3d/ads/webview/WebViewApp (Unity Ads' embedded WebView, the only ad SDK WebView present // -- grepped for mytarget/admob/applovin/etc, none found). dump.cs shows the call chain: // FE.Director$$InitialiseUnityAds (0xFC422A, fires right at Director-event time) calls into // UnityEngine.Advertisements.iOS.Platform$$UnityAdsEngineInitialize (0xB4030C, an extern/PInvoke // bridge straight to the native ad-engine JNI call that spins up the WebView). Tried a Java.perform // Java-layer block first (iter33 v1) -- 'Java' is not a defined global in this frida 17.17.0 raw // script context (frida-java-bridge is no longer auto-injected, needs frida-compile bundling we // don't have set up), confirmed via a standalone probe script, so switched to this native-level // Interceptor.replace instead, consistent with how every other hook in this file already works. // Replacing with a no-op skips the native ad-engine call entirely; FE.Director's managed-side // setup around it (delegates/callbacks) still runs harmlessly. try{ var uaInit=B.add(0xB4030C); Interceptor.replace(uaInit, new NativeCallback(function(){ send({ms:"UNITYADS_INIT_BLOCKED"}); }, 'void', ['pointer','pointer','int','pointer'])); }catch(e){ send({ms:"UNITYADS_HOOK_ERR", err: ""+e}); } send({ms:"READY"}); } // iter40: main() used to be called immediately on script load (right after dev.resume()), // i.e. at the EARLIEST possible instant post-spawn -- before IL2CPP's own domain/runtime init // (not just the dynamic linker, which iter39's ensureInitialized() already covers) has run. // The very first live run after iter39's fix still hit the identical crash iter38 diagnosed // (MAIN_RETRY err="access violation accessing 0xf346af00" from the dg()/dga()/etc. block), // then went totally silent for the rest of the 340s timeout -- confirming retries-after-crash // don't help: the interrupted native call wedges an IL2CPP-internal lock permanently, so EVERY // subsequent attempt (main()'s own setTimeout(main,150) retry AND the independent unstickVault // poller) hangs forever instead of erroring. Since a crashed first attempt can never be // recovered from, the fix has to stop it from happening at all, not catch it better -- give // IL2CPP a real head start (well past typical cold-start domain-init time) before the FIRST // native call is ever made, instead of racing it at t=0. setTimeout(main,4000); """ 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) # SPAWN-GATED LAUNCH (iter36): the old monkey-launch + late pidof-poll + dev.attach(pid) # left a multi-second race window between process start and hook installation. iter35 # traced the recurring fixed-timer boot crash (Unity Ads WebViewApp. -> sandboxed # Chromium WebView -> breakpad SIGABRT ~24-26s post-launch) and found the native intercept # on UnityAdsEngineInitialize (0xB4030C) never fires -- consistent with that call already # having happened before frida's late attach could install it. dev.spawn() starts the app # SUSPENDED (like `am start -S`), so the script (and every Interceptor.attach hook) loads # BEFORE any app code runs at all; dev.resume() then lets it go. This also subsumes the # old cold-boot SIGABRT retry (pid appears then vanishes within ~2s) and the separate # ProcessNotRespondingError attach retry (iter25) -- both folded into one spawn/attach/ # resume/verify loop below. def restart_frida_server(): try: fpid=sh("shell","pidof","frida-server").stdout.strip().split() if fpid: sh("shell","kill","-9",fpid[0]) except Exception: pass time.sleep(1) sh("shell","sh","-c","/data/local/tmp/frida-server >/dev/null 2>&1 &") time.sleep(3) pid=None; s=None; sc=None; mark=0 for attempt in range(2): sh("shell","am","force-stop","se.foglo.svt"); time.sleep(1) try: mark=len(open(PHOTON_LOG,errors="ignore").read().splitlines()) except Exception: mark=0 try: pid=dev.spawn("se.foglo.svt") s=dev.attach(pid); sc=s.create_script(JS); sc.on("message",on_msg); sc.load() dev.resume(pid) except Exception as e: print("SPAWN_ATTACH_FAIL attempt=%d err=%s"%(attempt,e),flush=True) try: s and s.detach() except Exception: pass pid=None; s=None; sc=None try: sh("shell","am","force-stop","se.foglo.svt") except Exception: pass restart_frida_server() dev=frida.get_usb_device(timeout=10) continue time.sleep(2) still=sh("shell","pidof","se.foglo.svt").stdout.split() if str(pid) in still: break try: sc.unload(); s.detach() except Exception: pass pid=None; s=None; sc=None if pid is None or s is None: print("DOGFOOD VERDICT stage=NOPID playable=no detail=spawn-attach-failed-twice",flush=True); sys.exit(3) 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()