Skip to content

About

Reverse-engineered Mount & Blade II: Bannerlord save format, with tools to read and safely edit .sav files. Pure Python, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Bannerlord save tools

Reading — and carefully editing — Mount & Blade II: Bannerlord .sav files in pure Python.

The save format is undocumented, so docs/save-format.md is the real output of this project: a reverse-engineered spec derived from the game's own assemblies, verified against v1.4.8. The scripts here are what proved each claim in it.

Two kinds of question get answered:

  • What is actually in my save? — a hero's skills, focus and attributes; a town's garrison, militia and prisoners; why a town is or isn't rebelling; what a fief would cost to buy in a barter.
  • Can I change it safely? — every edit is a width-neutral overwrite, an int32 replaced in place inside the decompressed stream, so no length, folder table or entry count moves and the JSON header stays byte-identical. Nothing here rewrites structure.

Editing a save is still editing a save. Every patch tool is a dry run by default, asserts the value it expects to find before writing, and leaves a timestamped .bak when it overwrites in place. Back up your saves anyway.

Setup

python bannerlord/config.py          # check that your game and saves were found

Python 3.8+, and no dependencies for reading or patching a save. The game install and the save folder are discovered automatically — Steam (via the registry and every library in libraryfolders.vdf), GOG, Epic, and a OneDrive-redirected Documents folder. Override either if the search misses:

set BANNERLORD_GAME=D:\SteamLibrary\steamapps\common\Mount & Blade II Bannerlord
set BANNERLORD_SAVES=%USERPROFILE%\Documents\Mount and Blade II Bannerlord\Game Saves

Every tool takes -s/--save, which accepts a path or a bare save name, and defaults to your most recently written save. All of them have --help.

Reading a save

python tools/inspect/hero.py <name>              # attributes, focus and skills, any hero
python tools/inspect/hero.py --rank              # every named lord, by attribute total
python tools/inspect/skills.py                   # the player's skills, XP, focus + offsets
python tools/inspect/attributes.py               # the player's six attributes + offsets
python tools/inspect/garrison.py <settlement>    # garrison, militia and prisoner rosters
python tools/inspect/garrison.py <settlement> --detail    # + class, tier, culture breakdown
python tools/inspect/rebellion.py <town>         # every gate the game checks, and the gap
python tools/inspect/rebellion.py --all          # all 57 towns, closest to rebelling first
python tools/inspect/fiefprice.py <settlement>   # what the owner would accept in a barter
python tools/inspect/fiefprice.py --all          # every barterable fief, cheapest first
python tools/inspect/marriage.py                 # your clan's unmarried adults
python tools/inspect/marriage.py <member>        # who they can marry, priced and ranked
python tools/inspect/market.py <settlement>      # a market's stock with exact buy/sell prices
python tools/inspect/market.py --item grain      # one good across every market
python tools/inspect/market.py --best            # widest buy->sell margins right now
python tools/inspect/armory.py                   # the best armor/shield in stock, per slot
python tools/inspect/armory.py --slot body       # every body armor ranked, who stocks it
python tools/inspect/armory.py "Pavise Shield"   # one piece: stats, variants, stockists
python tools/inspect/workshop.py                 # every workshop: owner, capital, real pay/day
python tools/inspect/workshop.py --best          # every town x type as a purchase, best first
python tools/inspect/workshop.py <town>          # one town's shops, line by line
python tools/inspect/wanderer.py                 # every hireable wanderer: town, fee, wage
python tools/inspect/wanderer.py --role surgeon  # the tavern pool ranked for one job
python tools/inspect/clan.py --rank              # every clan: strength, members, fiefs
python tools/inspect/clan.py <clan>              # one clan's full ledger, line by line
python tools/inspect/governor.py <settlement>    # who should govern it: culture, perks, score
python tools/inspect/governor.py --all           # every fief you hold, candidates ranked
python tools/inspect/whereis.py <name>           # where a hero is: settlement, army, dungeon
python tools/inspect/whereis.py                  # the whole map: armies, sieges, prisoners
python tools/inspect/smith.py                    # your smiths, and what each template can build
python tools/inspect/smith.py --all              # best design per template: xp, price, profit
python tools/inspect/smith.py --template <id>    # one template's designs ranked
python tools/inspect/smith.py --catalogue        # price every weapon the game ships
python tools/inspect/smith.py --template <id> --parts   # a template's parts, by in-game name

