Save Files
playerInfo.dat holds a player's whole account — research, workshop, modules, cards, ultimate
weapons, bots, guardians, vault, relics, themes, and every battle the game kept. Decode it once and
read whichever parts your tool needs, each as a typed object.
The decode runs wherever you call it, including in a browser tab. There is no service to stand up and nothing to upload: a player's save can go from a file input to a parsed account without ever leaving their machine.
What The File Actually Is
A save is gzip-compressed .NET Binary Format — NRBF, the wire format BinaryFormatter writes. It is not JSON and not a database; it is a stream of typed records
describing objects, their class definitions, their members and the references between them, laid out
the way the .NET runtime laid them out in memory.
Reading that normally means .NET. The usual shape of a save-file tool is therefore a server: the player uploads their file, something on the other end deserializes it and hands back JSON. That is a backend to run and pay for, and it means every player has to send their account data somewhere to use your tool.
This package reads it directly. The NRBF reader here is a port of the format to TypeScript,
written against the record types themselves — ClassWithMembersAndTypes, BinaryArray, MemberReference, the object-null runs, the string table,
the reference graph that has to be resolved after the fact because records point forward as well
as back. It has no dependencies and touches nothing platform-specific, so the same reader runs in
Node and in a page.
Decode In The Browser
thetowersdk/save-decoder is the client-side path. Gunzip comes from the platform's
own DecompressionStream, so the entry point carries no Node imports at all and a
bundler has nothing to shim.
import { decodeSaveFile } from 'thetowersdk/save-decoder'
import { listImportableBattleRuns } from 'thetowersdk/save'
// Straight from a file input. Nothing is uploaded.
async function onFile(file) {
const root = await decodeSaveFile(await file.arrayBuffer())
return listImportableBattleRuns(root)
}The decode is CPU-bound, so for a large save do it in a Web Worker and post the result back — the
Run Tracker does exactly that, and falls back to the main thread where Worker is
unavailable. decodeInflatedSave is there for the worker case, where the gunzip has already
happened.
// worker.ts
import { decodeInflatedSave } from 'thetowersdk/save-decoder'
self.onmessage = (event) => {
self.postMessage(decodeInflatedSave(event.data))
}Decode In Node
Same reader, node:zlib for the gunzip. decodePlayerInfoSaveBytes returns
the parsed account under parsedRoot, alongside the number of battles it found and
whether the file was compressed. Every extractor on this page takes that parsedRoot.
import { readFileSync } from 'node:fs'
import { decodePlayerInfoSaveBytes } from 'thetowersdk/node'
const decoded = decodePlayerInfoSaveBytes(readFileSync('playerInfo.dat'))
console.log(decoded.battleRunCount) // 30
console.log(decoded.wasGzip) // true
const root = decoded.parsedRoot // pass this to the extractorsReach for the reader itself — NRBFReader and nrbfToJSON, exported from
both entry points — when you want the raw record stream rather than a save root.
Read The Run History
listImportableBattleRuns returns the battle reports as the game recorded them. Each run
carries well over a hundred fields — tier and wave, coins and cash, damage by source, enemies by kind,
and what killed you.
import { listImportableBattleRuns } from 'thetowersdk/save'
const runs = listImportableBattleRuns(root)
for (const run of runs.slice(0, 5)) {
console.log({
date: run.battleDate, // '2026-08-09T00:21:42.063Z'
tier: run.tier, // 23
wave: run.wave, // 41
coins: run.coinsEarned,
realTime: run.realTime, // seconds of wall clock
gameTime: run.gameTime // seconds of in-game time
})
}Coins per hour falls out of two of those fields, which is where most run trackers start.
import { formatNumberForDisplay } from 'thetowersdk/formatting'
const withCph = runs.map((run) => ({
tier: run.tier,
wave: run.wave,
cph: formatNumberForDisplay(run.coinsEarned / (run.realTime / 3600))
}))The damage and enemy breakdowns are on the same object, so a per-run detail view needs no second call.
const run = runs[0]
const damageBySource = {
projectiles: run.projectilesDamage,
thorns: run.thornDamage,
orbs: run.orbDamage,
deathWave: run.deathWaveDamage,
blackHole: run.blackHoleDamage,
smartMissiles: run.smartMissileDamage,
chainLightning: run.chainLightningDamage
}
const enemiesByKind = {
basic: run.totalBasic,
fast: run.totalFast,
tank: run.totalTank,
ranged: run.totalRanged,
boss: run.totalBoss,
elites: run.totalElites
}Read Account Progress
Each area has its own extractor, and each returns a summary plus the detail behind it.
import {
readLabsFromSaveRoot,
readCardsFromSaveRoot,
readUltimateWeaponsFromSaveRoot,
readBotsFromSaveRoot,
readVaultFromSaveRoot,
readModulesFromSaveRoot
} from 'thetowersdk/save'
const labs = readLabsFromSaveRoot(root)
console.log(labs.researchedCount) // 189
console.log(labs.maxedCount) // 147
console.log(labs.researches.length) // 250 — one entry per lab
console.log(labs.researches[0])
// { index: 0, level: 100, displayName: 'Damage', slug: 'damage',
// category: 'Attack', percentComplete: 0, remainingSeconds: 4341120 }
const cards = readCardsFromSaveRoot(root)
console.log(cards.slotsUnlocked) // 22
console.log(cards.cards[0])
// { index: 0, slug: 'dmg', name: 'Damage', level: 7, count: 0,
// unlocked: true, active: true, masteryUnlocked: true }
const uw = readUltimateWeaponsFromSaveRoot(root)
console.log(uw.slots.length) // 9
console.log(uw.slots[0])
// { slotIndex: 0, unlocked: true, active: true,
// plusLevel: 6, plusUnlocked: true, plusOn: true, baseStatLevels: [20, 4, 15] }
const bots = readBotsFromSaveRoot(root)
console.log(bots.bots[0])
// { index: 0, name: 'Flame Bot', label: 'Flame Bot', unlocked: true,
// active: true, statLevels: [0, 15, 0, 6], plusUnlocked: false, plusLevel: 0 }
const vault = readVaultFromSaveRoot(root)
console.log(vault.keys) // 79
console.log(vault.powerUnlockedCount) // 45
console.log(vault.harmonyUnlockedCount) // 45Join A Save To The Catalogs
A save gives levels; the catalogs give what each level costs and does. Put them together and you have a "what should I buy next" view for a specific account.
import { readLabsFromSaveRoot } from 'thetowersdk/save'
import { LAB_CATALOG } from 'thetowersdk/data'
import { formatNumberForDisplay } from 'thetowersdk/formatting'
const owned = readLabsFromSaveRoot(root)
// A research entry names its lab in `displayName`, which matches the catalog's `name`.
const nextUp = owned.researches
.map((research) => {
const catalog = LAB_CATALOG.find((entry) => entry.name === research.displayName)
const next = catalog?.levels.find((level) => level.level === research.level + 1)
return next && { name: research.displayName, level: research.level, cost: next.cost }
})
.filter(Boolean)
.sort((a, b) => a.cost - b.cost)
for (const lab of nextUp.slice(0, 5)) {
console.log(`${lab.name} ${lab.level} → ${lab.level + 1}: ${formatNumberForDisplay(lab.cost)}`)
}
// Interest 6 → 7: 3.59K
// Starting Cash 8 → 9: 4.51K
// Workshop Attack Discount 12 → 13: 13.35KGet The File From A Device
On Android the save lives at Android/data/com.TechTreeGames.TheTower/files/playerInfo.dat. adb-bridge finds it and serves it to your page over a local WebSocket
— from a phone or emulator over ADB, or from the Mac App Store build's own container. Ask it to watch
and it re-sends on every write, so a tracker updates while the player plays.
// The player runs this once:
// npx adb-bridge
const socket = new WebSocket('ws://127.0.0.1:8765')
socket.onmessage = async (event) => {
const bytes = new Uint8Array(await event.data.arrayBuffer())
const decoded = decodePlayerInfoSaveBytes(bytes)
render(listImportableBattleRuns(decoded.parsedRoot))
}