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
A run of payload bytes and the sentence its report line carries. |
|
Curse of the Azure Bonds. |
|
Secret of the Silver Blades. |
Functions
|
The container for a title, Pool of Radiance's by default. |
Classes
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 $0C87isLDA $7F12 / STA $2BE6 / STA $4BEEon the save path andGEN $2008isLDA $4BEE / STA $7F12on the load path;+$EEreads 2 in all three engine-written Curse saves, whose files are on side 2, and+$EAreads 0 in all three and is named by nothing in 411 files (#192step 0e). So Pool of Radiance’s five-byte zeroing run from+$EBis split in two here, and+$EEis written from the area’s own row.`+$E7` and `+$E8` are copied, not zeroed. Four area scripts write them at their heads and
DUNGEON $1502reads 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, theNEWECLhandler, clears it only when the script id changes (CMP $7F1B / BEQguardingLDX #$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 becauseDUNGEON $202Adoes 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:
objectOne 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:
game (goldbox.games.Game)
slot_area (int)
slot_stride (int)
party_slots (int)
record_pages (int)
name_table (int | None)
name_stride (int)
names_in_marching_order (bool)
item_area (int)
item_pages (int)
icon_table (int)
icon_size (int)
roster_offset (int | None)
roster_stride (int)
cache_bit7 (bool)
disk_hint (int)
current_geo (int)
current_script (int)
indoors (int)
position (int)
travel_position (int)
clock (int)
portrait_switch (int)
- Return type:
None
- 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 $25DEisLDA $4BC0,X / ORA #$80 / STA $6E13,Xover 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 $0CBCandGEN $1F9FareLDA $7F13,X / ORA #$80 / STA $4DC0,X, andGEN $1F55isLDA $4DC0,X / STA $7F13,Xwith noORA(#192step 0e).
- 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.
- game: goldbox.games.Game¶
- icon_table: int = 736¶
eight icons of 36 bytes, ending exactly where the slot area begins.
- Type:
The combat-icon table
- name_index(slot, party)[source]¶
Which table entry belongs to the character in
slot.partyis how many characters the party has, because a marching-order table is indexed from the top slot down and the top slot isparty - 1– the same arithmeticgoldbox.dos.marching_slotdoes in the other direction.
- 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_BLADESbelow for why the two titles differ.
- 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
ENCAMPis 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
SAVEof$4B00-$67FFand$6300is simply what lies between the item pages and the roster.docs/181-curse-picture-buffer.mdhas 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
- 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+$1F8because+$1FAand+$1FDare 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 – seegoldbox.dos.quest_flags.
- 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_fileinstead.
- goldbox.c64_save.Region¶
A run of payload bytes and the sentence its report line carries.
- 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
SAVEDBASHat$4B00, header$400, eight character pages, a name table at+$C00, eight item pages at+$1000, the picture buffer at+$1800and 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 $0CA5isLDX #$18 / LDA $7F13,X / ORA #$80 / STA $4DC0,Xon the save path withLDA $4BF2 / ORA #$80 / STA $4DC8after it,GEN $2469is the same loop, andGEN $2424isLDA $4DC0,X / STA $7F13,Xwith noORAon the load path – so a converted save must set bit 7 itself.CAMP $0C65isLDA $7F12 / STA $4BEEandGEN $228EisLDA $4BEE / STA $7F12, so+$EEis the disk hint here too;+$EAis named twice inDUNGEON $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$4BE7and$4BE818 writes and no reads over seventeen scripts,$4BE910 writes over seven,$4BFD8 and$4BFE16. 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/+$FEand 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
SAVEDBASHentry 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;#193step 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, aContainer, or None – the same shapegoldbox.spells.for_gametakes, 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: