btd6 unlocker deluxe 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 game's own manual, extracted

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.

level ups that fight back

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.

the xp that would not stick

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.

the stat that saved to nowhere

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.

medals are not stats

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.

all 89

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.

the phantom heroes

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.

the hard unlock

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.

the menu, and a one-way mirror

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.

building it without a compiler

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 check

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