Gameloft's servers for The Amazing Spider-Man 2 have been dead since ~2018, and the iOS build hangs forever on “Downloading profile”. This patches the game so it launches, plays and saves entirely offline.
Nine instructions, all in the arm64 slice, every one derived from the disassembly and verified on the binary that actually ships. No new save format, no injected code, no size change.
Confirmed on device (LiveContainer, iOS): a continuous playthrough through the prologue, all of chapter 1 and into chapter 2, with the app force-quit several times along the way. Settings, trophies, exploration, skills, position, tutorial state and the story cursor all survive.
That line is the whole project in one sentence. Unpatched and offline, this
screen never appears — the game hangs on “Downloading profile”. Patched, the
chapter it reports comes from ud_OObjects.sav, the profile document that
edit 8 finally allowed to be
written: the local mission server read "ch2" back out of it at launch and
rebuilt the story cursor from the bundled ch2.json, with no server involved.
The workflow builds the IPA from the archive.org copy, and running a workflow needs write access to the repository it runs in — so fork this one first. The Actions tab of somebody else's repository will not run it for you.
- Fork, then Actions tab → Patch TASM2 IPA → Run workflow
- Download
SpiderMan2_patched.ipafrom the release the run creates - Install with LiveContainer / SideStore / Sideloadly
Patch before installing, not after. LiveContainer loads apps with
dlopen()and converts the binary fromMH_EXECUTEtoMH_DYLIBat install time. Replacing the binary inside an already-installed.appoverwrites that conversion and producescannot dlopen a main executable. When updating, delete the app first rather than installing over the top.
| Launch | no more “Downloading profile” spinner |
| Story | prologue plays once, then the chapters advance on their own |
| Saves | all 17 save objects persist to Documents/ud_*.sav |
| Profile | the story cursor persists in ud_OObjects.sav |
| Settings, trophies, fog of war, skills, position, tutorial state | all persist |
Everything in that frame used to travel in the Gameloft profile and is now a
local file: the trophy that just fired is ud_Trophy.sav, the uncovered part
of the minimap is ud_FogOfWar.sav, the spot Spider-Man will respawn at is
ud_InitPos.sav. Twelve of the seventeen save objects were server-persisted
until edit 2 set one byte.
- Anything genuinely online: shop purchases, events, friends. The servers are gone; the game says so with a “Le réseau actuel est indisponible” dialog, which is honest.
- A waiting spinner can sit on screen while a request is pending. There is
one widget for the whole game, shown while any bit of a mask is set, and the
raises are not matched by clears offline: reason 0 has 47 raises against 36
clears, reason 3 has five raises and a single clear. One request that never
answers keeps it up over every screen you open next — which is why it looks
like the skills menu's spinner, or the home menu's. Edit 9 fixes the one case
that was ours to cause;
--fail-networkshortens the waits that really are waits;--kill-spinnerstops the widget being displayed at all. See both below. - The “network unavailable” dialog still appears now and then. It is emitted from 15 separate places, each of which looks the text up and builds its own message box through the same helper every other dialog in the game uses — there is no shared switch to flip. Removing it would mean fifteen guessed control-flow edits for the sake of one OK tap, on a build that works. Left alone deliberately.
UI_DOWNLOADING_PROFILE has exactly one emission site. A shared predicate
decides between that spinner and the game's own UI_FIRST_CHECK (“nothing to
download, carry on”), and it keeps answering “yes” because the login never
completes.
bl <predicate> -> mov w0, #0
Applied only at that call site; the predicate itself is called from ~50 other
places and is left alone. Self-locating through the single reference to the
string: every ADRP/ADD pair forming that address is collected, each ADD has to
sit within eight instructions of its ADRP, and exactly one candidate must
survive — a first-match rule would patch a wrong bl in silence if a stale
register value ever produced one.
Each save object has a byte at +0x25: “persist me to a local
ud_<Name>.sav”. The constructor sets it on five of the seventeen — the
settings. Everything else rode in the Gameloft profile.
CSaveMgr::ReloadAll already walks all seventeen, already holds 1 in w25,
and its first act on each is the branch that skips the non-local ones:
0x10021bc78 ldrb w8, [x20, #0x25]
0x10021bc7c cbz w8, <next object> -> strb w25, [x20, #0x25]
From there the other twelve take exactly the path the five already take, with the game's own serialisers on both ends.
That object persists two ints. The second, progressMgr+0x2dc, is the
pending-mission id: the constructor sets it to -1, its only producer resets
its source to -1 right after use, and its only consumer reads -1 as
“nothing pending”. Every save stored the constructor default, so the slot was
free.
0x1001ff4c8 ldr w1, [x20, #0x2dc] -> mov w1, #1 serialise
0x1001ff594 str w0, [x19, #0x2dc] -> str w0, [x19, #0x2a4] restore
Still two ints at version 3 — format, length and version byte unchanged.
0x1001edd54 does not compute a chapter; it reads one out of the
mission-result map, whose only wholesale writer is the JSON callback of the
mission finished HTTP request. Offline the map stays empty and
map["chapter"] default-inserts 0.
0x1001edd28 ldr w20, [sp, #0x10] -> mov w20, #1
w20 is read nowhere between that load and the store.
Two paths threw it away, and device data caught both:
0x1003cc5f4 cbz w0, +0x14 -> nop the deserialiser stops discarding
the saved value when the chapter is 0
0x1003cc5b8 str wzr, [x0, #4] -> nop CTutorialMgr::Reset becomes a no-op
The second matters most: its single caller is the tail of the script
dispatcher at 0x1001205ec, so the game's own scripts request the reset, and
one runs whenever the opening sequence plays. Before: 132926 → 62
(0x0002073e → 0x0000003e, nine tutorial steps down to five). After:
132926 → 153406 → 161790.
The one that made story progression work.
progressMgr+0x50 is not an HTTP client in this build. It is a local
mission server (0x100416000..0x100422000) that reads the bundled
ch0.json … ch8.json and synthesises the answers Gameloft's server used to
send: request 0x16 fills the whole story cursor (progressMgr+0x110, a
map<string,vector<int>> keyed "mm"/"sm"/"prm"/"rm") from
profile["_ca"] == "chN"; request 0x15 advances profile["_ca"] to
chapterDoc["nc"].
Its state is save object 16 — the profile document — and object 16 is the only one of the seventeen the writer refuses to write:
0x100211620 ldr w8, [x19, #0x1c] ; SaveIndex
0x100211624 cmp w8, #0x10
0x100211628 b.ne <normal write>
0x100211634 ldr w8, [saveMgr, #0xfc8]
0x100211638 and w8, w8, #0xff00ff
0x10021163c cbz w8, <exit without writing> -> nop
+0xfc8 is “did this file already exist when the manager was constructed”;
+0xfca is “the cloud profile was touched”, set only on two dead-offline
paths. Clean install ⇒ both zero ⇒ never written ⇒ never exists. A loop that
locks itself, and the reason object 16's Reset default of "ch0" was what
the game started from every launch.
The read-back side already ran for object 16 unconditionally, so the nop
enables no new code path — the file simply starts existing.
ud_OObjects.savis the one save filetools/decode_sav.pycannot read, and that is by design: object 16's constructor sets the device-key flag at0x100215920, so its XXTEA key comes from a device secret rather than from the file's own trailer.
Edit 1 has a side effect it took a device report to notice. The main-menu
state machine raises the boot spinner once, then branches on the predicate:
the online branch sets a flag at 0x10110b0bb, the offline branch — the one
edit 1 forces — clears it. Both then show the same widget with their own
label.
That flag is what both of the spinner's hides are gated on: the state
machine's (0x1000ab60c → 0x1000ab62c) and the menu destructor's
(0x1000a8a70 → 0x1000a8a8c). With it at zero neither can fire, and the
destructor clears the other flag on its way past, so once the menu is torn
down nothing can ever take the widget down or put it up again. The only two
unconditional HideWaiting(12) sites in the binary are response callbacks
with no direct caller, which offline never run.
So the offline branch now stores w20 where it stored wzr — the same
instruction the online branch already carries, 0x3902ed14, same register,
same offset. w20 is 1 four instructions earlier and 0x1000ab7c0 is the
only branch in __text that lands here.
The flag is written in exactly two places and read in exactly two, both of
them these hides — established by scanning all 952 functions that reference
that page for any byte access at 0xb9, 0xba or 0xbb, because a linear
register sweep misses this site: its ADRP is 0x460 bytes back, on the far
side of the branch.
Dead Gameloft hostnames are rewritten to .invalid (RFC 6761: never
resolves), and the jailbreak detection paths and function are neutralised.
Every edit preserves length exactly.
| Neutralised host | Role |
|---|---|
| livewebapp.gameloft.com | autologin.php |
| eve.gameloft.com | profile services |
| pjsmmm-legacy.gameloft.com | legacy backend |
| ingameads.gameloft.com | ads / iphoneloading.php |
| 201205igp.gameloft.com | IGP / freemium |
Not in the shipped build yet; it needs a device run first.
0x100346c10 is the game's reachability predicate — it asks Reachability
for the current status, counts wifi and wwan as reachable, and caches the
answer for a second. Fifty call sites reach it, and every one of the
twenty-eight functions containing them is a network feature: login, shop and
IAP, leaderboards, friends and mail, analytics, the news and support buttons,
the cloud-profile upload in the save manager. Nothing in the local mission
server calls it, and nothing on the save/restore path does either.
Its tail is cmp w8,#0 ; cset w0,ne ; ret. Replacing the cset with
mov w0,#0 answers “no network” everywhere, which is a state Gameloft
shipped a branch for at each of those fifty sites — the same branch the main
patch above already forces at one of them.
The skills menu is the case that shows why it matters. SP_ShowSkillTree
(0x1003eafe8) calls 0x1000bf130, which raises the spinner when the goods
categories are not loaded, starts the fetch, then reads the predicate:
reachable → state 1, wait for the answer; unreachable → state 3, the offline
branch, which the manager's update (0x1000b1cd0) runs through 3 → 4 → −3
without any I/O, and whose handler calls 0x1000b1c0c — the only code in the
binary that clears that spinner. Same screen, same widget, same clearing path,
reached in a frame instead of a timeout.
One visible change beyond the waits: on a genuinely first launch, with no
profile file yet, the boot flow shows one UI_cloud_data_reminder dialog whose
OK button lands on the state the online branch jumped to. Once the file exists
— which is what edit 8 above is for — it never appears again.
Also not shipped by default, and meant to be used with --fail-network.
ShowWaiting sets its bit and then displays the widget. This jumps from just
after the mask update straight to the function's own epilogue, so the mask is
still maintained and only the display is skipped:
0x1001e7bc0 str w0, [x19, #0x330] ; mask |= 1<<reason, as always
0x1001e7bc4 bl 0x1001e78b0 -> b 0x1001e7f28 ; the epilogue
Deliberately not the first thing tried, which was ret at 0x1001e7b94 — that
skipped the mask update too, leaving ShowWaiting and HideWaiting disagreeing
about what was on screen. The body writes exactly two manager fields, +0x31d
and +0x10, both of them “the widget is visible” bookkeeping; left at zero,
HideWaiting finds nothing to hide and the shop's hide-everything helper
returns early. Both correct, because nothing is shown.
The trade is real: the widget is the only signal that a page is still loading.
That is why it pairs with --fail-network, which makes the pages that used to
wait fail in a frame instead.
--no-local-save, --no-persist-chapter, --no-tutorial-guards,
--no-profile-save, --no-boot-spinner-fix. An unknown flag is an error
rather than a silent no-op, so a typo cannot quietly drop an edit;
--no-force-prologue-skip still works as the former name of
--no-tutorial-guards.
The patcher writes nothing if a site is missing or ambiguous, and
patch_tasm2.py --verify <binary> re-checks the binary that ships: all nine
edits — the main patch included — plus the jailbreak edit, plus that no live
Gameloft host survived, plus that the removed prologue-skip edit is absent,
so a binary built by an older patcher cannot pass. The build workflow runs it
after rezipping, and that is the only gate on the release.
tools/ is the analysis harness. The three scripts that open the executable
themselves — scan.py, summary.py, st.py — read its path from TASM2_BIN,
defaulting to /home/user/AmazingSpiderMan2; the sweeps and closures import it
from st.py, so the variable covers them too. machoscan.py is a library and
takes the path as an argument, and decode_sav.py / encode_sav.py work on
.sav files named on the command line, not on the executable. Imports resolve
relative to each script's own location, so the directory works wherever it is
cloned:
pip install -r tools/requirements.txt # capstone, for the disassembler
TASM2_BIN=/path/to/AmazingSpiderMan2 python3 tools/scan.py dis 0x10021bc3c 40
patch_tasm2.py itself depends on nothing outside the standard library, which
is why the build workflow installs nothing.
machoscan.py |
FAT/Mach-O parsing, LC_FUNCTION_STARTS for exact function bounds, libc stub names from the indirect symbol table, ADRP/ADD cross-references |
scan.py |
dis / fn / str / sref / xref / callers / info |
summary.py |
dense per-function overview |
decode_sav.py |
decodes a ud_*.sav all the way to plain bytes |
encode_sav.py |
the inverse; refuses to write what it cannot read back |
sav-reader.html |
the same decoder as a self-contained page, to read a save on the phone |
st.py, sweep2.py, sweep2d8.py |
field sweeps that match a store's byte range, not its immediate — see below |
thisclose.py, closure2.py, closure3.py |
symbolic tracking of a base register through a function |
Matching str Wt,[Xn,#imm] against an offset is not an enumeration of a
field's writers: it misses every wider store at a lower offset. That is not
hypothetical — it is how the progress manager's constructor
(str d1,[x9], x9 = progressMgr+0x2a4) escaped the first sweep in this
repo, and it cost a device test.
- FAT
armv7 + arm64; all edits are in the arm64 slice cryptid=0on both slices → already decrypted, no Apple ID prompt- SDK iphoneos10.3 / Xcode 8.3, MinimumOSVersion 8.0
- Original SHA1
b3d322a788bbeeb1a006ba0da23a28300a5b7105, 33,375,152 bytes, unchanged after patching
The IPA is ~769 MB, which is why the workflow publishes it as a Release (2 GB limit) rather than an artifact (bounded by the 500 MB account quota).
Giving the game a server was tried on device and is a dead end. Redirecting
works end to end — the phone really did reach a Cloudflare Worker under our
control — but the only HTTP traffic is the ad WebView. autologin.php is
never called, even with the profile patch removed so the game attempts a
genuine login.
The profile went exclusively through the federation server: raw TCP, an
opcode-based binary protocol (_socket/_connect/_send/_recv,
port=7744&type=gs, Send federation request, opcode[%d]). Framing,
handshake, opcodes and any encryption would have to be reconstructed with no
reference capture, the servers having been dead since ~2018. Hostname length
is not the obstacle — a DNS rewrite points any name anywhere without
touching the binary.
Which is the irony of this project: the local replacement Gameloft shipped in
the same binary was there the whole time, behind one cbz.
Blocking those domains via DNS (NextDNS, AdGuard, an iOS profile) before the first launch produces the same network failure as the host rewrite, without touching any file and fully reversibly. It does not solve saving.
LOCAL_SAVE_DESIGN.md — the save subsystem, the decoded file format, the full reasoning behind each edit, and the log of everything that was tried and did not work, including the two conclusions that device data had to overturn.
MIT, and it covers this repository's own code — patch_tasm2.py,
tools/, the workflow and the documentation. Fork it, change it, ship it.
It does not and cannot cover The Amazing Spider-Man 2 itself. The game is Gameloft's; nothing here is a licence to it. The patcher ships no game code — it rewrites nine instructions in a binary you supply yourself.

