goldbox.spells

The spell name table, and what a memorised spell list means.

A character’s memorised spells are a packed list of spell ids at record offset 0x020, and the names live on the game disk. Where on the disk is the one thing that does not transfer between titles, so this module is a table per title – the shape goldbox/games.py settled on – and every entry point takes an optional game.

Pool of Radiance

Curse of the Azure Bonds

Secret of the Silver Blades

file

SPELLN00

COMBAT2

COMBAT2

resident at

$B000

$E000

$E000

entries

128

170

194

order

128 low bytes, 128 high bytes, then the strings

the strings, then 170 high bytes, then 170 low bytes

the same as Curse

index of spell n

n

n - 1

n - 1

spells run to

56

100

117

spellbook mask

7 bytes

13

16

Neither file’s PRG header helps: SPELLN00 declares $2710, which is a scratch buffer. Curse’s base needs no fitting at all – the pointer for index 0 is $E000 and the text runs $E000-$E7DA, exactly the range of high bytes the array holds. Silver Blades’ is the same file in the same shape with a longer text block: $E000-$E877, 194 entries, and 193 of its 194 pointers land on a string start where no neighbouring entry count scores better than 167. The method was validated on Curse first, where it recovers the already-known 170 / $07DB / $0885 exactly.

Read through the pointers, never by splitting on NULs. The strings overlap. CURE LIGHT WOUNDS and CAUSE LIGHT WOUNDS share one copy of ` LIGHT WOUNDS` in both games; Curse adds SHIELD as the tail of FIRE SHIELD and INVISIBILITY as the tail of DETECT INVISIBILITY, so splitting its block yields 150 strings for 169 names and goes wrong from id 11 onward.

Ids 1-56 are the same spell in both games, read off Curse’s own table rather than inferred, which is what makes an imported spellbook mean what it said: bit 20 is SHOCKING GRASP either side. Past its own last spell each table continues with combat message fragments – AND MISSES..., POINTS OF DAMAGE – which share the mechanism and not the meaning: Pool of Radiance from 57, Curse from 101, Silver Blades from 118.

Silver Blades keeps 54 of those 56 and reassigns two: 36 is HEAL where the other two have ANIMATE DEAD, and 56 is HARM where they have RESTORATION. That is the game’s own doing and not a misread stride – its GEN spell-grant table sets exactly those two bits, and only those two, when a cleric reaches level 11 with wisdom 17 or better, which is when and how AD&D 1st edition grants sixth-level clerical spells. An import from another title therefore carries a spellbook whose bits 36 and 56 change meaning, and nothing here rewrites them.

`SPELLN64` is not a spell-name table in either game, whatever its stem suggests. It is 1878 bytes of icon-editor menu strings, and both titles ship it. Curse ships no SPELLN00 at all.

Module Attributes

DEFAULT

What a caller gets when it says nothing.

SPELL_RESTORATION

RESTORATION.

SPELLBOOK_FIELDS

The two goldbox/layout.py fields the mask is declared as, in record order: the seven bytes Pool of Radiance uses, and the nine 0x07F-0x087 the titles after it continue into.

Functions

capacity(class_bits, level, wisdom[, game])

How many spells of each level the character may memorise.

describe(spell_id[, names, game])

SLEEP (magic-user 1) -- the form a person wants to read.

for_game([game])

The spell table for a title.

load_spell_names(disk[, game])

Every string in the title's name table, keyed by spell id.

set_spellbook_raw(record, raw)

Write raw over the front of the mask.

spell_group(spell_id[, game])

(class, spell level) for a spell id, or None if it is not a spell.

spellbook_bytes(ids[, game])

The bitmask for a set of spell ids, as wide as the title's mask.

spellbook_raw(record)

Both declared fields of the mask, as one run of bytes.

spells_known(record_bytes[, game])

Every spell id the bitmask at 0x078 has set, for one title.

write_spellbook(record, ids[, game])

Set a record's spellbook to exactly ids, as wide as the title's mask.

Classes

SpellTable

