BRE save-file format notes (data/game.dat)¶
Partial map of BRE v0.988's binary save, from differential diffing (make one known in-game change, diff the file). Reference for understanding BRE, not for IB's own save format (IB uses JSON). Key finding: BRE validates record integrity, so naive byte-edits are rejected — see the warning below.
Files¶
data/game.dat(~29 KB) — the empire records. Fixed-size records (~1069 bytes apart: two empires' name fields sat at file offsets 2521 and 3590). Each record's name is a Turbo PascalShortString(1 length byte + chars), e.g.04 "Rome".data/planet.bre(~360 KB) — planet/region map data; holds no empire name strings.data/ids.dat— empty in this game.
Region counts (mapped)¶
The eight region-type counts are int32 little-endian, stored contiguously
in Buy-Regions display order:
Coastal, River, Agricultural, Desert, Industrial, Urban, Mountain, Technology
For the "Rome" record (name at file offset 3590) the block began at offset
3708 (0xE7C) — i.e. name + 118 bytes. Confirmed: buying exactly 10 Coastal
changed the first int32 there from 16 00 00 00 (22) to 20 00 00 00 (32), and
nothing else in the block moved. Two int32 = 100 fields sat just before the
block (0xE74/0xE78) — likely Popular Support / Morale, unconfirmed.
offset 0xE7C Coastal int32 LE
offset 0xE80 River "
offset 0xE84 Agricultural
offset 0xE88 Desert
offset 0xE8C Industrial
offset 0xE90 Urban
offset 0xE94 Mountain
offset 0xE98 Technology
Runtime record layout (from the disassembly)¶
The offsets above came from differential diffing the file. These come from
reading BRE.OVR, where the empire record is reached through a far pointer
loaded with les di,[0x28d8] and the config record with les di,[0x28b4].
Offsets are relative to the record base, and the base IS the offset of the
ShortString name — the length byte itself. (An earlier note here put the base
0x20 before the name, reconciling the two maps by arithmetic rather than by
measurement. A live check disproves it: with base = name, +0x281 read 2 after two
turns played, +0x8e read 100 against 100% morale on screen, and the region block
at +0x96 read [3,0,2,5,0,0,5,0,0] against a status screen showing 3 Coastal,
2 Agricultural, 5 Desert, 5 Mountains. With base = name-0x20 none of those line
up. The file-offset map above and this one therefore do NOT agree where they
overlap, and this one is the measured one.)
The two maps agree where they overlap: the region block is name + 118 above
and base + 0x96 (150) here, and 150 − 32 = 118.
empire record (les di,[0x28d8])
+0x00 String[30] the CALLER'S BBS ACCOUNT NAME — the person, not the realm.
A live save has "SELBY" here against a realm named "duh", and a
second record "WRAPTEST" against "Wraptest", the BBS's own
upper-cased login beside the name its owner typed. Nothing on
any screen prints it; the dupe file DATA\DUPE.BR carries it
across boards for duplicate-user checking, and the League
Coordinator's PLAYERLIST command line dumps that file to
PLAYERS.LST. Its length byte doubles as the slot-occupied test
the pickers use (`cmp byte [es:di-0xf6d],0`).
+0x1f String[30] the REALM name, and the one every player-visible screen
uses: See Scores, the coordinator vote's roster, the
interplanetary Player Information screen, and the recon packet's
own `PlanetInfo.Names`, which `build_recon_record` fills from
here. So a realm's owner never crosses to another planet.
+0x3e String[30] a third name slot, empty in every record examined.
+0x62 int32 population, in MILLIONS. Two independent reads agree: the food
routine at BRE.OVR 0x37418 multiplies it by 1.5 for "Your People
Need N", and the chemical and biological strikes print "<N>
million civilians were killed!" beside it after taking a
percentage of it. Both missiles also multiply it into their
price, so every per-head price in this binary is per million.
+0x5d int32 slot-in-use serial. -1 in every unoccupied slot; a positive,
game-lifetime-increasing number in an occupied one (921 / 932 /
933 across three saves, the second realm exactly one above the
first). Read or tested `> 0` at 83 sites — target pickers, net
worth, the roster, the coordinator vote, the Technology
Agreement partner loop — always through the indexed
other-empire pointer, never written anywhere. It has to be
tested everywhere because BRE initialises every unused slot
with the STARTING TEMPLATE, so an empty record still carries a
plausible region mix.
+0x6a int32 bank balance
(+0x66 is NOT gold on hand: the turn routine zeroes it every
turn and it reads 0 in the save. The maintenance routine loads
gold from it into a local, so it is a per-turn working field.)
+0x6e int32 food in store. The production routine adds each turn's yield
here; the allocation routine draws feeding out of it.
+0x72 int32 gold earned this turn (see below)
+0x76 .. +0x8a six int32 unit counts, in the order the food routine reads
them: Trooper +0x76, Bomber +0x7a, Jet +0x7e, Turret +0x82,
Tank +0x86, Carrier +0x8a. Trooper is measured (see the market
escrow below); the rest are paired with their escrow slots by
the armed-forces food routine, which reads each type's two
fields back to back.
+0x5d int32 the slot's player id, and the FREE-SLOT MARKER. The record
initializer (056d:0d21) writes -1 here; the daily purge skips
any slot whose value is not > 0, so this — not the name's
ShortString length byte — is what says "empty". The deletion
routine passes it as the key to the message, trade-offer and
report cleanups, which is what identifies it as the player id
(a BBS user number: inferred from that use, not proven).
+0x62 int32 population. Seeded 100 by the initializer, and the field the
chemical and biological strikes subtract from before printing
a millions-of-civilians line.
+0x8e int32 military morale
+0x92 int32 popular support
+0x96 int32 region block starts (Coastal first, Buy-Regions order); the
nine counts +0x96..+0xb6 are what "total regions" sums
+0xa6 int32 Industrial count
+0xae int32 Mountain count
+0xbe int32 technology levels: FIFTEEN counters, +0xbe .. +0xf9, one per
research slot. Only slots 0-5 do anything; 6-14 are pure
dilution. Never decremented anywhere in the binary.
+0x129 .. +0x12e six bytes: Set-Industries allocation percentages, in
menu order (Troopers, Jets, Turrets, Bombers, Tanks,
Carriers)
+0x130 .. +0x161 the DIPLOMATIC RELATION row: 25 words, one per empire
letter A..Y. Values are -1 Enemy, 0 None, and 1..7 for the
seven pacts in Diplomacy-menu order (1 Tariff Trade
Agreement, 2 Protective Trade, 3 Free Trade Agreement,
4 Terrorist Prevention, 5 Intelligence Alliance,
6 Technology Agreement, 7 Full Defense Alliance); 8 and 9
are menu items (Declaration Of War, View Treaties) that
share the same name table and are never stored here.
VERIFIED: `break_diplomatic_treaty` writes `xor ax,ax` to
both parties' rows (`BRE.OVR 0x1a8f0`, `0x1a912`), so a
Declaration Of War leaves 0 behind rather than 8, and a
sweep of every write site at both relation displacements
found no site that stores 8 at all. This matters to any
code testing the row against a range: BRE's own battle
report loop admits `> 5`, which reaches 6 and 7 only.
BRE indexes it by the RAW ASCII letter, so every access
reads `[es:di + 2*letter + 0xae]` and the displacement
collides with the Mountain count above — see the
bre-gather skill's disassembly reference. Both rows are
written: forming a pact writes the current player's, and
break_diplomatic_treaty clears the partner's through the
all-empires array as well.
Only ELEVEN sites in the whole overlay touch it, all via
the current-player pointer, which is itself the finding that
a Full Defense Alliance cannot reach across planets: no
interplanetary code path can read a relation row at all.
+0x130 .. +0x161 the diplomatic relation with each of the 25 slots, one
int16 per slot. For the enum see the fuller entry above —
8 is NOT among the stored values. Indexed by the raw ASCII
letter, so the code's displacement carries a folded
`base - 2*'A'` and a hit only means "relation" when a
`shl ax,1` precedes it. Each realm holds its OWN row, so a
pair's relation is stored twice and both copies have to be
maintained together.
+0x281 int32 lifetime turns played. Drives the HeadQuarters price ratchet and,
against config +0x38, whether the realm is still protected.
+0x285 byte turns remaining today. Reset to config +0x36 at rollover; zero is
what produces "Sorry, you have used all of your turns today."
+0x2b8 byte stage counter for the turn in progress. The turn is a state
machine: the loop head at BRE.EXE 0x6236 dispatches on this byte,
each stage advances it, and at 20 (0x685f) the turn commits —
+0x281 increments, +0x285 decrements, and this resets to 0
(0x688e). It lives in the record, not on the stack, so an
abandoned turn resumes where it stopped; the same job as IB's
TurnProgress.
+0x2b9 .. +0x2bb three penalty bytes, zeroed at turn start (BRE.EXE 0x62c4)
and spent during the end-of-turn step:
+0x2b9 subtracted from military morale (BRE.OVR 0xc1a1)
+0x2ba subtracted from popular support (0xcf41)
+0x2bb civil-war severity, in percent (0xc59a): halves
popular support and destroys that percentage of
every military unit type. The food-allocation
routine writes all three; an anti-crack check
writes 50 into the third.
+0x211 .. +0x231 the Trading Market "For Sale" escrow, one int32 per slot in
the market screen's own order: Trooper, Jet, Turret,
Bomber, Food, (unused key 6), Agent, Tank, Carrier.
MEASURED: listing 73 of 100 troopers put 73 at +0x211 and
left 27 at +0x76. Net worth sums each of these with its
home counterpart, and the pirate raid's category ladder
reads them on five of its sixteen faces — so escrowed
military is NOT hidden from pirates.
+0x26f int32 agents held (the market's escrowed agents are at +0x229)
+0x28a real48 the day the realm was last played, as a date serial. The daily
purge truncates it, adds the configured DeletionDays (a word at
DS:0x6f99, default 7) and deletes the slot when the sum falls
before today (a Real48 at DS:0x8606).
+0x33f int32 SDI program funding, in WHOLE THOUSANDS of gold. The strength
routine (BRE.EXE 056d:1139) reads it, multiplies by 1000 and
divides by 10 x (totalRegions + 1) before the square root; the
screen prints it followed by a literal ",000".
+0x331 int32 land still available to BUY — the Daily Land Creation allowance.
PER-EMPIRE, not a planet-wide pool: 0x12D30 bounds a region
purchase against it and 0x12EF9 subtracts the number bought
(pointer + sub32 helper 0c03:0fe3). Exhausting it produces
"No land is available at this time."
config record (les di,[0x28b4])
+0x1c int32 Daily food pool, planet-wide (seeded 0xF4240 = 1,000,000 at
game init; buying depletes it, selling replenishes it)
+0x24 int32 Pool the Queen Royale tax refund is paid out of, planet-wide.
Seeded 0x000186A0 = 100,000 at game init (0x44CE6, beside the
food pool's 1,000,000). The refund routine (BRE.OVR 98944, just
past its message string) reads it, pays min(V x rate, 1,000,000)
to the empire via 0c03:0f10 into empire +0x66, then writes
V x (1 - rate) back. rate = 0.02, or 0.07 once V > 100,000,000
(the threshold is a literal cmp against 0x05F5E100; all three
reals decode exactly: 0.02, +0.05, cap 1,000,000). The cap is
applied by overwriting rate with 1,000,000/V, and is itself
gated by the per-empire predicate at 056d:19b5 — when that is
false the payout is uncapped. The uncapped branch is common in
play: a census of all 47 refunds in cap/ found payouts of
12,581,639 to 14,000,000 on two boards, alongside eighteen at the
cap (1,000,000, or 999,999 where the rate substitution loses a
unit).
THE PREDICATE IS "is this empire still under New Realm
Protection?" It reads config +0x38 (Turns Of Protection) and the
empire's lifetime turn counter +0x281 and returns
turnsPlayed < turnsOfProtection. Identified from its other call
sites: at BRE.OVR 0x1771a the same call gates the covert message
"Our empire is in protection, my lord." So the 1,000,000 ceiling
is a newcomer guard — a fresh realm on a mature planet cannot
open with a 14-million windfall.
THE CALLER is BRE.EXE 0x61dd, the only one, inside the "Since
your last play" recap. It runs when the turn-stage counter
(+0x2b8) is zero AND turnsRemaining (+0x285) >= config +0x36
(turns per day) — no turn part-way through and none taken today,
i.e. the player's first play of a game day. The routine holds no
Random(), so it always pays when reached. The call immediately
after it is the lottery (its strings are at BRE.OVR 0x18531), so
the two share one first-play-of-the-day event block.
The pool is FED by the crown tax: the tax routine at 0x2FAF1 —
the same one that charges the Queen Royale tax and computes the
underpayment support penalty — adds the empire's tax figure
straight into V. It banks the amount actually PAID, not the
amount due: the value added ([bp-0x10], from 056d:01bf at
0x2FA7C) is the same one the shortfall penalty divides by the
amount due at 0x2FA94, which only makes the penalty positive if
it is the paid figure.
Play data confirms the seed and the writeback independently: a
fresh game's first refund is 2,000 (2% of 100,000) and the next
is 1,960 (2% of 98,000 = V x 0.98). Two refunds captured on a
third board (an A-Net game server) fit the same 2% branch on a
mature pool: 375,090 and 384,673 put V in the 18.7-19.3M range,
well under both the 100M rate threshold and the cap, so no
capture yet exercises the 0.07 branch or the cap gate. See
issue #93.
+0x36 int16 Turns per day
+0x38 int16 Turns of Protection (compared against empire +0x281)
+0x42 int16 Planetary Tax Rate, tenths of a percent (default 50 = 5.0%,
editor maximum 200 = 20.0%)
+0x72 is a per-turn accumulator, not a running total. It is zeroed at the
start of the income phase and added to at exactly six sites, one per income
line — population tax, ore, tourism, solar, industrial, hydro. Nothing else in
the binary writes it, so bank interest, food sales and plunder do not enter it.
This is the base for the crown tax (issue #52).
Deleting an empire record¶
Every deletion in the game funnels through one routine, BRE.OVR 0x0079c1. It
has exactly two callers — the sysop's Delete Empire in manage_players, and the
daily-maintenance purge at BRE.OVR 0x007ed2 — which is why a crushed or
abdicated realm is still there for the rest of the game day and gone the next.
The purge loops the 25 slots, skips any whose +0x5d is not > 0, and marks a
slot for deletion when it has no regions (total_regions < 1), no population
(+0x62 < 1), has gone unplayed past DeletionDays, or was never played and the
game has been running more than three days. The slot currently in play is exempt.
The routine itself clears the player's messages, trade offers and reports by
+0x5d; calls BRE.OVR 0x050d74, which loops the slots and zeroes the relation
at +0x130 in BOTH directions (the deleted realm's row toward each rival and
each rival's row toward it); and finishes with 056d:0d21, which FillChars the
whole 1069-byte record to zero and seeds the new-realm defaults — +0x5d = -1,
+0x62 = 100 population, +0x76 = 100 troopers, +0x8e/+0x92 = 100 morale
and support. The attack resolver never deletes anything: a realm crushed at
BRE.OVR 0xef90 just hands over its land and surviving units.
0x050d74's other caller is confirm_end_game, which wipes the whole relation
table at the end of a season.
Runtime helpers worth recognising when reading this code:
0c03:0ed0 Random(n) 0fd0:178e int -> real
0c03:0f10 add32 through a pointer 0fd0:177a real multiply
0fd0:0ecc 32-bit multiply 0fd0:1780 real divide
0fd0:0f09 32-bit divide 0fd0:1768 real add
0fd0:176e real subtract
0851:0288 int32 -> "1,234,567" 0fd0:178a real compare
056d:1a07 technology factor: 1 + (cap-1)*(1 - exp(-level[sel]/(regions+1)))
0fd0:193e Ln 0fd0:19e7 Exp 0fd0:1774 square
0fd0:1841 real square root
0c03:12e1 min(int32,int32) 0c03:129b max(int32,int32)
056d:0ec6 sum of the nine region counts (total regions)
056d:18f0 ruin N regions: calls the proportional remover (056d:11f1) across the
nine counts, then adds the same N to Waste at +0xb6. Both the
nuclear and the chemical strike reach it; the biological one does
not, which is why a plague leaves the land alone.
056d:0d21 blank an empire record and seed the new-realm defaults
056d:1139 SDI strength %: clamp(0..100, trunc(sqrt(funding[+0x33f] * 1000
/ (totalRegions + 1) / 10)))
0fd0:1792 real -> int, TRUNCATES
0fd0:179a real -> int, ROUNDS <- the two are easy to confuse; which one a
routine uses changes results by one unit
⚠ Integrity check — direct edits are rejected¶
Patching Coastal from 32 to 200 directly in game.dat (with BRE closed) and
relaunching made BRE fail to find the empire and prompt the caller to name a
new realm — the edited record was discarded, no explicit "tampered" message.
Restoring the pre-edit backup brought the empire back intact. So BRE stores a
per-record (or per-file) integrity value that a raw field edit invalidates.
Consequence for test setup: you cannot just poke region counts to stage a scenario — the empire gets reset. To edit safely you would first have to locate the checksum field and its algorithm (another differential-diff pass: change one field in-game and find which other bytes move — those are the checksum), then recompute it after each edit. Until that is done, prefer in-game changes (e.g. buying regions over a few turns) for staging tests.
Always back up data/game.dat (and planet.bre) before any experiment.