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

RACES_FORGOTTEN_REALMS

Pool of Radiance and Gateway to the Savage Frontier, identically.

RACES_CURSE

its label table points BOTH 6 and 7 at HUMAN.

RACES_SILVER_BLADES

Silver Blades drops half-orc and re-orders the rest, so human moves to 6.

RACES_KRYNN

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.

CLASS_BITS_WITH_PALADIN_RANGER

Curse, Silver Blades and Gateway.

CLASS_BITS_KRYNN

Champions' STRONGSWORD and Death Knights' SIR DRYDEN are single-class 0x10, lawful good, and the label pool carries KNIGHT and KNIGHT OF THE ROSE.

LIVE_POSITION_GOLDBOX

Where the engine keeps the party's square while the game runs, which is not where it writes it when the game saves.

MODE_FLAG_POOL

which overlay is running, and 2 is COMBAT.

MODE_FLAG_LATER

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: LINKER is its own resident, so the flag went $6E11 -> $7F11 while the save image went $4900 -> $4B00.

DEFAULT

What a caller gets when nothing says otherwise.

Functions

by_key(key)

by_title(title)

The title a person named, or None.

class_table(game)

The bit -> name pairs for a title, or Pool of Radiance's four.

classes_to_names(bits[, game])

The classes a bitmask holds, named -- or the mask itself, unnamed.

detect(disk[, default])

The title a D64 holds a save for, or default.

detect_from_names(names)

The title whose save file appears in a directory listing, or None.

race_table(game)

Race code -> name for a title, or Pool of Radiance's.

Classes

Game

One title's save-container geometry.

Exceptions

UnknownGameError

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: object

One title’s save-container geometry.

roster_file is the whole difference between the two shapes. When it is None the roster is roster_offset bytes into the main payload; when it is set the roster is that separate file, and roster_offset is an offset within it.

races, class_bits and item_names_load_address are 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)

  • races (tuple[tuple[int, str], ...] | None)

  • class_bits (tuple[tuple[int, str], ...] | None)

  • 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.

class_bits: tuple[tuple[int, str], ...] | None = None
property clock_base: int

The game clock, which is live at its save-image address.

disk_glob: str = '*.[dD]64'
property files: tuple[bytes, ...]

Every directory entry that makes up a save.

property icon_table_base: int
property indoors_flag_base: int | None

zero on the travel grid, non-zero in a GEO area.

None unless travel_grid, the same refusal live_position makes 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

property item_area_base: int
item_names_load_address: int | None = None
key: str
live_position: int | None = None

it sits outside the save image, so it cannot be derived from save_load_address and 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 SAVEAZURE that is a truncated demo party, and this is how that is told from the real thing.

Parameters:

prg (bytes)

Return type:

bool

mode_flag: int | None = None

The loader’s resident-overlay flag – the byte an action has to read before it writes, because 2 is 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 follows save_load_address nor 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.

races: tuple[tuple[int, str], ...] | None = None
record_slot_count: int = 12
property roster_base: int

The roster’s live address, wherever it lives.

roster_file: bytes | None = None
property roster_in_payload: bool
roster_load_address: int | None = None
roster_offset: int = 0
roster_size: int = 256
save_file: bytes
save_load_address: int
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_position is the one that moves.

property save_prg_size: int

What the file measures on disk, load address included.

save_size: int
property slot_area_base: int
slot_count: int = 8
title: str
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, SQRPACI or WALLS on either side of any disk (docs/121-silver-blades.md, “No city-block/wilderness structure”), so $49E6 and $49C3 there would be read as this title’s meaning of bytes that belong to something else – a plausible wrong square, which party_fix refuses to answer rather than guess at. See indoors_flag_base and travel_position_base.

The status line’s own OUTDOORS pattern 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’ DUNGEON overlay (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.

property travel_position_base: int | None

the window-local travel-grid square, x then y.

None unless travel_grid, for the same reason as indoors_flag_base.

Type:

$49C3/$49C4

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. $C04B x, $C04C y, $C04D facing.

MEASURED, three times over, and not inferred from anything:

  • Pool of Radiance – $1A3C is if $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.md records it as CONFIRMED for those three titles and for no others, which is why the Krynn titles and Gateway leave live_position None 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: LINKER is its own resident, so the flag went $6E11 -> $7F11 while 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 LINKER loads: LINKER on CURSE_A.D64 and on SILVER-1.D64 both begin AD 11 7F, LDA $7F11, then index a name table of ten entries and JSR $0800. The name table is Pool of Radiance’s, entry for entryGEN, DUNGEON, COMBAT, INIT, COM.PREP, POST.COM, two dead slots, FINAL, CAMP – so 2 is COMBAT in all three titles and automap.actions.COMBAT needs no per-title value.

CONFIRMED for both, each in its own driven session on pool slot 2. LINKER is resident at $2D00 in both – byte-identical to the disk copy – and the flag was sampled across every overlay change the session made: Curse world 1, camp 9, world 1, roster 0, world 1; Silver Blades credits 3 INIT, roster 0 GEN, world 1, treasure 5 POST.COM, world 1. LDA #$09 / STA $7F11 sits at $100E in Curse’s resident DUNGEON, where Pool of Radiance’s DUNGEON $10B1 writes 9.

`2` was sampled live on Silver Blades, at the end of 228 driven steps: 1 -> 4 COM.PREP -> 2 with MOVE VIEW AIM TURN QUICK DONE on the command bar, and identify refusing because $7F11 is 2. That is also the first live sighting of 4 in any title. On Curse it was not: no session has reached a fight there, so 2 rests 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 2 is COMBAT. Outside the save image like live_position, and like it a measurement of one title rather than a family constant – LINKER is 136 bytes of resident code at $2B80.

CONFIRMED: “$6E11 is the mode flag” in docs/50-experiments.md reads 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: ValueError

Raised when a key names no title we know.

goldbox.games.by_key(key)[source]
Parameters:

key (str)

Return type:

goldbox.games.Game

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:

list[tuple[int, str]]

goldbox.games.classes_to_names(bits, game=None)[source]

The classes a bitmask holds, named – or the mask itself, unnamed.

Parameters:
Return type:

list[str]

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

goldbox.games.race_table(game)[source]

Race code -> name for a title, or Pool of Radiance’s.

Empty when the title’s list is unknown, so a caller shows the raw number rather than inventing a name for it.

Parameters:

game (goldbox.games.Game | None)

Return type:

dict[int, str]