Convert BloonsTD6+ (Apple Arcade) save files so they can be loaded by the Steam version of Bloons Tower Defense 6. Conversion in the other direction (Steam → Apple Arcade) is also supported (although it is not required as the Apple Arcade version can load Steam saves).
Web app — convert in your browser with no setup required.
The Apple Arcade version can load Steam saves natively, but the Steam version rejects Apple Arcade saves. This is because the save file embeds a platform identifier (savedBySkuId) that the Steam client checks and refuses if it doesn't match. This tool decrypts the save, patches the relevant fields, and re-encrypts it in a format the Steam client accepts.
See REVERSE_ENGINEERING.md for a full account of how the save format was figured out.
The script uses uv inline script metadata to declare its own dependency (pycryptodome), so no manual setup is needed if you have uv installed.
With uv (recommended):
uv run convert.py <command> ...Without uv, install the dependency manually then run with Python 3.10+:
pip install "pycryptodome>=3.23.0"
python3 convert.py <command> ...Takes your Apple Arcade save and produces a file the Steam version will load:
uv run convert.py to-steam BloonsTD6+.Save output.SaveThe output will have the Steam platform ID in its header and savedBySkuId set to 1136 (Steam's SKU). All gameplay progress — XP, monkey money, unlocks, etc. — is preserved exactly.
uv run convert.py to-arcade BloonsSteam.Save output.SavePrints a summary of a save without modifying it:
uv run convert.py inspect BloonsTD6+.SaveExample output:
Inspecting: BloonsTD6+.Save
Header platform ID : 95 (Apple Arcade)
savedBySkuId : 1108 (Apple Arcade)
savedByGameVersion : 53.2
rank : 48.0
xp : 1598108.0
monkeyMoney : 3708.0
ownerID : 69bdbad81ab8d0727b4fcb42
timeStamp : 2026-03-27T19:49:05.896705-07:00
JSON size : 83,611 bytes
Decrypts a save file to a human-readable JSON file for manual inspection or editing:
uv run convert.py decrypt BloonsTD6+.Save save.jsonRe-encrypts a JSON file (previously produced by decrypt) back into a .Save file. Use --platform to set the target platform:
uv run convert.py encrypt save.json Profile.Save --platform steam
uv run convert.py encrypt save.json Profile.Save --platform arcade| Platform | Save file location |
|---|---|
| Steam (macOS) | ~/Library/Application Support/NinjaKiwi/BTD6/Profile.Save |
| Steam (Windows) | %APPDATA%\NinjaKiwi\BTD6\Profile.Save |
| Apple Arcade (macOS) | ~/Library/Containers/com.ninjakiwi.bloonstd6plus/Data/Library/Application Support/com.ninjakiwi.bloonstd6plus/link/PRODUCTION/<SaveID>/Profile.Save |
Back up your save before converting. The converter writes a new file and never modifies the original.
to-steam makes three targeted changes to the JSON payload; everything else is left untouched:
| Field | Apple Arcade value | Steam value |
|---|---|---|
savedBySkuId |
1108 |
1136 |
pendingSteamAttribution |
null |
"" (empty string) |
trophiesWalletId |
UUID string | null |
The header's platform ID (bytes 8–11) is also updated from 95 to 18. A new random salt is generated for re-encryption, so the output bytes are never identical to the input even if the JSON is unchanged.
REVERSE_ENGINEERING.md documents the full process of figuring out the save format from scratch, starting with no prior knowledge. It covers:
- Initial reconnaissance — comparing the two save files byte-by-byte and parsing the header as structured integers to spot the platform ID difference at offset 8.
- Entropy and block-alignment analysis — using Shannon entropy (~8.0) to confirm the payload is encrypted, and testing AES block alignment to determine that the header is exactly 44 bytes.
- Game engine identification — recognising the Unity + IL2CPP setup from the bundle layout and understanding what that means: the game logic is compiled native code in
GameAssembly.dylib, but all class and method names are preserved in a separate symbol table (global-metadata.dat). - IL2CPP metadata mining — using
stringsonglobal-metadata.datas a symbol table to discover the save codec class hierarchy:EncryptedFileReader,EncryptedFileWriter,PlayerSavePasswordGenerator, and the compile-time constantsPBKDF2_ITERATIONS,PBKDF2_SALT_BITS,AES_KEY_BITS,ZLIB_LEVEL, and so on. - Encryption stack reconstruction — tracing the discovery of
RijndaelManaged(AES),Rfc2898DeriveBytes(PBKDF2), andCryptoStreamto confirm the full encryption pipeline. - Finding the password — why the password
"11"could not be extracted directly from the binary, and how cross-referencing with open-source community tools (monke,NKSave) confirmed it. - Decryption verification — the Python code that successfully decrypted both saves to JSON, proving the format was fully understood.
- Platform difference analysis — a systematic JSON diff of the two saves identifying
savedBySkuIdas the primary compatibility gate. - Implementation notes — annotated code for each stage of the pipeline (PBKDF2 derivation, AES-CBC, PKCS#7 padding, zlib), plus notes on JSON serialisation fidelity (BOM preservation, separator style).
- Complete file format reference — a byte-map table, all encryption parameters, and a "key insight chain" that summarises the logical steps from raw bytes to working decryption.