Skip to content

Repository files navigation

Breath of Fire Save State Editor

A Windows Forms editor for party stats in Breath of Fire (SNES), working directly on SNES9x 1.63 save states (*.000*.009).

The window is styled after the game's own status screen: navy ground, outlined boxes and white fixed-pitch text.

What it edits

Everything the status screen shows is editable, except the level:

Tab Group Fields
Stats Combat ATK, DEF, ACT, MAG, INT, FATE
Stats Vitals EXP, HP current/max, AP current/max
Stats Base stats Str., Vigor, Agil., Wisdom, Luck
Stats Read-only LEVEL, character name, equipped Wepn/Shld/Armr/Helm and the two Etc. accessories
Items Gold The party purse
Items Inventory Quantity of each consumable the party is carrying; spare equipment is listed but read-only
Magic Spells Read-only: the spells the picked character knows at their level, or the hero's dragon forms

Item quantities are editable; which item sits in a slot is not. The equip menu will not let you take a piece off, only swap it for another, so equipment is reported rather than edited. A slot can still be empty — Bo joins with no shield, id 0x0000, and the status screen simply leaves the line blank — it just cannot be emptied from inside the game.

Level is deliberately locked. Changing it on its own does not touch the stat spread or the level-up tables, so it just produces a character the game disagrees with about its own progression.

ATK, DEF, ACT, MAG, INT and FATE are derived values. The game recalculates them from the base stats and equipment, so an edit to one of them can be overwritten at the next level up or equipment change. Edit the base stats if you want a change that sticks.

Not handled yet

Magic is reported, not editable — for the pool casters there is nothing in a save to edit, since the game builds the list from the ROM each time it draws the menu. Which characters have magic and how many spells each gets is settled; the level each spell arrives at is not, which is why lists are still added one character at a time. See Magic.

The hero is the exception on both counts. His list is the dragon transformations, out of a table of their own, and they are unlocked by story trials rather than by levelling — so that part really is carried in the save, as a mask at block +0x482. It is read and reported like every other character's magic, and left alone for the same reason: it would put the hero further through the trials than the story he is in. See Dragon transformations.

The two Etc. accessory slots are reported like the other four. They are not stored with them: they sit at the front of the record, at +0x04, which is why they read as an unexplained zero run for as long as nobody in the party owned an accessory.

Using it

  1. File → Open and pick a save state, e.g. Breath of Fire.000 from your SNES9x Saves folder. Slot 0 of bank 0 is the file with the .000 extension.
  2. Pick a party member from the CHARACTER list.
  3. Edit the values and File → Save (Ctrl+S).
  4. Load the state in SNES9x.

A .bak copy of the previous file is written next to the state on every save. The editor reopens the last file you worked on when it starts.

Save state format

Notes from reverse-engineering an actual SNES9x 1.63 state, in case they are useful elsewhere.

The file is gzip-compressed. Decompressed it is a header line followed by fixed-length blocks laid back to back:

#!s9xsnp:0012\n
NAM:000008:<8 bytes>
CPU:000048:<72 bytes>
REG:000016:...
PPU:002652:...
DMA:000152:...
VRA:065536:...
RAM:131072:...     <- SNES WRAM, $7E0000-$7FFFFF
SRA:524288:...
FIL:032768:...
SND:066560:...
CTL:000091:...
TIM:000070:...
SHO:734213:...

Each block is NAME:LLLLLL: followed by exactly LLLLLL bytes. Blocks the editor does not understand are preserved byte for byte, and the file is written back gzip-compressed.

Party data

Live party data sits in WRAM at $7E1040, as an array of 0xA0-byte records. Slots 0–6 are confirmed by the per-slot index bytes the game pre-initialises at record offset +0x6F; the eighth slot is read as well and filtered out when it holds nothing plausible.

Record layout:

Offset Size Field
+0x00 4 Name
+0x04 2 Equipped "Etc." accessory, first slot (probe confirmed). Zero on everyone until the hero picked up a ring, and not EXP: a probe holding 12345 here still read EXP 0 on screen.
+0x06 2 Equipped "Etc." accessory, second slot (probe confirmed).
+0x08 2 Flags. Bit 0x40 means the character is not in the active party; see The save block. 0x0080 for Jack, Bo and the palace soldiers, 0x0081 for Nina and Karn, 0x00C0 on a benched Jack and 0x80C0 on a benched soldier. 0x01 is not "has magic", tempting as that looks: the ROM's starting records give Karn 0x0081 with no spell list at all and Bleu 0x0080 with 31.
+0x0A 1 Level
+0x0C 2 HP max
+0x0E 2 HP current
+0x10 2 AP max
+0x12 2 AP current
+0x14 3 EXP (little endian)
+0x17 1 Str.
+0x18 1 Vigor
+0x19 1 Wisdom (probe confirmed)
+0x1A 1 Agil.
+0x1B 1 MAG
+0x1C 1 Luck (probe confirmed)
+0x1D 2 ATK
+0x1F 2 DEF
+0x21 1 INT (probe confirmed)
+0x22 1 ACT
+0x23 1 FATE (probe confirmed)
+0x24 4 Unidentified. 05 00 00 00 in the reference save.
+0x28 2 Equipped weapon id
+0x2A 2 Equipped shield id
+0x2C 2 Equipped armour id
+0x2E 2 Equipped helmet id

Names use the game's own character set rather than ASCII. 0x81-0x9A is a-z, 0xC1-0xDA is A-Z, 0x20-0x3F is plain ASCII so digits and punctuation land where you expect, 0x7F is a space, and 0x00 ends the field. "Jack" is stored as CA 81 83 8B. The item screen draws 0x2E as a middle dot rather than a full stop, which is why M.Drop reads as M·Drop in game.

Note the ordering of the two vitals pairs: the record holds max first and current second, which is the reverse of the order the status screen prints them in. A state saved either side of a won battle settles both that and EXP — the character came out four HP down and two EXP up, and the only bytes of the record that moved were +0x0E (20 to 16) and +0x14 (0 to 2).

The two Etc. accessory slots are the same kind of 16-bit equipment id, but they are nowhere near the other four: they are the +0x04 pair, which had read as four unexplained zero bytes through every save until the hero finally had a ring to wear. The save that has one puts 8D 21 there and nowhere else in the whole 128K of WRAM, and 0x218D is DreamRG in the armour table. The second slot is the other half of the run, at +0x06.

A probe settled both halves and their order at once: 0x218C (SmartRG) written to +0x04 with 0x218D (DreamRG) at +0x06 drew Etc. SmartRG and Etc. DreamRG, in that order, on the status screen — the first slot taking the id that had moved, which is what tells the two apart.

Equipment ids are 16-bit, and both bytes matter. Writing 0x08 over the low byte of the weapon id turned Dirk (0x1100) into EmporSD (0x1108), and the shield went from WoodSH (0x2130) to WolfSkin (0x2109) the same way, while the armour and helmet ids that were left alone did not budge. Zeroing +0x24..+0x2F empties all four lines.

Still unverified

Offsets are only trusted once a probe state has been written, loaded in SNES9x and read back off the screen. Two things are still open.

Where the spell learn levels are indexed from. Each character's learn levels are a plain array — Nina's at 0x05BFF1 — but nothing in the ROM points at them: $BDF1 appears nowhere, and there is no table of $BDxx words near the spell data. The obvious guess is that the level arrays sit at a fixed distance from the id lists, and for two of them they do: Bleu's ids are 0x20 past Nina's and her levels are 0x20 past Nina's. Run the same arithmetic back to Bo, whose ids are 8 before Nina's, and it lands on 00 00 00 1C 00 04 00 — which is not a rising run of levels, and is ruled out outright by the game: Bo knows all seven of his spells at level 1, so nothing in his array can be 28. The pattern is a coincidence, the arrangement has a hole in it, or Bo has no array at all. Learn tables are therefore still transcribed one character at a time. See Magic.

What +0x24 is. It is the last unexplained run inside a record, and it was the standing guess for the accessory slots until those turned up at +0x04 instead. Its low byte moves with equipment rather than with level — Nina went from 0E to 0C on changing armour alone — and it does not look like an item id.

The 0x10 of padding after each record in the save block used to be listed here as the last place a spell list could hide. It is not: Nina, at level 18 with twelve spells, sits in the block with that padding entirely zero.

The save block

There is a second, 0x500-byte structure at $7E5000, byte for byte what ends up in the .srm file:

Block offset Contents
0x000 16-bit little-endian checksum, the sum of bytes 0x0020x4FF
0x002 CAPCOM!!
0x020 Gold, three bytes little-endian (a probe holding 123456 read back as 123456 in game)
0x038 Party roster: one character id per byte, ending 0xFF, one per block record that is still in the party
0x140 Inventory: 64 back-to-back two-byte slots
0x1C0 A second copy of each character record, 0x40 apart
0x482 The hero's dragon transformations, a 16-bit mask with the nine forms at bits 4–12

This is not a staging buffer. Gold and the inventory live only here, and the game keeps them current as you play: winning a battle worth 7G moved 0x020 from 188 to 195 on the spot, with only the map position moving alongside it.

The character records in the block behave differently. In the same pair of states the live record was four HP down and two EXP up while its copy in the block still held the values from the last in-game save — so the block copy trails the live record rather than tracking it. Edits are written to both, so an in-game save cannot resurrect the old numbers.

The records are 0x40 apart inside the block, not the 0xA0 of the live array. A three-character save puts them at +0x1C0, +0x200 and +0x240: 0x30 of record then 0x10 of padding. Eight of those reach +0x3BF and stop clear of the next occupied bytes at +0x3F0, which the live stride does not — it runs out of block after five.

Comparing the two copies is also what pins down how much of a record is real save data: the first 0x30 bytes match, and everything after is zero in the save block. So +0x30 onward — the pointers at +0x38 and friends — is runtime-only.

Live slot n is not block record n. The game lets the party be reordered from the menu, and that moves the live records around while the block keeps the order it had at the last in-game save. A state with Bo, Jack and Nina live had them stored as Jack, Nina, Bo in the block, with the roster reading 00 02 01 to match. Pairing the two copies by position therefore mirrors each character's edits onto a neighbour's record — Jack's block record came back holding Bo's HP, AP, EXP and stats, with only the fields the editor never writes (name, level, equipment) left to give it away. The two copies are paired by name instead. A live character with no block record has joined since the last in-game save and simply has no copy to keep in step yet.

Leftover records sit in among the live ones, not after them, which is what the fourth party member exposed. The block here holds five occupied records — Jack, Nina, Bo, a stale second Jack at level 13, then Karn — against a roster of 00 02 01 05. Karn is fourth in the party and fifth in the block. Matching records only as far as the roster is long, on the assumption that anything past that was a leftover, therefore dropped him: he came out with no id, no spell list, listed as (not in party), and — the part that actually costs something — no mirror, so every edit to him stopped at the live record and the block kept the level 12 he joined at.

An in-game save writes the block as a snapshot of the live array, in live order, one record per party member, and leaves everything past the party untouched. The save taken on finishing the Krypt rewrote records 0–3 as Karn, Nina, Jack, Bo with the roster reading 05 02 00 01 to match, overwriting the stale Jack that had been sitting at record 3 — while the previous Karn record, five deep, was left exactly where it was.

So a leftover is anything the last save did not reach, and there are two different reasons a record can be one. The first is bit 0x40 of the flags at +0x08, which marks a character who is not in the active party: set on that stale Jack (0xC0) and on every live Sr-1 left behind by the palace soldiers (0x80C0), clear on all four of the current party (0x80 or 0x81). The roster is a list of the party rather than of the records, so the two line up only once the flagged records are skipped — which is how a party of four came to be spread over five records, with Karn's fourth id landing on the fifth record. The second reason is simply running past the end of the roster: the Karn record now sitting fifth is stale but carries no flag at all, because it was a live party member's record when it was written and nothing has overwritten it since.

Both rules are needed, and either one alone gets a save wrong. That flag is also a better answer to "is this character in the party" than the roster is, because it is in the record itself and so still right for someone who joined after the last in-game save.

A duplicate name is settled the same way: the block has held two records reading Jack, and two reading Karn, and the live character is paired with the first record that both matches the name and agrees about party standing. First match is the right one because the game always writes the party into the records ahead of anything it leaves behind.

The checksum is not maintained by the editor. The game's own copy in WRAM is already stale against its contents, so it is plainly recomputed only when SRAM is written.

Magic

No pool caster's magic is stored in a save. In a state where Nina had nine spells, neither the ids nor a bitmask appeared anywhere in the 128K of WRAM, and the persistent save block held no record for her at all — its party was still Jack and the two palace soldiers from the last in-game save — yet the game drew her list correctly. It is built from the ROM at draw time, from a character and a level. So the editor reports magic and cannot edit it. (The hero is the one exception: his dragon transformations are unlocked by story trials and so must be recorded in the save somewhere. That does not make them editable — where it lives is still unknown.)

The ROM is LoROM with a 512-byte header, so a bank $0B address lands at bank * 0x8000 + (addr - 0x8000) + 0x200:

What Where
Spell names 0x05920C, stride 0x11, 0x50 entries, eight-byte name then nine of data
Spell id pool 0x05B196..0x05B1DA, ending FF FF
Pointers into the pool 0x05B180, eleven 16-bit bank $0B addresses
Bo's ids 0x05B197, 7 entries
Nina's ids 0x05B19F, 23 entries
Nina's learn levels 0x05BFF1, 23 entries
Dragon transformations 0x059906, stride 0x0F, 9 entries, eight-byte name then seven of data
Starting character records 0x009808, stride 0x30, twelve entries