rebellion.py, fiefprice.py, marriage.py, market.py, armory.py, workshop.py, wanderer.py, clan.py, governor.py, whereis.py and smith.py reimplement the game's own models (RebellionsCampaignBehavior, DefaultSettlementValueModel, DefaultMilitaryPowerModel, DefaultMarriageModel, MarriageBarterable, DefaultTradeItemPriceFactorModel, DefaultItemValueModel, WorkshopsCampaignBehavior, DefaultWorkshopModel, CompanionsCampaignBehavior, DefaultCompanionHiringPriceCalculationModel, Clan.UpdateCurrentStrength with DefaultDiplomacyModel's hero valuation and governing strength, DefaultSettlementLoyaltyModel with PerkHelper.AddPerkBonusForTown, Hero.CurrentSettlement / Hero.GetCampaignPosition, CraftingStats with CombatStatCalculator and DefaultSmithingModel) rather than approximating them, so the numbers are the ones the game will act on. Where a value genuinely cannot be known offline — the terrain and weather modifier on troop power, or the roll that decides a crafted weapon's modifier quality — the output brackets or omits it instead of guessing.

smith.py is the deepest of these: a crafted weapon's price is not stored anywhere, so it reimplements the whole crafting simulation the game runs at load — piece geometry, centre of mass, inertia, the numeric integration behind swing and thrust speed, the collision behind damage — because experience, sale price and profit are all functions of the value that falls out of it. See docs/save-format.md section 22.

Building a report

python tools/report/fief_ledger.py --open        # every fief as a browsable HTML page
python tools/report/rebellion_watch.py --open    # every town ranked by rebellion risk
python tools/report/lord_ranking.py --open       # every named lord ranked by attributes
python tools/report/marriage_finder.py --open    # every match for your clan, priced
python tools/report/price_atlas.py --open        # every market's buy/sell prices, browsable
python tools/report/armory_finder.py --open      # every armor piece and shield, who stocks it
python tools/report/workshop_ranking.py --open   # every workshop and every purchase, priced
python tools/report/wanderer_scout.py --open     # every hireable wanderer, priced and ranked
python tools/report/clan_ranking.py --open       # every clan ranked: troops, members, fiefs
python tools/report/hero_tracker.py --open       # find any hero on the map: army, dungeon, tavern
python tools/report/governor_council.py --open   # who should govern each fief, by culture and perks
python tools/report/forge_planner.py --open      # what to smith: xp, price and profit per design
python tools/report/report_shelf.py --all --open # rebuild every report, and the shelf that links them

Each report runs the matching inspect tool's model over a save (fiefprice.py, rebellion.py, hero.py, marriage.py, market.py, armory.py, workshop.py, wanderer.py, clan.py, whereis.py, governor.py, smith.py) and drops the result into its template in tools/report/templates/, producing one self-contained file — sortable, filterable, no server. The data is embedded as a <script type="application/json"> block and the template is the page with that block cut out, so edit the template to change how it looks and re-run to refresh the numbers.

report_shelf.py is the index: one page linking every report, with the save and campaign day each page was built from and whether the world has moved on since. It discovers its siblings instead of keeping a list, so a new report that follows the conventions appears on the shelf by itself; on its own it only re-indexes, --all rebuilds everything first.

Editing a save

Dry run by default; add --write to apply.

python tools/patch/attributes.py --set social=5:7 --set endurance=8
python tools/patch/focus.py <hero> --set Charm=2:5
python tools/patch/focus.py <hero> --move Riding=Steward
python tools/patch/garrison.py <settlement> --class HorseArcher
python tools/patch/modifier.py                              # everything you carry, variants marked
python tools/patch/modifier.py "Wolf Pelt Cape" --set legendary_leather
python tools/patch/modifier.py "Winds Fury" --clear
python tools/patch/modifier.py --market <town> "Warlord Helmet" --set legendary_chain

NAME=OLD:NEW asserts the current value before writing; NAME=NEW does not.

