goldbox.levels¶
Level progression: what each class needs, and what it gets.
Two titles, one shape. Pool of Radiance caps a fighter at 8 and a cleric at 6
because it was written to hand its party on to Curse of the Azure Bonds;
Curse raises every ceiling, adds paladin and ranger, and carries thirteen
experience rows where Pool of Radiance carries nine. Nothing about that is a
different kind of table, so this module is data per title – the same choice
goldbox/games.py made – and every entry point takes an optional game.
Every number here is either read off the player’s own disks or transcribed from AD&D 1st edition and then checked against them. The tables the game carries, and where:
table |
Pool of Radiance |
Curse |
Silver Blades |
|---|---|---|---|
experience |
|
|
|
class ceiling |
|
|
|
racial class limit |
|
|
|
THAC0 |
|
|
|
hit dice |
– (no class reaches the flat-hit-point rule) |
|
|
spell slots |
|
|
not read (trainer input, #89) |
saving throws |
|
|
|
racial save bonus |
|
|
|
thief skills |
|
|
|
hit die |
|
|
|
constitution hit-point bonus |
|
|
|
wisdom bonus spells |
|
|
not read (#89) |
turning level |
|
|
|
GEN is resident at $0800 in all three games whatever its PRG header
claims.
One of those tables is not the same on the DOS side. Everything above is
the C64’s, and for every table but one the DOS build agrees. The exception is
THAC0: Pool of Radiance’s DOS build ships 40 – THAC0 20 – where the C64’s
$1F1F ships 39, in the magic-user’s rows 1-5 and the thief’s rows 1-4, so a
low-level DOS caster or thief hits one point better than the same character on
the C64. dos_thac0 carries it and _DOS_THAC0_POOL carries the provenance.
Curse’s and Silver Blades’ DOS tables are not in here: theirs keep 39 in
the mage row and their records still store 40, so something else is at work in
those two and nobody has found it – see _DOS_THAC0_POOL.
Not one Pool of Radiance address survives into Curse, which is the
measurement TRAINER_MEASURED rests on. The two files were compared byte for
byte from $0800 – Pool of Radiance’s 9083 bytes off POOL3, Curse’s 9455
off CURSE_A – and 8925 of the 9083 common bytes differ. Every address in the
table above and every one in docs/135-levelling.md holds something else in
Curse. Two of Pool of Radiance’s tables were found elsewhere in Curse’s file
and no others: the hit die 2697 bytes earlier at $161E, and the thief-skill
rows 42 bytes earlier at $1004. So selecting Curse’s level tables is not
selecting Curse’s trainer.
Curse’s column of that table is now filled in, and the four gaps it used to
leave at `–` are what `tests/test_cursetrainer.py` reads off the disk
(#18). Finding them needed no emulator and no address from this file: Curse’s
working character sits at $7C00, so a census of every absolute instruction
whose operand lands in the record puts each routine within two instructions of
the table it reads, which is what tools/trainerscan.py prints. Locating
them is not the same as being able to write a Curse record, and four of the
readings are a different rule rather than the same rule at a new address:
the hit die is rolled twice and the better roll kept (
$15FC), where Pool of Radiance rolls once and floors a single-class fighter at 4;`hp_max` is per class slot,
min(level, roll_to) * bonussummed over the slots, one extra bonus for a ranger, then divided by how many classes the character has – againsthp_rolled + level * bonushere. It disagrees with three of the six characters SSI shipped;thief skills read dexterity (
$0FC6), andthief_skill_rowhas no term for it;spell capacity is never stored. Nothing in
GEN,ECL64orECL65writes0x0EE-0x0F3;ECL65 $880Drebuilds it in fifteen bytes of workspace whenever the sheet is drawn, and all six shipped characters hold zero there.
Curse’s tables are now in this file, and the four shapes that could not
carry them have been widened (#18): thief_skill_row takes a dexterity,
constitution_hp_bonus takes a class slot, wisdom_bonus_spells takes a title
and returns as many spell levels as that title reaches, and turning_level
takes a paladin. Each carries its own grade, because they are not evidenced
alike – what separates them is whether a byte on a disk votes for the reading
or only the code does:
Curse table |
grade |
what votes for it |
|---|---|---|
saving-throw rows, |
CONFIRMED |
45 of 45 rows re-expanded, and 30 of 30 stored saves across six characters |
constitution hit points, |
CONFIRMED |
6 of 6 shipped |
turning level, |
CONFIRMED |
2 of 2 shipped |
thief skills, |
CONFIRMED |
8 of 8 columns on the one shipped thief |
experience clamp, |
CONFIRMED |
the same table the 78 thresholds came from |
wisdom bonus spells, |
CONFIRMED, and no record can ever agree |
the table read, plus the Players Handbook row; the bonus lands in RAM at |
racial saving-throw bonus, |
PROBABLE |
the bytecode alone – no dwarf, gnome or halfling Curse character exists to check it against |
hit die rolled twice, |
PROBABLE |
the bytecode alone; a roll leaves no trace in a record |
`TRAINER_MEASURED` still has one entry. A table being right is not the same
as a level-up being right: no Curse training has been driven and captured, and
two of Curse’s own steps – the hit-die divide and the hp_max divide – round
up at random against the remainder ($11AB), so what they do can only be
believed after being watched. That is #18’s step 3.
THAC0 is the game’s, not a transcription, and reading it caught an error
that had been in this file since it was written: a thief is THAC0 19 at
levels 5-8 and 16 at 9, not 18/18/18/16/16. The rows are
LDA $1F1F,X away from the instruction that uses them, they are AD&D 1st
edition exactly, and no specimen held a thief past level 4 to contradict the
old numbers. Magic-user and thief level 1 are 21, not the 20 the published
table this file came from gave – that correction is older and is what the
record’s own 60 - THAC0 at 0x071 first caught.
Curse computes the fighter group’s THAC0 rather than tabulating it:
LDA $7C98 / CLC / ADC #$27 / STA $7C71 is THAC0 = 21 - fighting level,
where 0x098 is the fighting level Curse fills and Pool of Radiance leaves at
zero. That reproduces Pool of Radiance’s own fighter row and extends it, so a
level-12 fighter needs 9. It is also why a paladin and a ranger need no THAC0
table of their own.
The saving-throw rule is the game’s own, read out of `GEN` – saving_throws
below implements it:
> A character’s five stored saves are the class-table row for its level, taking > the best number in each column across every class it holds, less the AD&D > constitution bonus when the character is a dwarf, gnome or halfling.
Pool of Radiance does not tabulate the rows. GEN $1F44 fills all five columns
with 20, then for each class subtracts, per column, the number of set bits in
the low level - 1 bits of two masks – $1FB6 and $1FCA – from the
level-1 row at $1FA2, keeping whichever class gives the lower number. The
rows written out below are that encoding expanded, and
tests/test_levels.py re-expands it off the player’s own GEN rather than
trusting the transcription. It is what settles the fighter’s level-4 breath
save at 15: the fighter’s fourth column carries mask $0C where the other
four carry $08, so that column improves twice by level 4 where the rest
improve once. AD&D 1st edition says 16 there; the game has always written 15.
78 of 79 distinct Pool of Radiance records satisfy that (docs/127), and every
Curse record on the player’s disks does too. The two games disagree on one
detail, which is why the columns are a per-title field: Pool of Radiance
subtracts the bonus from all five columns, Curse from poison, wands and spells
only – the three the Players Handbook actually names. MAGNUS, a dwarf
fighter with constitution 13, reads 11 12 13 14 14 in Pool of Radiance and
11 15 13 17 14 in Curse, off the same character.
Curse’s paladin saves are the fighter row less 2 and its ranger saves are the fighter row unchanged, both AD&D and both confirmed against SSI’s own pre-generated party at level 5.
Monk is gone from this file. It was here because the published tables list it,
but no C64 title in the family implements one, and a table nothing can produce
is a trap rather than documentation. Pool of Radiance offers no paladin or
ranger either and displays all three as MAGIC-USER, because class-name
pointer entries 13, 14 and 15 hold one string address – so those two rows live
under Curse, which does implement them.
Module Attributes
What the racial-limit tables write for "no limit". |
|
The one title whose trainer tables have been read byte for byte. |
|
The five columns, in stored order at record offset |
|
|
|
Pool of Radiance offers four classes and no paladin or ranger, so bits 6 and 7 name nothing. |
|
Curse zeroes the cleric column for dwarf, elf and gnome where Pool of Radiance carried 8, 7 and 7 -- those three are the Dungeon Master's Guide NPC limits and the Players Handbook has no such player clerics, so Curse is the stricter reading of the same rule rather than a different one. |
|
Race 3 is the dwarf in this title ( |
|
What a caller gets when it says nothing. |
|
The titles whose trainer has been read. |
|
Titles whose racial saving-throw bonus is confirmed, which is a narrower question than |
Functions
|
|
|
|
|
|
|
|
|
Hit points a level from constitution. |
|
+1 saving throw per 3.5 points of constitution. |
|
|
|
|
|
The tables for a title. |
|
How many sides the class rolls a level, or None for no such class. |
|
Experience needed for the level after this one. |
|
How far through the current level, 0.0 to 1.0. |
|
|
|
Is this title's racial saving-throw bonus confirmed in the game? |
|
|
|
|
|
|
|
Has this title's trainer been measured? None means the default title. |
|
|
|
Bonus cleric spells, by spell level. |
Classes
One row: what this level costs and what it confers. |
|
One title's progression, as data. |
- goldbox.levels.CURSE_OF_THE_AZURE_BONDS = LevelTables(key='curse-of-the-azure-bonds', title='Curse of the Azure Bonds', class_order=('magic-user', 'cleric', 'thief', 'fighter', None, None, 'paladin', 'ranger'), classes=(('magic-user', (Level(level=1, experience=0, hit_dice='1d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(1,)), Level(level=2, experience=2501, hit_dice='2d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2,)), Level(level=3, experience=5001, hit_dice='3d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2, 1)), Level(level=4, experience=10001, hit_dice='4d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(3, 2)), Level(level=5, experience=22501, hit_dice='5d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(4, 2, 1)), Level(level=6, experience=40001, hit_dice='6d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 2, 2)), Level(level=7, experience=60001, hit_dice='7d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 3, 2, 1)), Level(level=8, experience=90001, hit_dice='8d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 3, 3, 2)), Level(level=9, experience=135001, hit_dice='9d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 3, 3, 2, 1)), Level(level=10, experience=250001, hit_dice='10d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 4, 3, 2, 2)), Level(level=11, experience=375001, hit_dice='11d4', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=(4, 4, 4, 3, 3)))), ('cleric', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(1,)), Level(level=2, experience=1501, hit_dice='2d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2,)), Level(level=3, experience=3001, hit_dice='3d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2, 1)), Level(level=4, experience=6001, hit_dice='4d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 2)), Level(level=5, experience=13001, hit_dice='5d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 1)), Level(level=6, experience=27501, hit_dice='6d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 2)), Level(level=7, experience=55001, hit_dice='7d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=(3, 3, 2, 1)), Level(level=8, experience=110001, hit_dice='8d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=(3, 3, 3, 2)), Level(level=9, experience=225001, hit_dice='9d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=(4, 4, 3, 2, 1)), Level(level=10, experience=450001, hit_dice='9d8+2', thac0=14, attacks=1, saves=(6, 9, 10, 12, 11), spells=(4, 4, 3, 3, 2)))), ('thief', (Level(level=1, experience=0, hit_dice='1d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=2, experience=1251, hit_dice='2d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=3, experience=2501, hit_dice='3d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=4, experience=5001, hit_dice='4d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=5, experience=10001, hit_dice='5d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=6, experience=20001, hit_dice='6d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=7, experience=42501, hit_dice='7d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=8, experience=70001, hit_dice='8d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=9, experience=110001, hit_dice='9d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=10, experience=160001, hit_dice='10d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=11, experience=220001, hit_dice='10d6+2', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=12, experience=440001, hit_dice='10d6+4', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()))), ('fighter', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2001, hit_dice='2d10', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4001, hit_dice='3d10', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=8001, hit_dice='4d10', thac0=17, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=5, experience=18001, hit_dice='5d10', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=35001, hit_dice='6d10', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=70001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=125001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=9, experience=250001, hit_dice='9d10', thac0=12, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=10, experience=500001, hit_dice='9d10+3', thac0=11, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=11, experience=749937, hit_dice='9d10+6', thac0=10, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()), Level(level=12, experience=1000001, hit_dice='9d10+9', thac0=9, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()))), ('paladin', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(12, 13, 14, 15, 15), spells=()), Level(level=2, experience=2751, hit_dice='2d10', thac0=19, attacks=1, saves=(12, 13, 14, 15, 15), spells=()), Level(level=3, experience=5501, hit_dice='3d10', thac0=18, attacks=1, saves=(11, 12, 13, 14, 14), spells=()), Level(level=4, experience=12001, hit_dice='4d10', thac0=17, attacks=1, saves=(11, 12, 13, 14, 14), spells=()), Level(level=5, experience=24001, hit_dice='5d10', thac0=16, attacks=1, saves=(9, 10, 11, 11, 12), spells=()), Level(level=6, experience=45001, hit_dice='6d10', thac0=15, attacks=1, saves=(9, 10, 11, 11, 12), spells=()), Level(level=7, experience=95001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(8, 9, 10, 10, 11), spells=()), Level(level=8, experience=175001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(8, 9, 10, 10, 11), spells=()), Level(level=9, experience=350001, hit_dice='9d10', thac0=12, attacks=1.5, saves=(6, 7, 8, 7, 9), spells=()), Level(level=10, experience=700001, hit_dice='9d10+3', thac0=11, attacks=1.5, saves=(6, 7, 8, 7, 9), spells=()), Level(level=11, experience=1050001, hit_dice='9d10+6', thac0=10, attacks=1.5, saves=(5, 6, 7, 6, 8), spells=()))), ('ranger', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2250, hit_dice='2d8', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4501, hit_dice='3d8', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=10001, hit_dice='4d8', thac0=17, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=5, experience=20001, hit_dice='5d8', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=40001, hit_dice='6d8', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=90001, hit_dice='7d8', thac0=14, attacks=1, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=150001, hit_dice='8d8', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=9, experience=225001, hit_dice='9d8', thac0=12, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=10, experience=325001, hit_dice='10d8', thac0=11, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=11, experience=650001, hit_dice='10d8+2', thac0=10, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=())))), ceilings=(('magic-user', 11), ('cleric', 10), ('thief', 12), ('fighter', 12), ('paladin', 11), ('ranger', 11)), racial_limits=((1, (0, 0, 99, 9, 0, 0, 0, 0)), (2, (11, 0, 99, 7, 0, 0, 0, 0)), (3, (0, 0, 99, 6, 0, 0, 0, 0)), (4, (8, 5, 99, 8, 0, 0, 0, 8)), (5, (0, 0, 99, 6, 0, 0, 0, 0)), (6, (0, 4, 8, 10, 0, 0, 0, 0)), (7, (99, 99, 99, 99, 0, 0, 99, 99))), constitution_save_columns=(0, 2, 4), sturdy_races=(1, 3, 5), thief_skills=((30, 25, 20, 15, 10, 10, 85, 0), (35, 29, 25, 21, 15, 10, 86, 0), (40, 33, 30, 27, 20, 15, 87, 0), (45, 37, 35, 33, 25, 15, 88, 20), (50, 42, 40, 40, 31, 20, 90, 25), (55, 47, 45, 47, 37, 20, 92, 30), (60, 52, 50, 55, 43, 25, 94, 35), (65, 57, 55, 62, 49, 25, 96, 40), (70, 62, 60, 70, 56, 30, 98, 45)), thief_skill_race=((0, 10, 15, 0, 0, 0, -10, -5), (5, -5, 0, 5, 10, 5, 0, 0), (0, 5, 10, 5, 5, 10, -15, 0), (10, 0, 0, 0, 5, 0, 0, 0), (5, 5, 5, 10, 15, 5, -15, -5), (-5, 5, 5, 0, 0, 5, 5, -10), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0)), turn_power=(1, 2, 3, 5, 6, 7, 8, 9, 10, 10), clamp_thresholds=(('magic-user', 750001), ('cleric', 675001), ('thief', 660001), ('fighter', 1250001), ('paladin', 1400001), ('ranger', 975001)), thief_skill_dexterity=((-15, -10, -10, -20, -10, 0, 0, 0), (-10, -5, -10, -15, -5, 0, 0, 0), (-5, 0, -5, -10, 0, 0, 0, 0), (0, 0, 0, -5, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 5, 0, 0, 0, 0, 0, 0), (5, 10, 0, 5, 5, 0, 0, 0), (10, 15, 5, 10, 10, 0, 0, 0), (15, 20, 10, 12, 12, 0, 0, 0), (20, 25, 15, 15, 15, 0, 0, 0), (25, 30, 20, 18, 18, 0, 0, 0), (30, 35, 25, 20, 20, 0, 0, 0), (35, 40, 30, 23, 23, 0, 0, 0), (40, 45, 35, 25, 25, 0, 0, 0), (45, 50, 40, 30, 30, 0, 0, 0)), thief_skill_dexterity_from=9, hp_bonus_by_score=(0, -2, -2, -2, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5, 6, 6, 6, 7, 7), hp_bonus_score_cap=16, hp_bonus_uncapped_from=3, wisdom_bonus_level=(0, 0, 1, 1, 2, 3, 4), wisdom_bonus_from=13, paladin_turn_offset=2, hit_die_rolls=2, hit_die_fighter_floor=None, hit_die_divide_floor=0, attack_forms_overwritten=True, stores_spell_capacity=False, dos_thac0=())¶
Curse zeroes the cleric column for dwarf, elf and gnome where Pool of Radiance carried 8, 7 and 7 – those three are the Dungeon Master’s Guide NPC limits and the Players Handbook has no such player clerics, so Curse is the stricter reading of the same rule rather than a different one.
- goldbox.levels.DEFAULT = LevelTables(key='pool-of-radiance', title='Pool of Radiance', class_order=('magic-user', 'cleric', 'thief', 'fighter', None, None, None, None), classes=(('magic-user', (Level(level=1, experience=0, hit_dice='1d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(1,)), Level(level=2, experience=2501, hit_dice='2d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2,)), Level(level=3, experience=5001, hit_dice='3d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2, 1)), Level(level=4, experience=10001, hit_dice='4d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(3, 2)), Level(level=5, experience=22501, hit_dice='5d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(4, 2, 1)), Level(level=6, experience=40001, hit_dice='6d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 2, 2)))), ('cleric', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(1,)), Level(level=2, experience=1501, hit_dice='2d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2,)), Level(level=3, experience=3001, hit_dice='3d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2, 1)), Level(level=4, experience=6001, hit_dice='4d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 2)), Level(level=5, experience=13001, hit_dice='5d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 1)), Level(level=6, experience=27501, hit_dice='6d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 2)))), ('thief', (Level(level=1, experience=0, hit_dice='1d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=2, experience=1251, hit_dice='2d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=3, experience=2501, hit_dice='3d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=4, experience=5001, hit_dice='4d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=5, experience=10001, hit_dice='5d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=6, experience=20001, hit_dice='6d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=7, experience=42501, hit_dice='7d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=8, experience=70001, hit_dice='8d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=9, experience=110001, hit_dice='9d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()))), ('fighter', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2001, hit_dice='2d10', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4001, hit_dice='3d10', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=8001, hit_dice='4d10', thac0=17, attacks=1, saves=(13, 14, 15, 15, 16), spells=()), Level(level=5, experience=18001, hit_dice='5d10', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=35001, hit_dice='6d10', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=70001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=125001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=())))), ceilings=(('magic-user', 6), ('cleric', 6), ('thief', 9), ('fighter', 8)), racial_limits=((1, (0, 8, 99, 9)), (2, (11, 7, 99, 7)), (3, (0, 7, 99, 6)), (4, (8, 5, 99, 8)), (5, (0, 0, 99, 6)), (6, (0, 4, 8, 10)), (7, (99, 99, 99, 99))), constitution_save_columns=(0, 1, 2, 3, 4), sturdy_races=(1, 3, 5), thief_skills=((30, 25, 20, 15, 10, 10, 85, 0), (35, 29, 25, 21, 15, 10, 86, 0), (40, 33, 30, 27, 20, 15, 87, 0), (45, 37, 35, 33, 25, 15, 88, 20), (50, 42, 40, 40, 31, 20, 90, 25), (55, 47, 45, 47, 37, 20, 92, 30), (60, 52, 50, 55, 43, 25, 94, 35), (65, 57, 55, 62, 49, 25, 96, 40), (70, 62, 60, 70, 56, 30, 98, 45)), thief_skill_race=((0, 10, 15, 0, 0, 0, -10, -5), (5, -5, 0, 5, 10, 5, 0, 0), (0, 5, 10, 5, 5, -5, 0, 10), (0, 0, 0, 5, 0, 0, 0, 5), (5, 5, 10, 15, 5, -15, -5, -5), (5, 5, 0, 0, 5, 5, -10, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0)), turn_power=(1, 2, 3, 5, 6, 7, 8, 9, 10, 10, 10, 10, 10, 12), clamp_thresholds=(('magic-user', 60001), ('cleric', 55001), ('thief', 160001), ('fighter', 250001)), thief_skill_dexterity=(), thief_skill_dexterity_from=0, hp_bonus_by_score=(), hp_bonus_score_cap=0, hp_bonus_uncapped_from=0, wisdom_bonus_level=(), wisdom_bonus_from=0, paladin_turn_offset=None, hit_die_rolls=1, hit_die_fighter_floor=4, hit_die_divide_floor=1, attack_forms_overwritten=False, stores_spell_capacity=True, dos_thac0=(('magic-user', (20, 20, 20, 20, 20, 19, 19, 19, 19, 19)), ('cleric', (20, 20, 20, 18, 18, 18, 16, 16, 16, 14)), ('thief', (20, 20, 20, 20, 19, 19, 19, 19, 16, 16)), ('fighter', (20, 19, 18, 17, 16, 15, 14, 13, 12, 11))))¶
What a caller gets when it says nothing. Every caller predates the second game and means this one.
- goldbox.levels.HP_BONUS_SCORE_CAP_CURSE = 16¶
$126D CPX #$11 / LDX #$10, and the slot the test turns on.
- class goldbox.levels.Level[source]¶
Bases:
objectOne row: what this level costs and what it confers.
- __init__(level, experience, hit_dice, thac0, attacks, saves, spells=())¶
- property hp_max: int¶
The most hit points the dice can give.
Derived rather than stored. The column used to hold 10 a level for a cleric, 14 for a fighter and 6 for a magic-user and a thief, which matches no rule this project could name and which nothing checked; a number computed from
hit_dicecannot drift away from it.
- class goldbox.levels.LevelTables[source]¶
Bases:
objectOne title’s progression, as data.
Pairs rather than dicts so the descriptor stays hashable and frozen, which is the shape
goldbox/games.pysettled on for the same reason.class_orderis class-bit order – indexnis bitnofclass_bitsat0x0EBand slotnof the per-class level array at0x0C9– so it is the index the game’s own tables use, andNonemarks a bit this title has no class for.racial_limitsrows are in that order.- __init__(key, title, class_order, classes, ceilings, racial_limits, constitution_save_columns, sturdy_races=(1, 3, 5), thief_skills=(), thief_skill_race=(), turn_power=(), clamp_thresholds=(), thief_skill_dexterity=(), thief_skill_dexterity_from=0, hp_bonus_by_score=(), hp_bonus_score_cap=0, hp_bonus_uncapped_from=0, wisdom_bonus_level=(), wisdom_bonus_from=0, paladin_turn_offset=None, hit_die_rolls=1, hit_die_fighter_floor=4, hit_die_divide_floor=1, attack_forms_overwritten=False, stores_spell_capacity=True, dos_thac0=())¶
- Parameters:
key (str)
title (str)
classes (tuple[tuple[str, tuple[goldbox.levels.Level, ...]], ...])
thief_skill_dexterity_from (int)
hp_bonus_score_cap (int)
hp_bonus_uncapped_from (int)
wisdom_bonus_from (int)
paladin_turn_offset (int | None)
hit_die_rolls (int)
hit_die_fighter_floor (int | None)
hit_die_divide_floor (int)
attack_forms_overwritten (bool)
stores_spell_capacity (bool)
- Return type:
None
- at_level(class_name, level)[source]¶
- Parameters:
- Return type:
goldbox.levels.Level | None
- attack_forms_overwritten: bool = False¶
Whether the recompute writes
attack_formsoutright or only raises it. Pool of Radiance’s$2342refuses to lower (LDX #$03 / CPX $6BD9 / BCC skip) and never writes anything but 3; Curse’s$1909stores what it computed, 2 or 3, whatever was there before.
- base_thac0(class_levels)[source]¶
thac0_baseas this title’s C64 engine computes it, best of the classes – the mirror ofdos_base_thac0(), overself.classesrather thanself.dos_thac0.GEN $1EF3clears the byte, walks the per-class level array at0x0C9and keeps the best row; this is that recompute, and it is CONFIRMED for all three titles’ C64 sides (self.classesis read off each title’s ownGEN). A level past this title’s own table contributes nothing, the same floorsaving_throws()applies – a character cannot hold a level the game itself never lets him reach. None where no class has a level the table reaches, which is what the C64 engine leaves as the zero it started from.#366 (A converted magic-user or thief arrives with the other port's THAC0, because the two ports ship different tables and the conversion copies the byte): DOS’s magic-user rows 1-5 and thief rows 1-4 hold one worse than this table’s, so a value copied straight from a DOS source is the wrong port’s number.- Return type:
int | None
- clamp_threshold(class_name, level)[source]¶
What
GEN $23D4reads for a class at that level, ceiling included.
- clamp_thresholds: tuple[tuple[str, int], ...] = ()¶
What the trainer clamps experience to at the class ceiling. The game’s threshold arrays are nine wide a class and each class’s tenth entry falls in the next class’s unused slot 0, so
GEN $23D4reads a real number one past every ceiling: 60,001 for a magic-user 6, 55,001 for a cleric 6, 160,001 for a thief 9 and 250,001 for a fighter 8. Kept apart from the rows becausenext_thresholdmust stay None at the ceiling – an experience bar there has nothing to fill towards.Curse’s rows are thirteen wide and every class has a real entry one past its ceiling, so its six numbers come out of the same table the 78 thresholds did rather than out of a neighbouring class’s slot 0. The field is still needed:
at_levelstops at the ceiling, so nothing else in here can reach that thirteenth entry.
- constitution_hp_bonus(constitution, *, fighter=False, class_slot=None)[source]¶
Hit points a level from constitution, this title’s way.
class_slotis Curse’s selector andfighteris Pool of Radiance’s; see the module-level function of the same name for why they are not the same question.
- constitution_save_columns: tuple[int, ...]¶
Which of the five save columns the racial constitution bonus reaches.
- dos_base_thac0(class_levels)[source]¶
thac0_baseas the DOS engine computes it, best of the classes.The record stores
60 - THAC0; this returns the THAC0 itself. None where the title’s DOS table is unread or the character has no class with a level, which is whatGAME.OVR:0x1A659leaves as the zero it started from.- Return type:
int | None
- dos_thac0: tuple[tuple[str, tuple[int, ...]], ...] = ()¶
This title’s DOS THAC0 rows, where the DOS build does not ship the C64’s table – class name to THAC0 by level, index
level - 1. See_DOS_THAC0_POOLfor the two routines and the provenance. Empty means nobody has read this title’s DOS copy, and every caller treats that as “cannot answer” rather than as agreement with the C64.
- dos_thac0_at(class_name, level)[source]¶
What the DOS build’s own table gives one class at one level.
None where this title’s DOS table has not been read, so a caller that needs the DOS number can tell “we have not looked” from “it is the same as the C64’s”. Levels below 1 and above the row clamp to its ends, the way
mov al, [di+0x3C7C]does with whateverclass_levelsholds.
- flat_hit_points(class_name, level)[source]¶
What this level adds instead of rolling, or None if it rolls.
GEN $15F2 CMP $1626,X / BCC roll: pastroll_tothe class stops rolling and adds a flat number a level ($162E). Pool of Radiance caps every class below the level this starts at, so it is None there for every class and every level it has a row for.
- hit_dice_rolled(class_name, level)[source]¶
How many dice the class has rolled by
level–min(level, roll_to), which is the dice count_progressionwrote into the row.This is the same
roll_tothe trainer keeps atGEN $1282, and it is what stops a Curse constitution bonus counting past the level the dice stop at.
- hit_die_divide_floor: int = 1¶
What a divided roll is floored at. Pool of Radiance’s
$20A2 BNE / LDA #$01; Curse’s$11CCis a bareLDA $4C / RTS, so a Curse multi-class character can gain nothing from a die.
- hit_die_fighter_floor: int | None = 4¶
The floor a single-class fighter’s roll takes – Pool of Radiance’s
CMP #$04. None where the title has no floor of any kind, which is Curse:$15E1has noCMP #$04in its 61 bytes.
- hit_die_rolls: int = 1¶
How many hit dice the trainer rolls, keeping the best. Pool of Radiance rolls one (
$2037) and Curse rolls two ($15FC). Only a title inTRAINER_MEASUREDmay lean on this: an unread title keeps the default because something has to be the default, not because anybody looked.
- hp_bonus_by_score: tuple[int, ...] = ()¶
hit points a level from constitution, indexed by the raw score and signed. Empty means the title uses the two banded rows in
_HP_BONUS_FIGHTERand_HP_BONUS_OTHERfromHP_BONUS_FROMup, which is Pool of Radiance’s shape.- Type:
GEN $11D7
- hp_bonus_score_cap: int = 0¶
What a capped score is clamped to, and the first class slot that is not capped.
$126D CPY #$03 / BCS / CPX #$11 / BCC / LDX #$10.
- paladin_turn_offset: int | None = None¶
How many levels behind a cleric a paladin turns undead, or None where the title has no paladin at all. Curse’s
$113F SBC #$02.
- racial_limit(race, class_name)[source]¶
How far a race of that class may go.
UNLIMITEDis 99.None means the title says nothing – race 7 and above skip the check in both games, and so does a class this title does not implement. Zero means the race may not take the class at all.
- saving_throws(class_levels, race=0, constitution=0)[source]¶
The five saves a record should store, or None for no known class.
class_levelsmaps a class name to its level – the per-class array at0x0C9, not the single level byte at0x0A0, because a multi-class character takes the best column from each of its classes.
- stores_spell_capacity: bool = True¶
Whether
spells_castableat0x0EEis a field this title writes at all. False for Curse, where nothing inGEN,ECL64orECL65writes those six bytes and all six shipped characters hold zero –ECL65 $880Drebuilds the number in RAM whenever the sheet is drawn.
- sturdy_races: tuple[int, ...] = (1, 3, 5)¶
Race codes that take it at all. The default,
(1, 3, 5), is Pool of Radiance’s dwarf, gnome and halfling; Silver Blades overrides it to the dwarf alone ((3,)– race 3 there, not the gnome it is in Pool of Radiance). Derivable fromgames.Game.racesand would live better there; it is here because nothing else needs it yet and this module does not import that one.
- property tables: dict[str, tuple[goldbox.levels.Level, ...]]¶
- thief_skill_dexterity: tuple[tuple[int, ...], ...] = ()¶
GEN $10A4, added to the level row alongside the racial one. Empty where the title’s thief routine reads no ability score at all, which is Pool of Radiance ($1FEC). A title that has this table cannot answer without a dexterity andthief_skill_rowreturns None rather than reading row 0, which would be the adjustment for a dexterity of 9.
- thief_skill_row(level, race, dexterity=0)[source]¶
The eight percentages a thief of that level and race stores.
Pool of Radiance’s rule is the level row plus the racial row and nothing else –
GEN $1FECwrites one, adds the other, and reads no ability score. Curse adds a dexterity row between them ($0FAD), so a title withthief_skill_dexterityreturns None when it is not given one rather than silently reading the row for a dexterity of 9.Sums are left as they come out, including the negative ones: a dwarf thief’s read-languages column is 0 - 5, and -5 is the byte the game’s own
ADCleaves behind as$FB.
- thief_skills: tuple[tuple[int, ...], ...] = ()¶
The trainer’s own tables, read out of the title’s
GEN. Empty means nobody has read this title’s copy, and every caller treats that as “cannot answer” rather than as a zero.
- turning_level(cleric_level, paladin_level=0)[source]¶
What
0x0A4holds, or None where this title has no answer.Pool of Radiance never writes the byte for a non-cleric:
GEN $2388isLDX level_cleric / BEQstraight to theRTS, so None here means “leave it alone” rather than “zero”.Curse writes it for everybody,
$113Fending in an unconditionalSTA $7CA4, and a paladin turns as a clericpaladin_turn_offsetlevels weaker – so a title with that offset answers 0 for a character who turns nothing, which is the byte the game stores.
- wisdom_bonus_level: tuple[int, ...] = ()¶
which spell level each point of wisdom from
wisdom_bonus_fromup buys a cleric. Empty means the title uses Pool of Radiance’s$10ADarithmetic inwisdom_bonus_spells.- Type:
ECL65 $8906
- wisdom_bonus_spells(wisdom)[source]¶
Bonus cleric spells by spell level, for a title that tabulates it.
One spell for every point of wisdom from
wisdom_bonus_fromup, at the levelwisdom_bonus_levelnames for that point –ECL65 $88F6, which loopsDEY / CPY #$0D / BCS. Returns as many spell levels as the table can name.
- goldbox.levels.POOL_KEY = 'pool-of-radiance'¶
The one title whose trainer tables have been read byte for byte.
- goldbox.levels.POOL_OF_RADIANCE = LevelTables(key='pool-of-radiance', title='Pool of Radiance', class_order=('magic-user', 'cleric', 'thief', 'fighter', None, None, None, None), classes=(('magic-user', (Level(level=1, experience=0, hit_dice='1d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(1,)), Level(level=2, experience=2501, hit_dice='2d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2,)), Level(level=3, experience=5001, hit_dice='3d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(2, 1)), Level(level=4, experience=10001, hit_dice='4d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(3, 2)), Level(level=5, experience=22501, hit_dice='5d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=(4, 2, 1)), Level(level=6, experience=40001, hit_dice='6d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=(4, 2, 2)))), ('cleric', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(1,)), Level(level=2, experience=1501, hit_dice='2d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2,)), Level(level=3, experience=3001, hit_dice='3d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=(2, 1)), Level(level=4, experience=6001, hit_dice='4d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 2)), Level(level=5, experience=13001, hit_dice='5d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 1)), Level(level=6, experience=27501, hit_dice='6d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=(3, 3, 2)))), ('thief', (Level(level=1, experience=0, hit_dice='1d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=2, experience=1251, hit_dice='2d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=3, experience=2501, hit_dice='3d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=4, experience=5001, hit_dice='4d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=5, experience=10001, hit_dice='5d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=6, experience=20001, hit_dice='6d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=7, experience=42501, hit_dice='7d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=8, experience=70001, hit_dice='8d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=9, experience=110001, hit_dice='9d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()))), ('fighter', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2001, hit_dice='2d10', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4001, hit_dice='3d10', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=8001, hit_dice='4d10', thac0=17, attacks=1, saves=(13, 14, 15, 15, 16), spells=()), Level(level=5, experience=18001, hit_dice='5d10', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=35001, hit_dice='6d10', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=70001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=125001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=())))), ceilings=(('magic-user', 6), ('cleric', 6), ('thief', 9), ('fighter', 8)), racial_limits=((1, (0, 8, 99, 9)), (2, (11, 7, 99, 7)), (3, (0, 7, 99, 6)), (4, (8, 5, 99, 8)), (5, (0, 0, 99, 6)), (6, (0, 4, 8, 10)), (7, (99, 99, 99, 99))), constitution_save_columns=(0, 1, 2, 3, 4), sturdy_races=(1, 3, 5), thief_skills=((30, 25, 20, 15, 10, 10, 85, 0), (35, 29, 25, 21, 15, 10, 86, 0), (40, 33, 30, 27, 20, 15, 87, 0), (45, 37, 35, 33, 25, 15, 88, 20), (50, 42, 40, 40, 31, 20, 90, 25), (55, 47, 45, 47, 37, 20, 92, 30), (60, 52, 50, 55, 43, 25, 94, 35), (65, 57, 55, 62, 49, 25, 96, 40), (70, 62, 60, 70, 56, 30, 98, 45)), thief_skill_race=((0, 10, 15, 0, 0, 0, -10, -5), (5, -5, 0, 5, 10, 5, 0, 0), (0, 5, 10, 5, 5, -5, 0, 10), (0, 0, 0, 5, 0, 0, 0, 5), (5, 5, 10, 15, 5, -15, -5, -5), (5, 5, 0, 0, 5, 5, -10, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0)), turn_power=(1, 2, 3, 5, 6, 7, 8, 9, 10, 10, 10, 10, 10, 12), clamp_thresholds=(('magic-user', 60001), ('cleric', 55001), ('thief', 160001), ('fighter', 250001)), thief_skill_dexterity=(), thief_skill_dexterity_from=0, hp_bonus_by_score=(), hp_bonus_score_cap=0, hp_bonus_uncapped_from=0, wisdom_bonus_level=(), wisdom_bonus_from=0, paladin_turn_offset=None, hit_die_rolls=1, hit_die_fighter_floor=4, hit_die_divide_floor=1, attack_forms_overwritten=False, stores_spell_capacity=True, dos_thac0=(('magic-user', (20, 20, 20, 20, 20, 19, 19, 19, 19, 19)), ('cleric', (20, 20, 20, 18, 18, 18, 16, 16, 16, 14)), ('thief', (20, 20, 20, 20, 19, 19, 19, 19, 16, 16)), ('fighter', (20, 19, 18, 17, 16, 15, 14, 13, 12, 11))))¶
Pool of Radiance offers four classes and no paladin or ranger, so bits 6 and 7 name nothing. Its racial rows are four bytes wide, which is why they stop at fighter.
- goldbox.levels.RACIAL_SAVE_BONUS_MEASURED: frozenset[str] = frozenset({'curse-of-the-azure-bonds', 'pool-of-radiance', 'secret-of-the-silver-blades'})¶
Titles whose racial saving-throw bonus is confirmed, which is a narrower question than
TRAINER_MEASUREDand the only onegoldbox/c64_codec.pyneeds when it recomputes the five save columns for a converted character (#311).Pool of Radiance’s whole trainer is measured. Curse’s
$0F19was graded PROBABLE while the module docstring said “no dwarf, gnome or halfling Curse character exists to check it against” – TRAVIS refuted that on 2026-09-04, and five driven Curse level-ups on 2026-09-05 agreed with the engine on 75 of 75 derived fields including the five saving throws (#18 (Measure Curse's trainer so Level Up works there),tools/cursetrain.py). So Curse belongs here even though it is not yet inTRAINER_MEASURED, whose own condition covers more than this.Silver Blades’
$11D8was watched on 2026-09-06 (#344 (A converted Silver Blades dwarf, gnome or halfling keeps DOS's saving throws, because that title's racial bonus has never been watched in the game),tools/ssbtrain.py): MALACHITE, thief 8 / fighter 7 with constitution 17, was trained to thief 9 five times on one boot with only the race byte changed between presses, and the engine wrote6 10 6 12 7for the dwarf (twice) and10 10 10 12 11for the gnome, the halfling and the human – 25 of 25 columns whatsaving_throwscomputes. So the reading that only the dwarf takes the bonus in this title is measured, not read, andtests/test_ssblevels.pypins the five rows.
- goldbox.levels.SAVE_COLUMNS = ('paralysis/poison/death', 'petrification/polymorph', 'rod/staff/wand', 'breath weapon', 'spell')¶
The five columns, in stored order at record offset
0x09A.
- goldbox.levels.SECRET_OF_THE_SILVER_BLADES = LevelTables(key='secret-of-the-silver-blades', title='Secret of the Silver Blades', class_order=('magic-user', 'cleric', 'thief', 'fighter', None, None, 'paladin', 'ranger'), classes=(('magic-user', (Level(level=1, experience=0, hit_dice='1d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=()), Level(level=2, experience=2501, hit_dice='2d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=()), Level(level=3, experience=5001, hit_dice='3d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=()), Level(level=4, experience=10001, hit_dice='4d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=()), Level(level=5, experience=22501, hit_dice='5d4', thac0=21, attacks=1, saves=(14, 13, 11, 15, 12), spells=()), Level(level=6, experience=40001, hit_dice='6d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=()), Level(level=7, experience=60001, hit_dice='7d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=()), Level(level=8, experience=90001, hit_dice='8d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=()), Level(level=9, experience=135001, hit_dice='9d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=()), Level(level=10, experience=250001, hit_dice='10d4', thac0=19, attacks=1, saves=(13, 11, 9, 13, 10), spells=()), Level(level=11, experience=375001, hit_dice='11d4', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=()), Level(level=12, experience=750001, hit_dice='11d4+1', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=()), Level(level=13, experience=1125001, hit_dice='11d4+2', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=()), Level(level=14, experience=1500001, hit_dice='11d4+3', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=()), Level(level=15, experience=1875001, hit_dice='11d4+4', thac0=16, attacks=1, saves=(11, 9, 7, 11, 8), spells=()))), ('cleric', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=()), Level(level=2, experience=1501, hit_dice='2d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=()), Level(level=3, experience=3001, hit_dice='3d8', thac0=20, attacks=1, saves=(10, 13, 14, 16, 15), spells=()), Level(level=4, experience=6001, hit_dice='4d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=()), Level(level=5, experience=13001, hit_dice='5d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=()), Level(level=6, experience=27501, hit_dice='6d8', thac0=18, attacks=1, saves=(9, 12, 13, 15, 14), spells=()), Level(level=7, experience=55001, hit_dice='7d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=()), Level(level=8, experience=110001, hit_dice='8d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=()), Level(level=9, experience=225001, hit_dice='9d8', thac0=16, attacks=1, saves=(7, 10, 11, 13, 12), spells=()), Level(level=10, experience=450001, hit_dice='9d8+2', thac0=14, attacks=1, saves=(6, 9, 10, 12, 11), spells=()), Level(level=11, experience=675001, hit_dice='9d8+4', thac0=14, attacks=1, saves=(6, 9, 10, 12, 11), spells=()), Level(level=12, experience=900001, hit_dice='9d8+6', thac0=14, attacks=1, saves=(6, 9, 10, 12, 11), spells=()), Level(level=13, experience=1125001, hit_dice='9d8+8', thac0=12, attacks=1, saves=(5, 8, 9, 11, 10), spells=()), Level(level=14, experience=1350001, hit_dice='9d8+10', thac0=12, attacks=1, saves=(5, 8, 9, 11, 10), spells=()), Level(level=15, experience=1575001, hit_dice='9d8+12', thac0=12, attacks=1, saves=(5, 8, 9, 11, 10), spells=()))), ('thief', (Level(level=1, experience=0, hit_dice='1d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=2, experience=1251, hit_dice='2d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=3, experience=2501, hit_dice='3d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=4, experience=5001, hit_dice='4d6', thac0=21, attacks=1, saves=(13, 12, 14, 16, 15), spells=()), Level(level=5, experience=10001, hit_dice='5d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=6, experience=20001, hit_dice='6d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=7, experience=42501, hit_dice='7d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=8, experience=70001, hit_dice='8d6', thac0=19, attacks=1, saves=(12, 11, 12, 15, 13), spells=()), Level(level=9, experience=110001, hit_dice='9d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=10, experience=160001, hit_dice='10d6', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=11, experience=220001, hit_dice='10d6+2', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=12, experience=440001, hit_dice='10d6+4', thac0=16, attacks=1, saves=(11, 10, 10, 14, 11), spells=()), Level(level=13, experience=660001, hit_dice='10d6+6', thac0=14, attacks=1, saves=(10, 9, 8, 13, 9), spells=()), Level(level=14, experience=880001, hit_dice='10d6+8', thac0=14, attacks=1, saves=(10, 9, 8, 13, 9), spells=()), Level(level=15, experience=1100001, hit_dice='10d6+10', thac0=14, attacks=1, saves=(10, 9, 8, 13, 9), spells=()), Level(level=16, experience=1320001, hit_dice='10d6+12', thac0=14, attacks=1, saves=(10, 9, 8, 13, 9), spells=()), Level(level=17, experience=1540001, hit_dice='10d6+14', thac0=12, attacks=1, saves=(9, 8, 6, 12, 7), spells=()), Level(level=18, experience=1760001, hit_dice='10d6+16', thac0=12, attacks=1, saves=(9, 8, 6, 12, 7), spells=()))), ('fighter', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2001, hit_dice='2d10', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4001, hit_dice='3d10', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=8001, hit_dice='4d10', thac0=17, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=5, experience=18001, hit_dice='5d10', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=35001, hit_dice='6d10', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=70001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=125001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=9, experience=250001, hit_dice='9d10', thac0=12, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=10, experience=500001, hit_dice='9d10+3', thac0=11, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=11, experience=749937, hit_dice='9d10+6', thac0=10, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()), Level(level=12, experience=1000001, hit_dice='9d10+9', thac0=9, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()), Level(level=13, experience=1250001, hit_dice='9d10+12', thac0=8, attacks=2, saves=(5, 6, 7, 5, 8), spells=()), Level(level=14, experience=1500001, hit_dice='9d10+15', thac0=7, attacks=2, saves=(5, 6, 7, 5, 8), spells=()), Level(level=15, experience=1750001, hit_dice='9d10+18', thac0=6, attacks=2, saves=(4, 5, 6, 4, 7), spells=()))), ('paladin', (Level(level=1, experience=0, hit_dice='1d10', thac0=20, attacks=1, saves=(12, 13, 14, 15, 15), spells=()), Level(level=2, experience=2751, hit_dice='2d10', thac0=19, attacks=1, saves=(12, 13, 14, 15, 15), spells=()), Level(level=3, experience=5501, hit_dice='3d10', thac0=18, attacks=1, saves=(11, 12, 13, 14, 14), spells=()), Level(level=4, experience=12001, hit_dice='4d10', thac0=17, attacks=1, saves=(11, 12, 13, 14, 14), spells=()), Level(level=5, experience=24001, hit_dice='5d10', thac0=16, attacks=1, saves=(9, 10, 11, 11, 12), spells=()), Level(level=6, experience=45001, hit_dice='6d10', thac0=15, attacks=1, saves=(9, 10, 11, 11, 12), spells=()), Level(level=7, experience=95001, hit_dice='7d10', thac0=14, attacks=1.5, saves=(8, 9, 10, 10, 11), spells=()), Level(level=8, experience=175001, hit_dice='8d10', thac0=13, attacks=1.5, saves=(8, 9, 10, 10, 11), spells=()), Level(level=9, experience=350001, hit_dice='9d10', thac0=12, attacks=1.5, saves=(6, 7, 8, 7, 9), spells=()), Level(level=10, experience=700001, hit_dice='9d10+3', thac0=11, attacks=1.5, saves=(6, 7, 8, 7, 9), spells=()), Level(level=11, experience=1050001, hit_dice='9d10+6', thac0=10, attacks=1.5, saves=(5, 6, 7, 6, 8), spells=()), Level(level=12, experience=1400001, hit_dice='9d10+9', thac0=9, attacks=1.5, saves=(5, 6, 7, 6, 8), spells=()), Level(level=13, experience=1750001, hit_dice='9d10+12', thac0=8, attacks=2, saves=(3, 4, 5, 3, 6), spells=()), Level(level=14, experience=2100001, hit_dice='9d10+15', thac0=7, attacks=2, saves=(3, 4, 5, 3, 6), spells=()), Level(level=15, experience=2450001, hit_dice='9d10+18', thac0=6, attacks=2, saves=(2, 3, 4, 2, 5), spells=()))), ('ranger', (Level(level=1, experience=0, hit_dice='1d8', thac0=20, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=2, experience=2250, hit_dice='2d8', thac0=19, attacks=1, saves=(14, 15, 16, 17, 17), spells=()), Level(level=3, experience=4501, hit_dice='3d8', thac0=18, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=4, experience=10001, hit_dice='4d8', thac0=17, attacks=1, saves=(13, 14, 15, 16, 16), spells=()), Level(level=5, experience=20001, hit_dice='5d8', thac0=16, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=6, experience=40001, hit_dice='6d8', thac0=15, attacks=1, saves=(11, 12, 13, 13, 14), spells=()), Level(level=7, experience=90001, hit_dice='7d8', thac0=14, attacks=1, saves=(10, 11, 12, 12, 13), spells=()), Level(level=8, experience=150001, hit_dice='8d8', thac0=13, attacks=1.5, saves=(10, 11, 12, 12, 13), spells=()), Level(level=9, experience=225001, hit_dice='9d8', thac0=12, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=10, experience=325001, hit_dice='10d8', thac0=11, attacks=1.5, saves=(8, 9, 10, 9, 11), spells=()), Level(level=11, experience=650001, hit_dice='10d8+2', thac0=10, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()), Level(level=12, experience=975001, hit_dice='10d8+4', thac0=9, attacks=1.5, saves=(7, 8, 9, 8, 10), spells=()), Level(level=13, experience=1300001, hit_dice='10d8+6', thac0=8, attacks=1.5, saves=(5, 6, 7, 5, 8), spells=()), Level(level=14, experience=1625001, hit_dice='10d8+8', thac0=7, attacks=1.5, saves=(5, 6, 7, 5, 8), spells=()), Level(level=15, experience=1950001, hit_dice='10d8+10', thac0=6, attacks=2, saves=(4, 5, 6, 4, 7), spells=())))), ceilings=(('magic-user', 15), ('cleric', 15), ('thief', 18), ('fighter', 15), ('paladin', 15), ('ranger', 15)), racial_limits=((1, (11, 0, 99, 7, 0, 0, 0, 0)), (2, (8, 5, 99, 8, 0, 0, 0, 8)), (3, (0, 0, 99, 9, 0, 0, 0, 0)), (4, (0, 0, 99, 6, 0, 0, 0, 0)), (5, (0, 0, 99, 6, 0, 0, 0, 0)), (6, (99, 99, 99, 99, 0, 0, 99, 99))), constitution_save_columns=(0, 2, 4), sturdy_races=(3,), thief_skills=(), thief_skill_race=(), turn_power=(1, 2, 3, 5, 6, 7, 8, 9, 10, 10, 10, 10, 10, 12), clamp_thresholds=(), thief_skill_dexterity=(), thief_skill_dexterity_from=0, hp_bonus_by_score=(), hp_bonus_score_cap=0, hp_bonus_uncapped_from=0, wisdom_bonus_level=(), wisdom_bonus_from=0, paladin_turn_offset=2, hit_die_rolls=1, hit_die_fighter_floor=4, hit_die_divide_floor=1, attack_forms_overwritten=False, stores_spell_capacity=True, dos_thac0=())¶
Race 3 is the dwarf in this title (
games.RACES_SILVER_BLADES), not the gnome it is in Pool of Radiance. Row 6, the human, is not on disk –$178Arefuses to look one up for race 6 or above, which is “no limit” – and is synthesised the same way Curse’s row 7 is.
- goldbox.levels.TRAINER_MEASURED: frozenset[str] = frozenset({'pool-of-radiance'})¶
The titles whose trainer has been read. A stricter claim than having a table, and the distinction is the whole of issue #16: Curse’s level tables are in this module, and they are still not enough to level a Curse character. Everything around them was read at Pool of Radiance’s addresses out of Pool of Radiance’s
GEN– the hit-die roll at$2037, the saving-throw masks at$1F44, the constitution tables at$247B/$2486, the spell capacity at$20BC.Curse’s own copies have now been located, read and written into this module (#18,
tests/test_cursetrainer.pyandtests/test_curselevels.py), andgoldbox/levelup.pyhas been taught every rule of Curse’s that is not Pool of Radiance’s. The key is still not here, for one reason: no Curse training has been driven and captured. Every number above was read off a file or reproduced on a character SSI shipped, and two of the trainer’s own steps cannot be reproduced that way at all –$11ABdivides both the hit-die roll and the constitution total by the class count and rounds up at random against the remainder, so a multi-class Curse level-up has no single right answer to check. That is#18’s step 3, and it is what would put this key here.Two of the readings behind it are PROBABLE rather than CONFIRMED – the racial saving-throw bonus at
$0F19and the double hit-die roll at$15FC– because the bytecode is the whole of their evidence. The module docstring’s grade table says which, and why each is where it is.Silver Blades is the same case: its level tables are in this module now (#187), and its trainer’s own inputs – the constitution hit-point bonus, thief-skill racial adjustment, wisdom bonus spells – are either unread or unattributed (
docs/121-silver-blades.md). Its turning table is read, atGEN $13A5(#288), and it is CONFIRMED: the routine’s own expansion agrees with the two shipped records that store the byte, DOMINIC a cleric 8 at 9 and GUY DE VALOIS a paladin 8 at 7. Reading the other three is what would move this title into the set; the turning table alone does not.for_gamedeliberately falls back to Pool of Radiance for a title it has no tables for, which is right for reading a spell name and wrong for writing a character record. A writer asks this instead.
- goldbox.levels.UNLIMITED = 99¶
What the racial-limit tables write for “no limit”.
- goldbox.levels.at_level(class_name, level, game=None)[source]¶
- Parameters:
- Return type:
goldbox.levels.Level | None
- goldbox.levels.constitution_hp_bonus(constitution, fighter=False, game=None, class_slot=None)[source]¶
Hit points a level from constitution.
GEN $2471, or Curse’s$126D.The default is Pool of Radiance’s, the AD&D 1st edition pair of banded rows unchanged, including the cap of +2 for anybody who is not a fighter, and every caller written before there was a second title means that one.
Curse’s is one signed row and no floor –
_HP_BONUS_CURSEsays how they differ.class_slotis Curse’s own selector:$126Dclamps the score for slots 0-2 and reads the whole row for 3 and up, which is not the same question asfighter, because a fighter/magic-user’s magic-user slot is capped and its fighter slot is not. Pass one when summing a Curse character’s slots;fighterstill answers for a caller that has only the class bits, and picks the capped reading for a non-fighter.
- goldbox.levels.constitution_save_bonus(constitution)[source]¶
+1 saving throw per 3.5 points of constitution.
GEN $2359.Not a band table and not a transcription: the game divides
constitution * 2by 7 and subtracts the quotient, which is this expression exactly. Only the races flagged atGEN $2380– dwarf, gnome and halfling – take it. MAGNUS, a dwarf with constitution 13, reads three lower than the human SILAS on all five columns at every level, and26 // 7is 3.
- goldbox.levels.for_game(game=None)[source]¶
The tables for a title.
Takes a
goldbox.games.Game, a game key, aLevelTables, or None. Deliberately duck-typed on.keyrather than importinggoldbox.games: this module needs one string from that one, and a title it has no tables for falls back to Pool of Radiance rather than raising, because every geometry-only title ingoldbox/games.pyruns an engine whose progression has not been read.- Return type:
- goldbox.levels.hit_die(class_name, game=None)[source]¶
How many sides the class rolls a level, or None for no such class.
- goldbox.levels.next_threshold(class_name, level, game=None)[source]¶
Experience needed for the level after this one.
None at the class’s ceiling – Pool of Radiance stops a fighter at 8 and a cleric at 6, so there genuinely is no next threshold, and an experience bar should say “maximum” rather than draw an empty one.
- goldbox.levels.progress(class_name, level, experience, game=None)[source]¶
How far through the current level, 0.0 to 1.0. None at the ceiling.
- goldbox.levels.racial_save_bonus_measured(game=None)[source]¶
Is this title’s racial saving-throw bonus confirmed in the game?
- Return type:
- goldbox.levels.trainer_measured(game=None)[source]¶
Has this title’s trainer been measured? None means the default title.
- Return type:
- goldbox.levels.wisdom_bonus_spells(wisdom, game=None)[source]¶
Bonus cleric spells, by spell level.
GEN $2108, or Curse’s$88F6.Three numbers for Pool of Radiance and five for Curse, because the width is how many spell levels the title’s cleric rows reach. A caller that passes no title gets Pool of Radiance’s three, which is what every caller written before there was a second title means.
Curse’s is a different table and a different rule –
ECL65 $88F6loops once for every point of wisdom from 13 up, each point buying one spell at the level$8906names – and the one visible difference is at wisdom 12, where Pool of Radiance grants a spell AD&D does not and Curse does not (docs/125-bug-notes.md).The game’s table starts one point low. AD&D 1st edition gives the first bonus spell at wisdom 13 and the second at 14;
$10ADholds 1 at 12 and 2 from 13 up, so a wisdom-12 cleric memorises a first-level spell the rules do not give it. The second- and third-level columns are AD&D exactly – they are gated onCPY #$0F,#$10and#$11. Seedocs/125-bug-notes.md.The bonus is only granted where the class table already gives a slot at that spell level (
GEN $210Askips a zero), so a level-1 cleric gets no second-level spell however wise it is. That last clause is Curse’s too.