Where a title keeps its spell names, and what the ids mean.

goldbox.spells.DEFAULT = SpellTable(key='pool-of-radiance', title='Pool of Radiance', file=b'SPELLN00', entries=128, resident_base=45056, text_offset=256, low_offset=0, high_offset=128, first_id=0, last_spell=56, groups=((1, 8, 'cleric', 1), (9, 21, 'magic-user', 1), (22, 28, 'cleric', 2), (29, 35, 'magic-user', 2), (36, 44, 'cleric', 3), (45, 55, 'magic-user', 3)), not_a_spell=(), spellbook_size=7, not_granted=(), magic_user_grant=(), ranger_grant=())

What a caller gets when it says nothing. Every caller predates the second game and means this one.

goldbox.spells.SPELLBOOK_FIELDS = ('spells_known', 'spells_known_high')

The two goldbox/layout.py fields the mask is declared as, in record order: the seven bytes Pool of Radiance uses, and the nine 0x07F-0x087 the titles after it continue into. Two fields rather than one sixteen-byte one because seven is a fact about this game and the split is where that fact is recorded. Nothing should read or write either half on its own: the three functions below are how the mask is reached, and they cross the boundary because the title decides where the mask ends, not the layout.

goldbox.spells.SPELL_RESTORATION = 56

RESTORATION. A cleric spell far above anything Pool of Radiance grants a player, so it is presumably the temple’s, and its level is not worth guessing.

class goldbox.spells.SpellTable[source]

Bases: object

Where a title keeps its spell names, and what the ids mean.

All six offsets are payload offsets – the PRG’s two-byte load address already peeled off. text_offset is where the strings begin and text_end where they stop; the pointer arrays sit on whichever side of them the title chose.

Pairs rather than dicts in groups, so the descriptor stays frozen and hashable, which is what goldbox/games.py does for the same reason.

__init__(key, title, file, entries, resident_base, text_offset, low_offset, high_offset, first_id, last_spell, groups=(), not_a_spell=(), spellbook_size=7, not_granted=(), magic_user_grant=(), ranger_grant=())
Parameters:
Return type:

None

entries: int
file: bytes
first_id: int
groups: tuple[tuple[int, int, str, int], ...] = ()

(first id, last id, class, spell level), in id order.

high_offset: int
in_spellbook(spell_id)[source]

Can this id be in a spellbook at all? Bit 0 is not a spell.

Parameters:

spell_id (int)

Return type:

bool

key: str
last_spell: int
property last_spellbook_spell: int

The highest id the mask has a bit for and the title has a spell for.

Two ceilings, and the lower wins. Pool of Radiance’s mask stops one id short of its own spell list – seven bytes is 56 bits, ids 0-55, and id 56 is RESTORATION, which the game can memorise and cannot record knowing. The two later titles have bits to spare instead.

low_offset: int
magic_user_grant: tuple[tuple[int, tuple[int, ...]], ...] = ()

