botting a bloons profile: reversing btd6 with no source
this started small. i wanted the three towers that a fresh bloons td 6 save keeps locked behind lifetime pop milestones, without grinding to them. the tidy way is a mod, and there is already a good one, btd6-unlocker by interesting, which hands you every insta monkey and all the knowledge on a hotkey. i forked it. it grew. it is now a full profile editor with around sixty editable stats, medal and top-monkeys botting, currency tools, and an in-game menu. the feature list is in the readme.
this post is the other thing. it is how you figure out what to write when the game ships no manual, no source, and every internal name has been through a machine translator. almost every field below was a small mystery, and about half of them lied to me about whether they had worked.
the picture, if this is all new: btd6 is a unity game compiled with il2cpp, which means the original c#
was translated to c++ and then to native machine code. a mod does not get that source back. melonloader
and btd mod helper load your own c# into the running game and hand you reconstructed stubs of the game's
classes: the names survived, the bodies did not. so you can type player.Data.rank and it
compiles, but nothing tells you rank exists, what type it is, or what happens when you write to it. the
whole job is: find the fields, guess what each does, poke it, and watch what the game actually shows.
the thing that made all of this tractable: melonloader ships mono.cecil, a plain .net assembly reader,
in its net472 folder. point it at the game's reconstructed assemblies and you can walk
every type and dump its fields from powershell, in seconds, without the game even running. this one
trick is the source of every field name in this post.
Add-Type -Path "...\MelonLoader\net472\Mono.Cecil.dll"
$asm = [Mono.Cecil.AssemblyDefinition]::ReadAssembly(
"...\MelonLoader\Il2CppAssemblies\Assembly-CSharp.dll")
# walk every type, including nested ones, and print the fields of the one you want
$pm = $asm.MainModule.Types | ? { $_.FullName -eq
"Il2CppAssets.Scripts.Models.Profile.ProfileModel" }
$pm.Properties | % { "$($_.PropertyType.Name) $($_.Name)" }
ProfileModel is what you get from GameExt.GetBtd6Player(Game.instance).Data,
and it is the whole save: xp, rank, every currency, every lifetime counter. dumping it turns a black
box into a list of names to try.
one type shows up everywhere in these dumps and is worth knowing: KonFuze. it is ninja
kiwi's obfuscated number. the value is shuffled around in memory on purpose, so a cheat engine scanning
ram for "money = 50000" comes up empty. from inside the process you do not care, you read and
write its .Value and it deals with the scrambling. every coin and stat is one of these.
first target was player level, since that is what actually unlocks towers. the original mod's
GainPlayerXP works, but it queues a level-up ceremony per level, so maxing from
level 1 meant sitting through about 150 popups.
the dump showed rank and xp sitting right there as KonFuze. so skip the api
and set them directly, then SaveNow(). no ceremonies, and the reason is the good part: the
ceremony queue is not stored, it is derived from the gap between your rank and your xp. close the gap in
one write and there is nothing left to play. prestige is the same story one field over,
veteranRank. the lesson that carried through the whole project: the honest api path is
usually the wrong one for botting. go to the storage the api writes to, and write it yourself.
towers level up their upgrades with tower xp, and there is an obvious method for it,
AddTowerXP(name, amount). it exists, it compiles, the keypress even logs a success line.
nothing changes in game. my best read is that the setter recomputes or validates against something and
a raw add gets normalized straight back out. i did not chase it, because the upgrades themselves were
the real target anyway.
every tower model carries an appliedUpgrades list, the ids of the upgrades on its three
paths. AcquireUpgrade(towerModel.baseId, upgradeId, 0f) grants one at zero cost. loop every
tower, loop its upgrades, grant them all. that sticks, because it writes into the
acquiredUpgrades set that the shop actually reads when it decides what is unlocked. same
pattern as the levels: find the store, write the store.
this is the one i think about. building the stat editor, i wrote every pop count into the fields the
dump handed me, analyticsKonFuze.bloonsPopped and its neighbors. saved. opened the profile
stats screen. completely unchanged.
except that my own "load current stats" button read those same fields back as exactly the values i had written. so the write worked. the screen simply was not reading from there. re-reading your own write proves nothing, it is a tautology, and it had been quietly telling me "yes" the whole time.
the clue was a field i had skimmed past in the dump: hasTransferredStatsV49. and sure
enough, analyticsKonFuze had a nested basicStats object carrying the same field
names a second time. game update 49 migrated stats into basicStats, and the flat top-level
fields are the pre-v49 husk left behind for old saves. the stats screen is generated from
basicStats. i had been writing to the ghost the whole time.
var s = d.analyticsKonFuze; // legacy flat store, pre-v49 var bs = d.analyticsKonFuze.basicStats; // what the profile screen reads bs.bloonsPopped.Value = 487234116; // write the one that displays s.bloonsPopped.Value = 487234116; // and the legacy one, for consistency
write both, and it lands. this is the whole argument for testing against the real ui instead of a
re-read: the only source of truth in this game is what the screen renders after an actual save. a
smaller trap lived in the same dump, by the way: it is regrowPopped, singular, while every
field around it is plural. one wrong character is a silent miss with no error.
editing stats never moved the medals, and it never would, because a medal is not a number you set. it is
derived from a per-map completion record. the call is
mapInfo.CompleteMode(mapId, difficulty, mode, completedWithoutLoadingSave, isCoop), and that
fourth argument is the black-border flag, whether you finished without ever loading a save.
the mode ids are mostly what you would guess, except chimps, which is internally
"Clicks", an artifact of an old name. this matters more than it sounds: a wrong mode
id does not error, it writes a junk key the medal counter ignores, which is the exact silent-miss shape
again. so before trusting the tokens i grepped the assembly's raw strings to confirm every one. the map
list comes from GameData.Instance.mapSet.Maps.items, skipping the debug maps.
the first medal run completed every mode on every map. the profile then showed every medal count equal to 89, the number of maps. nobody has exactly 89 of everything. it reads as a dump because it is one.
so the real button builds a believable curve instead. a small stable hash of each
(map, mode) pair gives a fixed number 0 to 99, checked against a per-mode completion
chance: about 96% of easy standards, tapering down to roughly 18% of chimps, then scaled again by map
difficulty so beginner maps are near-complete and expert maps are sparse. a third of the wins are marked
black-border, and a thin slice get a co-op completion. it is deterministic, so re-running does not
reshuffle your profile into a different person each time.
int H(string s) { // stable 0-99 from a string, same every run
uint h = 5381;
foreach (char c in s) h = h * 33 + c;
return (int)(h % 100);
}
// complete this mode only if its hash falls under the mode's chance,
// itself scaled down by how hard the map is
if (H($"{id}|{diff}|{mode}") < chance * mapFactor / 100)
mapInfo.CompleteMode(id, diff, mode, blackBorder, coop:false);
now the profile looks like a strong player rather than a script. the 100% version is still one button over, for when you do not care who is looking.
botting the top-monkeys and top-heroes panels meant filling per-hero dictionaries with placement counts
and win counts. my first hero list came from "every tower whose tower set is Hero," which
returned 31 entries, including ShootyTurret, GenieBottle,
CreepyIdol, and a pile of geraldo's shop items, which are internally hero-set towers.
worse than clutter: mod helper runs its own profile cleaner on save that strips keys it does not
recognise, so those junk entries logged a wall of Cleaning heroLevelsByName ShootyTurret
and then vanished on the next save. the actual roster is a clean, separate list,
model.heroSet. use that and the panels populate and survive the save.
back to the original ask, the three pop-gated towers. the game defines that gate very literally: the
dump showed a TowerUnlockData type carrying a plain pops integer. so i max
the per-tower progress meter (towerUnlockProgresses), add the ids to the
unlockedTowers set, and call the real UnlockTower, three ways into the same
door.
but the surest lever was already sitting in the player object, and it belonged to ninja kiwi:
debugUnlockAllTowers, debugUnlockAllUpgrades,
debugUnlockAllPowersPro. their own dev switches, and the unlock checks consult them before
anything else. flip all three and the gates stop mattering. they are runtime-only, not saved, so the
button re-flips them each session. if a tower survives all of that, it is a server-side entitlement and
nothing local will touch it.
i wanted this to look like a real mod, not a stack of hotkeys. mod helper has a settings-page api,
ModSettingButton, ModSettingInt, ModSettingString, grouped under
ModSettingCategory. so: unlock buttons, editable currency and stat fields, and a five-line
console pinned at the bottom.
the console looked broken. actions ran, but the lines never changed on screen. the stat fields looked
broken the same way: the load button clearly read values, the log proved it, but the boxes on screen did
not move. reading mod helper's source explained it. SetValue updates the stored value, but
the binding between value and box is one-way, box to value, never value to box. to push text back onto
the screen you have to keep a handle on the actual input component, which mod helper gives you exactly
once, in the setting's modifyInput callback, and call SetText on it. i capture
every field's component when the page builds and write through those handles. now the console updates and
load fills the boxes.
a small aside that amused me. this machine has the .net runtimes but no sdk, so no dotnet
build. i compiled the whole mod with the roslyn compiler pulled straight out of its nuget package,
csc.exe with /noconfig /nostdlib+, referencing the game's own net6 and
Il2CppAssemblies dlls plus the shared framework, with a hand-written TargetFramework
attribute so melonloader treats the output as net6. the icon is an embedded resource, and mod helper only
finds it if the resource name ends in .Icon.png, dot included, which cost me one confused
rebuild to notice.
the through-line: this game tells you nothing, and half its fields lie about whether a write worked. a write succeeding means nothing. a re-read matching means nothing. the only truth is what the real screen renders after a real save. every trap here was the same shape, a write that "worked" into a store nobody reads: the ghost stat store, the tower xp normalized away, the junk medal keys, the phantom heroes swept up on save. so the loop was always the same. dump the type, write the field, save, close the menu, open the actual screen, look. it is the same discipline as confirming a coordinate from several directions before you commit to it. the game will happily tell you yes; you have to go and check.
source is on github, mit, a fork of interesting's original. it writes straight to your ninja kiwi profile and cannot be undone by removing the mod, so use an alt if you care. the entire point of the realistic-medal and stat work is that the result is indistinguishable from real play, which is also, precisely, what gets accounts flagged. worth saying plainly.
last updated 07/12/26, e. kruger