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
.bakwhen it overwrites in place. Back up your saves anyway.
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.
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.
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.
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.
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.
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.
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.
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.
MIT — see LICENSE. Not affiliated with or endorsed by TaleWorlds Entertainment.