the whole magic-user spell list a character of that level is granted, where the trainer hands out a row instead of building a menu to choose from. Empty for a title whose magic-user learns by picking one spell – goldbox.levelup.learnable is what tells the two apart. See SECRET_OF_THE_SILVER_BLADES below. CONFIRMED, read mechanically out of GEN by tests/test_silverblades.py:: _grant_table(_gen(), 0xC9, range(5, 10)) (#89).

Type:

(level, ids) pairs

not_a_spell: tuple[int, ...] = ()

unused slots, and the handful of combat messages Curse mixes in among its new spells.

Type:

Ids inside the spell range that are not spells

not_granted: tuple[int, ...] = ()

Ids that fall in one of the groups above and that the title’s trainer never hands out, so a level-up must not either. Pool of Radiance has none: GEN $20CF ORs a whole spell level into the mask rather than reading a table, so a cleric who can cast a level knows all of it. Curse replaced that with a per-level table and left three ids out of it, one of them not a cleric spell at all – see CURSE_OF_THE_AZURE_BONDS below. CONFIRMED.

ranger_grant: tuple[tuple[int, tuple[int, ...]], ...] = ()

Same shape, for a ranger. Empty below the level the title’s trainer first grants one – Silver Blades gates its routine at CPX #$08, so nothing before level 8. CONFIRMED the same way, _grant_table(_gen(), 0xD0, range(8, 16)).

resident_base: int
spellbook_size: int = 7

How many bytes of the spellbook bitmask at record 0x078 this title uses. Measured per title – the evidence is the comment above POOL_OF_RADIANCE below.

property text_end: int | None

Where the strings stop, or None when they run to the file’s end.

text_offset: int
title: str
goldbox.spells.capacity(class_bits, level, wisdom, game=None)[source]

How many spells of each level the character may memorise.

Read off the game’s own tables, not derived. The record also carries this numberspells_castable at 0x0EE-0x0F0, nibble-packed magic-user low / cleric high, one byte per spell level – so what this function computes can be checked against the save rather than trusted. Two independent readings agree on the packing: the project’s own (ROLAND, a level-1 cleric with WIS 16, reads $30) and the QUANTUM LEAPER trainer, which prints AND #$0F under MAGIC-USER SPELLS and four LSR`s under CLERIC SPELLS on those same three bytes, clamps each nibble to 14 and labels the field `LEVELS (0-14). It exposes three spell levels where the layout reserves six, which is Pool of Radiance’s real ceiling; Curse reaches five, and the record has room for it.

Returned per class, because a multi-class character memorises from each list separately.

Parameters:
Return type:

dict[str, tuple[int, …]]

goldbox.spells.describe(spell_id, names=None, game=None)[source]

SLEEP (magic-user 1) – the form a person wants to read.

Parameters:
Return type:

str

goldbox.spells.for_game(game=None)[source]

The spell table for a title.

Takes a goldbox.games.Game, a game key, a SpellTable, or None. Duck-typed on .key rather than importing goldbox.games, which would be a whole module of coupling for one string.

Return type:

goldbox.spells.SpellTable

goldbox.spells.load_spell_names(disk, game=None)[source]

Every string in the title’s name table, keyed by spell id.

Includes the non-spell tail: what a caller wants is usually {k: v for k, v in load_spell_names(d).items() if k <= LAST_SPELL}, but the messages are read the same way and there is no reason to hide them.

Parameters:

disk (goldbox.d64.D64 | str)

Return type:

dict[int, str]

goldbox.spells.set_spellbook_raw(record, raw)[source]

Write raw over the front of the mask. True if any byte moved.

A short raw writes only as far as it reaches: thirteen bytes leave 0x085-0x087 exactly as they were, which is what a Curse writer must do – whether those three are mask at all in Curse is UNKNOWN, its GEN having no clear loop, and a writer that does not know must not write.

Parameters:

raw (bytes)

Return type:

bool

goldbox.spells.spell_group(spell_id, game=None)[source]

(class, spell level) for a spell id, or None if it is not a spell.

Parameters:

spell_id (int)

Return type:

tuple[str, int] | None

goldbox.spells.spellbook_bytes(ids, game=None)[source]

The bitmask for a set of spell ids, as wide as the title’s mask.

Return type:

bytes

goldbox.spells.spellbook_raw(record)[source]

Both declared fields of the mask, as one run of bytes.

Return type:

bytes

goldbox.spells.spells_known(record_bytes, game=None)[source]

Every spell id the bitmask at 0x078 has set, for one title.

How far this reads is the title’s mask width: ids 1-55 on Pool of Radiance, 1-100 on Curse, 1-117 on Silver Blades. Reading a Silver Blades caster with no game costs five of MORGAINE’s twenty-nine spells, which is issue #81.

Ids the title’s name table calls something other than a spell – a combat message, an unused slot – are still reported. A bit that is set is set, and hiding it would lose it on a rewrite.

Parameters:

record_bytes (bytes)

Return type:

list[int]

goldbox.spells.write_spellbook(record, ids, game=None)[source]

Set a record’s spellbook to exactly ids, as wide as the title’s mask.

Return type:

bool