A character's spells are a contiguous slice of the pool, in the order the magic menu prints them, paired with an array giving the level each one arrives at. The character knows every entry whose level it has reached. Nina's levels are 1 4 5 6 7 9 11 12 13 14 15 17 19 21 23 24 26 28 30 34 37 41 46, which was checked in game three ways by patching her level byte: 5 gives 3 spells, 13 gives 9, and 30 gives 19, each matching exactly.

The pointer table is indexed by character id, which is what the roster at save block +0x38 holds. The ids are pinned down by the game's own table of starting records at 0x009808 — twelve of them 0x30 apart, in exactly the record layout above, so they read out as names directly:

Id 0 1 2 3 4 5 6 7 8 9 10 11
Zack Bo Nina Ox Gobi Karn Mogu Bleu Sr-1 Sr-2 Sr-3 Nina (Lv 99)

Every id a roster has produced so far lines up with it: Jack is 0 (the hero, Zack before he is named), Bo is 1, Nina is 2 and the palace soldiers are 8 and 9.

How long each slice is used to be inferred from where the next pointer landed. It is not: the byte immediately before a pointer's target is the count. Nina's list at $AF9F is preceded by 0x17 and is 23 long; Bleu's at $AFB7 by 0x1F and is 31; Ox's at $AFD7 by 0x02 and is 2 — and Ox's two ids run straight into the FF FF that the other seven pointers aim at directly, which is how "no magic" is spelt. That settles the whole table:

Id 0 Zack 1 Bo 2 Nina 3 Ox 4 Gobi 5 Karn 6 Mogu 7 Bleu 8-10 Sr-n
Pointer $AFDA $AF97 $AF9F $AFD7 $AFDA $AFDA $AFDA $AFB7 $AFDA
Spells none 7 23 2 none none none 31 none

So the editor can say "has no magic" outright for six of the eleven, rather than reporting that nothing is known about them. Zack is the seventh and is not one of them, however much his pointer looks like it — see Dragon transformations.

Dragon transformations

The hero's pointer aims at the terminator like Gobi's and the soldiers' do, and for four days that was read as "the hero has no magic". It is not: it means the hero is not fed from the pool. His magic is the dragon transformations, and they live in a table of their own at 0x059906 — stride 0x0F, an eight-byte name then seven bytes of data, nine entries and no count byte in front of it:

# Name Data
0 SnoDr 20 06 07 00 28 00 1f
1 FlmDr 40 07 0a 00 8c 00 29
2 ThrDr 14 08 0c 00 a0 00 34
3 IceDgn 24 0a 14 00 b4 00 3e
4 FirDgn c4 1a 1b 00 04 01 48
5 BltDgn 94 1f 1e 00 18 01 52
6 GldDgn a4 25 28 00 2c 01 5d
7 Rudra cc 2d 32 00 90 01 68
8 Agni cc 2d 3c 00 f4 01 68

The names decode with the game's own character set, the same as everywhere else.

Byte 2 is the AP cost, not a learn level, and the difference cost an hour. The column reads 7, 10, 12, 20, 27, 30, 40, 50, 60, which is exactly the shape of a learn table and was written down as one. It is not: with SnoDr highlighted the magic menu prints AP Used 7, and probe states holding Jack at level 6 and at level 60 both drew the same three forms he has at his real level 31. Level does not enter into it at either end. Byte 1 — 6, 7, 8, 10, 26, 31, 37, 45, 45 — is unidentified; it was the AP cost by a bad analogy with the spell table, where byte 1 of the nine is the cost, and that reading is now retracted.

The forms are unlocked by story trials, so what a save carries is which trials are done, and it is a single byte at save block +0x482. Reverting that one byte to its value in a save from before the trials empties Jack's magic menu outright; reverting any of the other sixteen bytes that had moved in the same flag run leaves all three forms in place, as does the front run 0x54000x543F and the header 0x50100x5033.

It is a bitmask, and it starts at bit 4. With three forms unlocked it holds 0x70. Written one bit at a time it comes back one form at a time, in table order:

+0x482 Menu
10 SnoDr
20 FlmDr
40 ThrDr
F0 SnoDr, FlmDr, ThrDr, IceDgn
FF SnoDr, FlmDr, ThrDr, IceDgn — the same four
01 nothing