modifier.py sets, swaps or removes the variant modifier on gear ("Legendary …", "Battered …") — a modifier is a reference next to the item in the same slot, not a different item, so all three edits are the same in-place overwrite. It reaches your own gear, a clan member's (--hero), or a settlement's shop stock (--market, the same roster encoding — upgrade the shelf copy, then buy it in game at the variant's price). It only points at modifiers this save already serializes, and refuses a modifier from another group than the item's (the pairing the game itself never rolls) unless --force.

--move exists because a skill with 0 focus has no dictionary entry to write into. It re-points an existing entry's key at another skill, which moves the whole allocation and is exactly what the game itself would serialize. Focus is a level-locked budget (level + 10), so the total is held constant unless you pass --allow-total-change.

Checking that it all still works

A game update is the thing most likely to break this repo, and it breaks it quietly: a type or member id that moved does not raise, it reads the field next door and prints a plausible wrong number. So there is a suite, and it is stdlib unittest — no test dependencies either.

python tools/selftest.py                 # everything, then what changed since last time
python tools/selftest.py --quick         # only what needs no game and no save (instant)
python tools/selftest.py --record        # remember this environment as the baseline
python tools/selftest.py -s <save>       # fingerprint one particular save
python -m unittest discover tests -b     # the same suite, plain

It answers two questions separately. Did something break? — the container format, the width-neutral write path, the XML readers, every id in campaign.py checked against the game's own metadata and against the save's JSON header, and every tool run end to end. That is what the exit code reports. Did the game move? — a fingerprint of the install (version, settlement and item counts, distance cache, id tables) written to data/selftest-baseline.json and diffed on later runs, printed but not failed, because a patch shipping is news rather than a bug.

The strongest check is the id one. tests/ids.py records what every T_* and M_* constant is supposed to be by name; bannerlord/assemblies/ extracts what it actually is from the installed game. So after an update:

python -m bannerlord.assemblies.typeids     # re-extract the id tables
python -m bannerlord.assemblies.members
python tools/selftest.py

and anything that moved is named — M_HERO_GOLD = 651 on Hero is (no such member), expected _gold — which is the list of what has to be re-derived before the numbers can be trusted again.

Nothing in the suite writes to the save folder: every write test copies the save to a temporary directory and patches that. Tests that need a game install or a save skip without one, so the format, patch, config and model tests still run on a machine that has neither.

Layout

bannerlord/            the library
  config.py            finding the game and the save folder
  save.py              the .sav container, blob tree and member parsing
  analyzer.py          the same tree, plus stream offsets, which is what patching needs
  campaign.py          type ids, member ids, and the lookups every tool repeats
  patch.py             width-neutral edits and safe write-back
  cli.py               shared argument and banner boilerplate
  gamedata/            world data that a save never stores, read from the game's files
    settlements.py       display names, cultures, town vs castle
    troops.py            troop names, formation class, tier, troop power
    lords.py             named-lord spawns and skill templates
    items.py             item names, categories, denar values; armor/shield stats and tiers
    workshops.py         workshop types, their inputs and outputs
    perks.py             what every governor-role perk is worth, from the assemblies
    distances.py         the campaign map's precomputed distance cache
    modules.py           which version of the game is installed
  assemblies/          .NET metadata extraction -- how data/ was produced
    ildump.py            a minimal IL disassembler over dnfile
    typeids.py           SaveableTypeDefiner subclasses  -> data/typeids.json
    members.py           [SaveableField]/[SaveableProperty] -> data/members.json
data/                  where the extracted id tables land (generated, not checked in)
docs/save-format.md    the format spec
tools/inspect/         read-only terminal reports
tools/patch/           the width-neutral editors
tools/report/          HTML reports built from a template + a save
tools/selftest.py      run the suite, and diff the environment against the baseline
tests/                 the suite
  synth.py             a .sav built by hand, so the readers can be tested with no game
  gamefiles.py         a Modules/ tree built by hand, for the same reason
  fixture.py           a miniature campaign with every answer known
  ids.py               what every id in campaign.py is supposed to be, by name

bannerlord/gamedata/ exists because a save is far less self-contained than it looks. It stores town_EN6, not "Amprela"; it stores a StringId for every troop but nothing about what that troop is; and whether a settlement is a town or a castle — which decides whether it can rebel at all — is an XML attribute that is never serialized.

The id tables (optional)

bannerlord/assemblies/ extracts two lookup tables from your installed game's assemblies: save-system type ids, and the [SaveableField] / [SaveableProperty] member id of every saved field. They are derived from a retail install and are not redistributed here, so generate your own if you want them:

pip install -r requirements.txt      # dnfile
python -m bannerlord.assemblies.typeids
python -m bannerlord.assemblies.members

Nothing in tools/ needs them — every tool resolves objects by StringId and by the ids in bannerlord/campaign.py, and runs fine with data/ empty. The tables are a reference for exploring a save by hand, and worth regenerating after a game update:

from bannerlord.analyzer import Analyzer
typeids, members = Analyzer(path).names      # {} until you generate them
typeids[330095]                              # -> 'Settlement'

See data/README.md.

Scope

Verified against v1.4.8.119303 (Native / SandBoxCore / CustomBattle / Sandbox / StoryMode / NavalDLC v1.2.8), Windows retail. Type and member ids move between game versions, and older saves do not parse at all — a save from 1.2.x or 1.5.x is rejected with a message saying so rather than a traceback.

Nothing here touches the game's files, hooks the running process, or works online. It reads and rewrites save files on disk, and reads the game's own XML and distance caches.

License

MIT — see LICENSE. Not affiliated with or endorsed by TaleWorlds Entertainment.

About

Reverse-engineered Mount & Blade II: Bannerlord save format, with tools to read and safely edit .sav files. Pure Python, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

⌂ Proxy