goldbox.c64_save

Where each C64 title’s saved game keeps the things a conversion writes.

goldbox/games.py says how big a title’s save file is and where it loads; this module says what is inside it – which pages are characters, which are items, where the names and the roster are, which header bytes a conversion computes and which it writes as a measured zero. A table, not a class hierarchy, for the same reason goldbox/games.py is one: what differs between the titles is a handful of numbers.

Every offset here is a payload offset, so the same number means the same thing in both titles – Pool of Radiance’s save image loads at $4900 and Curse of the Azure Bonds’ at $4B00, and every field anybody has looked at sits at the same distance into the payload. That is not a convenience: it is the finding. The same routine appears in both titles’ DUNGEON with every operand $200 apart – Pool of Radiance’s clock tick at $0DEC reads INC $49C6,X / CMP $0E4D,X / STA $49C6,X and Curse’s at $0D4F reads INC $4BC6,X / CMP $0DB0,X / STA $4BC6,X, instruction for instruction – and the two ports’ ECL bytecode is the same bytes, so the scripts cannot name different addresses (#192 step 0a).

What is not the same is the container around those bytes. Pool of Radiance writes two files and keeps twelve character pages, twelve item pages and its roster in the second file; every later title writes one file with eight character pages, a table of the party’s names where Pool of Radiance’s ninth character page would be, eight item pages, ANIMATE00’s picture buffer and the roster at the end.

Confidence: Pool of Radiance’s row and Curse of the Azure Bonds’ are each measured on that title’s own engine-written saves. Secret of the Silver Blades’ is not – the only save of it anybody here has is the one SSI shipped – so its rows are read out of that title’s own overlays and its ECL bytecode wherever the code says anything at all, and graded where they are made.

Module Attributes

Region

A run of payload bytes and the sentence its report line carries.

CURSE_OF_THE_AZURE_BONDS

Curse of the Azure Bonds.

SECRET_OF_THE_SILVER_BLADES

Secret of the Silver Blades.

Functions

container_for([game])

The container for a title, Pool of Radiance's by default.

Classes

Container

One title's saved game, as payload offsets and measured verdicts.

goldbox.c64_save.CURSE_OF_THE_AZURE_BONDS = Container(game=Game(key='curse-of-the-azure-bonds', title='Curse of the Azure Bonds', save_file=b'SAVEAZURE', save_load_address=19200, save_size=7424, roster_file=None, roster_load_address=None, roster_size=256, roster_offset=7168, slot_count=8, record_slot_count=12, disk_glob='CURSE*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter'), (64, 'paladin'), (128, 'ranger')), item_names_load_address=40448, live_position=49227, mode_flag=32529, travel_grid=False), slot_area=1024, slot_stride=256, party_slots=8, record_pages=8, name_table=3072, name_stride=16, names_in_marching_order=False, item_area=4096, item_pages=8, icon_table=736, icon_size=36, picture_buffer=(6144, 1024), roster_offset=7168, roster_stride=32, cache=(704, 25), cache_bit7=True, disk_hint=238, current_geo=197, current_script=242, indoors=230, position=192, travel_position=195, clock=198, quest_flags=(288, 224), portrait_switch=255, zeroed=((195, 2, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (204, 26, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (233, 1, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (234, 1, "zero: this is Pool of Radiance's disk hint and Curse does not use it -- no absolute-mode instruction in 411 files names it, against three that name +$EE, and it reads 0 in all three engine-written Curse saves whose files are on side 2 (#192 step 0e)"), (235, 3, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (239, 1, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (3200, 896, 'zero: the name table fills the first 128 bytes of its page and the rest of the page is zero in all three engine-written Curse saves'), (240, 2, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (243, 9, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (252, 1, "zero: the two ports disagree about it -- the DOS save holds 4 and both engine-written C64 saves hold 2 -- so it is a loader value each port keeps for itself rather than a variable the party carries, and Pool of Radiance's own zero here was loaded, walked and fought in (#118)"), (253, 2, "zero: nineteen of Curse's area scripts write +$FE and nine write +$FD from their own entry code, so the arriving script refills both (#192 step 0e)"), (512, 128, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)'), (729, 7, 'zero: what both engine-written Curse saves hold there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #192)')), copied=((231, 2, "from the DOS save: four area scripts write these two at their heads and DUNGEON $1502 reads them, and nobody has said what they hold -- so the party's own value crosses rather than a zero (#192 step 0e)"), (256, 32, 'the per-script scratch, from the DOS save: DUNGEON $21BA clears it only when the script id changes, so a save taken inside an area is carrying live scratch its own script reads on the next step (#192 step 0a)')))

Curse of the Azure Bonds. Four rows differ from Pool of Radiance’s and each has its own measurement:

  • the disk hint is `+$EE`, not `+$EA`. CAMP $0C87 is LDA $7F12 / STA $2BE6 / STA $4BEE on the save path and GEN $2008 is LDA $4BEE / STA $7F12 on the load path; +$EE reads 2 in all three engine-written Curse saves, whose files are on side 2, and +$EA reads 0 in all three and is named by nothing in 411 files (#192 step 0e). So Pool of Radiance’s five-byte zeroing run from +$EB is split in two here, and +$EE is written from the area’s own row.

  • `+$E7` and `+$E8` are copied, not zeroed. Four area scripts write them at their heads and DUNGEON $1502 reads them; nobody has said what they hold, so the party’s own value crosses rather than a zero.

  • the per-script scratch `+$100`-`+$11F` is copied. DUNGEON $21BA, the NEWECL handler, clears it only when the script id changes (CMP $7F1B / BEQ guarding LDX #$1F / LDA #$00 / STA $4C00,X), so a save taken inside an area is carrying live scratch its own script reads on the next step. Pool of Radiance zeroes it because DUNGEON $202A does the same clear and its converted saves always arrive somewhere.

  • the party’s names have a table of their own at +$C00, where Pool of Radiance’s ninth character page would be. Sixteen bytes each in slot order: in both engine-written specimens name n is the name in the record at slot n, for six characters and for four.

class goldbox.c64_save.Container[source]

Bases: object

One title’s saved game, as payload offsets and measured verdicts.

__init__(game, slot_area=1024, slot_stride=256, party_slots=8, record_pages=12, name_table=None, name_stride=16, names_in_marching_order=False, item_area=4096, item_pages=12, icon_table=736, icon_size=36, picture_buffer=None, roster_offset=None, roster_stride=32, cache=(704, 25), cache_bit7=False, disk_hint=234, current_geo=197, current_script=242, indoors=230, position=192, travel_position=195, clock=198, quest_flags=(288, 217), portrait_switch=255, zeroed=(), copied=())
Parameters:
Return type:

None

cache: tuple[int, int] = (704, 25)

25 slots, one per file kind.

Type:

The loaded-files cache

cache_bit7: bool = False

Does a converted save have to set bit 7 on the slots it fills?

The two titles are the reverse of each other, and getting it wrong is a save whose cache the loader reads as something else. Pool of Radiance ORs the bit on the load path – GEN $25DE is LDA $4BC0,X / ORA #$80 / STA $6E13,X over all 25 – so whatever a save carries is discarded and set again. Curse ORs it on the save path and copies raw on load: CAMP $0CBC and GEN $1F9F are LDA $7F13,X / ORA #$80 / STA $4DC0,X, and GEN $1F55 is LDA $4DC0,X / STA $7F13,X with no ORA (#192 step 0e).

clock: int = 198
copied: tuple[tuple[int, int, str], ...] = ()

Header runs a conversion copies out of the DOS save, at the same distance into that title’s own ECL variable array.

current_geo: int = 197

The map LOADFILES reloads, the script id, and the indoors flag.

current_script: int = 242
disk_hint: int = 234

The byte the loader asks the player for a disk side by.

game: goldbox.games.Game
icon(index)[source]
Parameters:

index (int)

Return type:

int

icon_size: int = 36
icon_table: int = 736

eight icons of 36 bytes, ending exactly where the slot area begins.

Type:

The combat-icon table

indoors: int = 230
item_area: int = 4096

The item pages, one per slot, and how many the file carries.

item_pages: int = 12
items(index)[source]
Parameters:

index (int)

Return type:

int

name(index)[source]
Parameters:

index (int)

Return type:

int

name_index(slot, party)[source]

Which table entry belongs to the character in slot.

party is how many characters the party has, because a marching-order table is indexed from the top slot down and the top slot is party - 1 – the same arithmetic goldbox.dos.marching_slot does in the other direction.

Parameters:
Return type:

int

name_stride: int = 16
name_table: int | None = None

The party’s names again, sixteen bytes each, or None for a title that keeps no such table.

names_in_marching_order: bool = False

Which index that table is keyed by. False – Curse of the Azure Bonds’ – means entry n is the name of the character in slot n. True means entry n is the *n*th character of the marching order, which is the reverse, because the C64 fills slots from the top down. See SECRET_OF_THE_SILVER_BLADES below for why the two titles differ.

party_slots: int = 8
property payload_size: int
picture_buffer: tuple[int, int] | None = None

the decoded glyphs and colours of whatever picture was in the view window when the save was written, which on ENCAMP is always the camp scene. None for a title whose save does not span it.

Not a map, though it was called one until #309 (Eight files still call Curse’s picture buffer a map region, which is what it was guessed to be before anybody read it). It is not a region the game keeps anything in: a Curse or Silver Blades save is one KERNAL SAVE of $4B00-$67FF and $6300 is simply what lies between the item pages and the roster. docs/181-curse-picture-buffer.md has the decode, the two specimens matching a frame byte for byte, and the driven session in which nothing read the region before the engine zeroed it.

Type:

ANIMATE00’s picture buffer

portrait_switch: int = 255

The switch that decides whether the character sheet draws a portrait.

position: int = 192

The party’s square and facing, the travel-grid square, the six clock digits, and the quest-flag page.

The flag page ends in a different place in each title, so this is (offset, length) rather than a shared constant. Pool of Radiance’s stops at +$1F8 because +$1FA and +$1FD are its wallset and wallmap triples; Curse of the Azure Bonds and Secret of the Silver Blades keep their wall triples elsewhere and their scripts use the page to the end – see goldbox.dos.quest_flags.

quest_flags: tuple[int, int] = (288, 217)
record_pages: int = 12
property roster_in_payload: bool
roster_offset: int | None = None

Where the roster blocks are. An offset means “in this payload”; None means the title writes them into game.roster_file instead.

roster_stride: int = 32
slot(index)[source]
Parameters:

index (int)

Return type:

int

slot_area: int = 1024

Where the character slots begin, and how many pages of them the file actually has. party_slots is how many a party member can occupy; record_pages is how many the file carries, which is more than that in Pool of Radiance because combat fills four more.

slot_stride: int = 256
travel_position: int = 195
zeroed: tuple[tuple[int, int, str], ...] = ()

Header runs a conversion writes as zero, with what measured each.

goldbox.c64_save.Region

A run of payload bytes and the sentence its report line carries.

alias of tuple[int, int, str]

goldbox.c64_save.SECRET_OF_THE_SILVER_BLADES = Container(game=Game(key='secret-of-the-silver-blades', title='Secret of the Silver Blades', save_file=b'SAVEDBASH', save_load_address=19200, save_size=7424, roster_file=None, roster_load_address=None, roster_size=256, roster_offset=7168, slot_count=8, record_slot_count=12, disk_glob='SILVER*.[dD]64', races=((1, 'elf'), (2, 'half-elf'), (3, 'dwarf'), (4, 'gnome'), (5, 'halfling'), (6, 'human')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter'), (64, 'paladin'), (128, 'ranger')), item_names_load_address=40448, live_position=49227, mode_flag=32529, travel_grid=False), slot_area=1024, slot_stride=256, party_slots=8, record_pages=8, name_table=3072, name_stride=16, names_in_marching_order=True, item_area=4096, item_pages=8, icon_table=736, icon_size=36, picture_buffer=(6144, 1024), roster_offset=7168, roster_stride=32, cache=(704, 25), cache_bit7=True, disk_hint=238, current_geo=197, current_script=242, indoors=230, position=192, travel_position=195, clock=198, quest_flags=(288, 224), portrait_switch=255, zeroed=((195, 2, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (204, 26, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (234, 1, "zero: DUNGEON $0B0E stores a byte it has just read out of its own table here and reads it back at $0B1E, so nothing in the save reaches that read -- and Pool of Radiance's disk hint is at +$EE in this title, named by CAMP, GEN and LINKER (#193)"), (235, 3, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (239, 1, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (3200, 896, 'zero: the name table fills the first 128 bytes of its page and the rest of the page is zero in the shipped save'), (240, 2, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (243, 9, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (252, 1, "zero: the two ports disagree about it -- all five DOS Silver Blades containers on this machine hold 4 and the shipped C64 save holds 2 -- so it is a loader value each port keeps for itself rather than a variable the party carries, and Curse's converted save booted with a zero here (#192, #193)"), (512, 128, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)'), (729, 7, 'zero: what the one Secret of the Silver Blades save on this machine holds there, and what the same address is written as in a Pool of Radiance save that was loaded, walked and fought in (#118, #193)')), copied=((231, 3, "from the DOS save: seventeen of this title's area scripts write +$E7 and +$E8 at their heads and seven write +$E9, none of the twenty-two ever reads one, and DUNGEON does -- so the party's own value crosses rather than a zero (#193)"), (253, 2, 'from the DOS save: per-area constants four scripts write into +$FD and fourteen into +$FE, read by DUNGEON and by no script (#193)'), (256, 32, 'the per-script scratch, from the DOS save: the NEWECL handler clears it only when the script id changes, so a save taken inside an area is carrying live scratch its own script reads on the next step (#192 step 0a, #193)')))

Secret of the Silver Blades. The container is Curse of the Azure Bonds’ byte for byte under a different file name – one 7424-byte SAVEDBASH at $4B00, header $400, eight character pages, a name table at +$C00, eight item pages at +$1000, the picture buffer at +$1800 and the roster at +$1C00 (tests/test_silverblades.py). Three rows differ from Curse’s, and each was read out of this title’s own overlays rather than assumed:

  • the cache bit and the disk hint are Curse’s, and the code says so. CAMP $0CA5 is LDX #$18 / LDA $7F13,X / ORA #$80 / STA $4DC0,X on the save path with LDA $4BF2 / ORA #$80 / STA $4DC8 after it, GEN $2469 is the same loop, and GEN $2424 is LDA $4DC0,X / STA $7F13,X with no ORA on the load path – so a converted save must set bit 7 itself. CAMP $0C65 is LDA $7F12 / STA $4BEE and GEN $228E is LDA $4BEE / STA $7F12, so +$EE is the disk hint here too; +$EA is named twice in DUNGEON $0B0E, which stores a table byte into it and reads it back three instructions later, so nothing in the save reaches that read. CONFIRMED.

  • `+$E7`-`+$E9` and `+$FD`-`+$FE` are copied rather than zeroed. An address census over all 22 of this title’s scripts, both ports (tools/eclcensus.py), gives $4BE7 and $4BE8 18 writes and no reads over seventeen scripts, $4BE9 10 writes over seven, $4BFD 8 and $4BFE 16. They are per-area constants an arriving script sets, and the party’s own value is in the DOS save at the same ECL address, so the conversion writes that rather than a zero nobody has measured. Curse zeroes +$FD/+$FE and its engine put 8 and 9 back unasked, which is the same fact from the other side.

  • the name table may be keyed the other way round. In both engine-written Curse saves entry n is the name in slot n; in the shipped SAVEDBASH entry 0 is GUY DE VALOIS and slot 0 is MORGAINE, so the table runs in marching order and the slots run the other way. Everything else in that file is slot-ordered – roster block n carries slot n’s armour class and hit points, six of six – so it is the table that is reversed and not the file. PROBABLE, on one file that SSI shipped; #193 step 3 is what settles it, because a wrong order is a party whose names do not match its sheets.

goldbox.c64_save.container_for(game=None)[source]

The container for a title, Pool of Radiance’s by default.

Takes a goldbox.games.Game, a key, a Container, or None – the same shape goldbox.spells.for_game takes, so a caller holding any of them does not have to convert first. A title with no row raises: writing a save for a container nobody has measured would be inventing its geometry, and an unrecognised key is more likely a typo than a new title.

Return type:

goldbox.c64_save.Container