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 |
|
|
|
resident at |
|
|
|
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
What a caller gets when it says nothing. |
|
RESTORATION. |
|
The two |
Functions
|
How many spells of each level the character may memorise. |
|
|
|
The spell table for a title. |
|
Every string in the title's name table, keyed by spell id. |
|
Write |
|
(class, spell level) for a spell id, or None if it is not a spell. |
|
The bitmask for a set of spell ids, as wide as the title's mask. |
|
Both declared fields of the mask, as one run of bytes. |
|
Every spell id the bitmask at 0x078 has set, for one title. |
|
Set a record's spellbook to exactly |
Classes
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.pyfields the mask is declared as, in record order: the seven bytes Pool of Radiance uses, and the nine0x07F-0x087the 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:
objectWhere 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_offsetis where the strings begin andtext_endwhere 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 whatgoldbox/games.pydoes 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=())¶
- groups: tuple[tuple[int, int, str, int], ...] = ()¶
(first id, last id, class, spell level), in id order.
- 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.
- 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.learnableis what tells the two apart. SeeSECRET_OF_THE_SILVER_BLADESbelow. CONFIRMED, read mechanically out ofGENbytests/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 $20CFORs 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 – seeCURSE_OF_THE_AZURE_BONDSbelow. 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)).
- 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 number –
spells_castableat0x0EE-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 printsAND #$0Funder MAGIC-USER SPELLS and fourLSR`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.
- goldbox.spells.describe(spell_id, names=None, game=None)[source]¶
SLEEP (magic-user 1)– the form a person wants to read.
- goldbox.spells.for_game(game=None)[source]¶
The spell table for a title.
Takes a
goldbox.games.Game, a game key, aSpellTable, or None. Duck-typed on.keyrather than importinggoldbox.games, which would be a whole module of coupling for one string.- Return type:
- 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:
- goldbox.spells.set_spellbook_raw(record, raw)[source]¶
Write
rawover the front of the mask. True if any byte moved.A short
rawwrites only as far as it reaches: thirteen bytes leave0x085-0x087exactly as they were, which is what a Curse writer must do – whether those three are mask at all in Curse is UNKNOWN, itsGENhaving no clear loop, and a writer that does not know must not write.
- goldbox.spells.spell_group(spell_id, game=None)[source]¶
(class, spell level) for a spell id, or None if it is not a spell.
- 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:
- goldbox.spells.spellbook_raw(record)[source]¶
Both declared fields of the mask, as one run of bytes.
- Return type:
- 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
gamecosts 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.