goldbox.games¶
Which Gold Box title a save came from, as data rather than as code.
Six C64 titles share one engine and one 580-byte character record. What differs between them is a handful of numbers – the save file’s name, where it loads, and whether the party roster is a second file or the last page of the first – so this module is a table, not a class hierarchy.
Pool of Radiance is the outlier. It writes two files, SAVEDGAME0 at
$4900 and SAVEDGAME1 at $8300, and keeps the roster in the second. Every
later title writes one file of exactly 7426 bytes: a $1D00 payload of
header $400, twelve $100 character slots, twelve $100 item pages, and a
final $100 page that is Pool of Radiance’s roster folded in. Measured on the
player’s own disks for Curse, Silver Blades, Champions of Krynn, Death Knights
of Krynn and Gateway to the Savage Frontier. The write-up,
work/reports/goldbox-inventory.md, is lost; the per-title base addresses are
asserted in tests/test_curse.py::test_the_addresses_are_the_ones_measured.
The offsets inside the payload are identical in all six. Items at $1000,
the combat-icon table at $2E0, the slot area at $400, the position triple at
$C0, the loaded-file cache and area byte at $2C0. Only the base moves, which
is why nothing here is a subclass and why goldbox/items.py, goldbox/icons.py and
editor/inventory.py – all of which work in payload offsets – needed no
change at all.
Confidence: Pool of Radiance and Curse are CONFIRMED (saves written by both games round-trip byte-identically). The other four have their geometry from one shipped pre-generated party each, which fixes the layout but not the header fields; no player save of them has ever been read here.
Module Attributes
Pool of Radiance and Gateway to the Savage Frontier, identically. |
|
its label table points BOTH 6 and 7 at HUMAN. |
|
Silver Blades drops half-orc and re-orders the rest, so human moves to 6. |
|
a different list entirely, and the only one that is 0-based -- Death Knights' CELESTE is race 0, which is why 0 had to be a real race rather than the "monster" it is in the Realms titles. |
|
Curse, Silver Blades and Gateway. |
|
Champions' STRONGSWORD and Death Knights' SIR DRYDEN are single-class 0x10, lawful good, and the label pool carries KNIGHT and KNIGHT OF THE ROSE. |
|
Where the engine keeps the party's square while the game runs, which is not where it writes it when the game saves. |
|
which overlay is running, and |
|
The same byte in Curse of the Azure Bonds and Secret of the Silver Blades, and it is not Pool of Radiance's plus anything the save image moved by: |
|
What a caller gets when nothing says otherwise. |
Functions
|
|
|
The title a person named, or None. |
|
The bit -> name pairs for a title, or Pool of Radiance's four. |
|
The classes a bitmask holds, named -- or the mask itself, unnamed. |
|
The title a D64 holds a save for, or |
|
The title whose save file appears in a directory listing, or None. |
|
Race code -> name for a title, or Pool of Radiance's. |
Classes
One title's save-container geometry. |
Exceptions
Raised when a key names no title we know. |
- goldbox.games.CLASS_BITS_KRYNN = ((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter'), (16, 'knight'), (64, 'paladin'), (128, 'ranger'))¶
Champions’ STRONGSWORD and Death Knights’ SIR DRYDEN are single-class 0x10, lawful good, and the label pool carries KNIGHT and KNIGHT OF THE ROSE.
- Type:
Krynn adds the Knight of Solamnia at 0x10. PROBABLE
- goldbox.games.CLASS_BITS_WITH_PALADIN_RANGER = ((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter'), (64, 'paladin'), (128, 'ranger'))¶
Curse, Silver Blades and Gateway. CONFIRMED for Curse, whose shipped party has two characters literally named PALADIN (0x40) and RANGER (0x80); the same two names sit at the same places in all three titles’ label tables.
- goldbox.games.DEFAULT = Game(key='pool-of-radiance', title='Pool of Radiance', save_file=b'SAVEDGAME0', save_load_address=18688, save_size=7168, roster_file=b'SAVEDGAME1', roster_load_address=33536, roster_size=2048, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='POOL*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter')), item_names_load_address=28416, live_position=49227, mode_flag=28177, travel_grid=True)¶
What a caller gets when nothing says otherwise. Pool of Radiance, because every existing caller predates this module and means it.
- class goldbox.games.Game[source]¶
Bases:
objectOne title’s save-container geometry.
roster_fileis the whole difference between the two shapes. When it is None the roster isroster_offsetbytes into the main payload; when it is set the roster is that separate file, androster_offsetis an offset within it.races,class_bitsanditem_names_load_addressare the three things that are per-title content rather than per-title geometry. Each may be None, and None means “we do not know”, not “there are none”: a caller that gets None must show the raw number rather than invent a name for it.- __init__(key, title, save_file, save_load_address, save_size, roster_file=None, roster_load_address=None, roster_size=256, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='*.[dD]64', races=None, class_bits=None, item_names_load_address=None, live_position=None, mode_flag=None, travel_grid=False)¶
- Parameters:
key (str)
title (str)
save_file (bytes)
save_load_address (int)
save_size (int)
roster_file (bytes | None)
roster_load_address (int | None)
roster_size (int)
roster_offset (int)
slot_count (int)
record_slot_count (int)
disk_glob (str)
item_names_load_address (int | None)
live_position (int | None)
mode_flag (int | None)
travel_grid (bool)
- Return type:
None
- property class_bit_names: dict[int, str] | None¶
Class bit -> name, or None when this title’s list is unknown.
- property indoors_flag_base: int | None¶
zero on the travel grid, non-zero in a
GEOarea.None unless
travel_grid, the same refusallive_positionmakes for the same reason: reading this on a title with no travel grid would answer a byte of unrelated resident code as though it meant something.- Type:
$49E6
- live_position: int | None = None¶
it sits outside the save image, so it cannot be derived from
save_load_addressand has to be measured on a running machine, one title at a time. None means nobody has measured this title’s – and a reader must then refuse rather than fall back to another title’s, because a wrong address yields a plausible square instead of an error.- Type:
The engine’s live x/y/facing triple. Not geometry
- matches_payload(prg)[source]¶
Does this PRG look like this title’s save? Size and load address.
A corroborator, not the discriminator – Curse’s own side B carries a 2032-byte
SAVEAZUREthat is a truncated demo party, and this is how that is told from the real thing.
- mode_flag: int | None = None¶
The loader’s resident-overlay flag – the byte an action has to read before it writes, because
2is combat and half of them are illegal there. Not geometry either: it is a byte of the loader’s own resident page, not of the save image, so it neither followssave_load_addressnor transfers. None means nobody has found this title’s, and every action then refuses: an unmeasured address reads as “not combat” whatever the machine is doing, which is a gate that is open rather than a gate that is missing.
- property race_names: dict[int, str] | None¶
Race code -> name, or None when this title’s list is unknown.
- property save_position_base: int¶
The save image’s own copy of the party square.
Refreshed only when the game saves, so it names the square the party stood on at the last save.
live_positionis the one that moves.
- travel_grid: bool = False¶
Does this title have a square-engine overland at all? True for Pool of Radiance only. Curse of the Azure Bonds and Secret of the Silver Blades carry no
SQRDATA,SQRPACIorWALLSon either side of any disk (docs/121-silver-blades.md, “No city-block/wilderness structure”), so$49E6and$49C3there would be read as this title’s meaning of bytes that belong to something else – a plausible wrong square, whichparty_fixrefuses to answer rather than guess at. Seeindoors_flag_baseandtravel_position_base.The status line’s own
OUTDOORSpattern is a different question and is not gated by this:#205 (A party that walks out onto the travel grid leaves the automapper's marker behind)found the literal string in both titles’DUNGEONoverlay (tools/outdoorsgrep.py), sitting among other short message fragments (EXIT,SEARCH," IS ") rather than proven to be a status-line reading – open, and needs a driven session, not this table.
- goldbox.games.LIVE_POSITION_GOLDBOX = 49227¶
Where the engine keeps the party’s square while the game runs, which is not where it writes it when the game saves.
$C04Bx,$C04Cy,$C04Dfacing.MEASURED, three times over, and not inferred from anything:
Pool of Radiance –
$1A3Cisif $49E6 then copy $C04B..$C04D into $49C0..$49C2, and 29 of its 30 area scripts write it (docs/118§);Curse of the Azure Bonds – found by intersecting two 64K dumps taken either side of a step, one candidate left (
docs/120§4);Secret of the Silver Blades – the same triple, confirmed unchanged over nine steps and three refusals (
docs/121§5).
docs/138-multiple-games.mdrecords it as CONFIRMED for those three titles and for no others, which is why the Krynn titles and Gateway leavelive_positionNone below.
- goldbox.games.MODE_FLAG_LATER = 32529¶
The same byte in Curse of the Azure Bonds and Secret of the Silver Blades, and it is not Pool of Radiance’s plus anything the save image moved by:
LINKERis its own resident, so the flag went$6E11->$7F11while the save image went$4900->$4B00.Read out of the loader’s own first instruction, which is where the address is an absolute operand and so does not depend on where
LINKERloads:LINKERonCURSE_A.D64and onSILVER-1.D64both beginAD 11 7F,LDA $7F11, then index a name table of ten entries andJSR $0800. The name table is Pool of Radiance’s, entry for entry –GEN,DUNGEON,COMBAT,INIT,COM.PREP,POST.COM, two dead slots,FINAL,CAMP– so2is COMBAT in all three titles andautomap.actions.COMBATneeds no per-title value.CONFIRMED for both, each in its own driven session on pool slot 2.
LINKERis resident at$2D00in both – byte-identical to the disk copy – and the flag was sampled across every overlay change the session made: Curse world1, camp9, world1, roster0, world1; Silver Blades credits3INIT, roster0GEN, world1, treasure5POST.COM, world1.LDA #$09 / STA $7F11sits at$100Ein Curse’s residentDUNGEON, where Pool of Radiance’sDUNGEON $10B1writes9.`2` was sampled live on Silver Blades, at the end of 228 driven steps:
1->4COM.PREP->2withMOVE VIEW AIM TURN QUICK DONEon the command bar, andidentifyrefusing because$7F11is 2. That is also the first live sighting of4in any title. On Curse it was not: no session has reached a fight there, so2rests on the dispatch table alone – which is the same table, so the risk is small and it is written down rather than glossed.docs/50-experiments.md, “the later titles’ mode flag is$7F11”; issue #29.
- goldbox.games.MODE_FLAG_POOL = 28177¶
which overlay is running, and
2is COMBAT. Outside the save image likelive_position, and like it a measurement of one title rather than a family constant –LINKERis 136 bytes of resident code at$2B80.CONFIRMED: “
$6E11is the mode flag” indocs/50-experiments.mdreads the outer loop itself –LDA $6E11, index the overlay name table, load it at$0800, call it.- Type:
LINKER’s dispatch byte in Pool of Radiance
- goldbox.games.RACES_CURSE = ((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (7, 'human'), (8, 'monster'))¶
its label table points BOTH 6 and 7 at HUMAN. 6 is left out here on purpose – naming it “half-orc” would contradict what the game prints, and naming it “human” would give two codes one name and let an import silently rewrite a 7 as a 6. A Pool of Radiance half-orc converted across shows as a bare
6, which is the honest answer.- Type:
Curse drops half-orc from generation but keeps human at 7
- goldbox.games.RACES_FORGOTTEN_REALMS = ((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster'))¶
Pool of Radiance and Gateway to the Savage Frontier, identically.
- goldbox.games.RACES_KRYNN = ((0, 'silvanesti elf'), (1, 'qualinesti elf'), (2, 'half-elf'), (3, 'mountain dwarf'), (4, 'hill dwarf'), (5, 'kender'), (6, 'human'))¶
a different list entirely, and the only one that is 0-based – Death Knights’ CELESTE is race 0, which is why 0 had to be a real race rather than the “monster” it is in the Realms titles.
- Type:
Krynn
- goldbox.games.RACES_SILVER_BLADES = ((1, 'elf'), (2, 'half-elf'), (3, 'dwarf'), (4, 'gnome'), (5, 'halfling'), (6, 'human'))¶
Silver Blades drops half-orc and re-orders the rest, so human moves to 6. Codes 1-6 are the generation menu in menu order; 0 also prints ELF.
- exception goldbox.games.UnknownGameError[source]¶
Bases:
ValueErrorRaised when a key names no title we know.
- goldbox.games.by_title(title)[source]¶
The title a person named, or None. Never falls back to a default.
The windows carry the game as a plain string – see
AutomapState.title– and this is the one place that turns it back into a descriptor. None for an unrecognised name on purpose: a caller that needs an address has to notice it does not have one.- Parameters:
title (str | None)
- Return type:
goldbox.games.Game | None
- goldbox.games.class_table(game)[source]¶
The bit -> name pairs for a title, or Pool of Radiance’s four.
A title whose list we do not know gets an empty table, which makes
classes_to_names()hand back the raw bitmask rather than a wrong name.- Parameters:
game (goldbox.games.Game | None)
- Return type:
- goldbox.games.classes_to_names(bits, game=None)[source]¶
The classes a bitmask holds, named – or the mask itself, unnamed.
- Parameters:
bits (int)
game (goldbox.games.Game | None)
- Return type:
- goldbox.games.detect(disk, default=None)[source]¶
The title a D64 holds a save for, or
default.- Parameters:
default (goldbox.games.Game | None)
- Return type:
goldbox.games.Game | None
- goldbox.games.detect_from_names(names)[source]¶
The title whose save file appears in a directory listing, or None.
The save file’s name is the discriminator: no two titles share one, and no disk carries two. Deliberately name-only – a truncated or absent payload is a loading error with a message worth reading, not a reason to guess a different game.
- Return type:
goldbox.games.Game | None