So bits 4–7 are entries 0–3 and the low nibble is not forms at all; FF and F0 draw an identical menu. The menu also parks each form at a fixed grid position rather than packing the list up — 20 alone leaves the first column empty and prints FlmDr in the second — which is what a mask over a fixed table looks like from the front.

The remaining five carry on into +0x483, so the whole thing is a 16-bit little-endian mask with the nine forms at bits 4–12. 0x0100 alone draws FirDgn, 0x1F00 draws the last five — FirDgn, BltDgn, GldDgn, Rudra, Agni — and 0xF0 plus 0x1F00 draws all nine in the game's own 3 × 3 grid. It stops there: setting +0x481, +0x482 and +0x483 all to FF draws those same nine and no more, so bits 0–3 and 13–15 are not forms and +0x481 is not part of the mask.

Bo is the second character with a table, and the odd one out: he learns nothing. Probe states put him at level 1 and at level 45 and the magic menu printed the same seven both times, in the pool's order — Flare Spark Cold Fry Flame Frost Cura — as it does at the level 9 he joins at. His levels are stored here as flat 1s standing in for a learn array that has not been found and may not exist; no level between 1 and 45 can disagree with them.

Because the roster lives in the save block it trails the live array, so a slot's id is only trusted when a block record still carries the same name as the live one. When the party has moved on since the last in-game save that check fails and the slot simply has no id, which also means no spell list — the honest answer rather than a wrong one.

Party membership is read from the record's own flags rather than from the roster, so it survives a party change the block has not caught up with. A record left behind by a character who has dropped out is still perfectly readable, so the editor lists it but marks it (not in party) rather than hiding it — a sleeping Jack is still worth editing before he rejoins.

What is still not worked out is where the learn levels are indexed from, which is why learn tables are transcribed one character at a time and checked against the game before going in. See Still unverified.

Items

An inventory slot is two bytes, but not always an (id, quantity) pair. Equipment can sit in the bag unequipped, and then the two bytes are the same 16-bit equipment id a character record holds, low byte first. A screenshot of a 34 item bag lined up against the bytes settled it: every entry the item screen printed with no x count read back with a second byte of 0x11 or 0x21 — the weapon and armour pages — and the low byte named the piece exactly.

5E 11  ->  0x115E  Rang        (read as "Icicle x17" while the pair was taken literally)
58 21  ->  0x2158  SuedeHT
04 11  ->  0x1104  LongSD
08 09  ->  0x08    Herb, nine of them

So the second byte is a count only while it stays inside its low nibble; above that its high nibble is a type. Two probes settled that rather than leaving it as a guess about where counts stop:

Probe Bytes The item screen printed
Herb stack forced to 99 08 63 ArmPad — the armour whose id is 0x08, no count at all
Cure stack forced to 15 09 0F Cure x? — a count is one digit, and 15 has no glyph

Which is why quantities are capped at 9 here. That also matches an untouched bag: thirty four Herbs sit in it as three stacks of nine and one of seven rather than merging, and no stack anywhere exceeds nine.

Type 6 in that first probe is an artefact of forcing a value the game never writes; only 1 (weapons) and 2 (armour) have turned up in a real save. Anything else is reported as a raw $XXXX rather than guessed at.

Names are not guessed at one save state at a time — they are read out of the game's own tables in the ROM, which is the only practical way to cover id spaces this large. Game/BofItemNames.cs holds the result. Three fixed-stride tables of eight-byte, zero-padded names:

Table Offset Stride Entries Indexed by
Consumables 0x13F400 0x0E 0x80 the one-byte inventory id
Weapons 0x13E400 0x10 0x60 the low byte of a 0x11xx equipment id
Armour 0x13EA00 0x10 0x8E the low byte of a 0x21xx equipment id

Offsets are into the .smc file, which carries a 512-byte copier header. The armour table covers shields, helmets and rings as well as body armour — WoodSH is 0x2130 and StrawHT is 0x216D. Gaps in the tables are real; the game leaves unused slots blank.

Only the 0x11 and 0x21 pages are recognised, because those are the only two that have turned up in a real save. Any other page, and any id whose slot is blank, falls back to $XX or $XXXX rather than being guessed at. To add or correct entries without rebuilding, drop a bof_items.json next to the executable:

{ "0x08": "Herb", "0x1100": "Dirk" }

Building

dotnet build

Requires the .NET 10 SDK with the Windows desktop workload. Output lands in bin/Debug/net10.0-windows/.

Licence

MIT. See LICENSE.

About

A save state editor for the SNES game Breath of Fire. Save states must be from the snesx9 emulator.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages