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

GEN $1DB5, parallel low/mid/high arrays, 9 wide

GEN $136E, 6 rows x 13 entries x 3 bytes big-endian

GEN $162D, 6 rows x 19 entries x 3 bytes big-endian, row stride 0x39

class ceiling

GEN $1E5C, 8 bytes in class-bit order

GEN $15A1, same shape

GEN $17D0, same shape

racial class limit

GEN $1E60, 4 bytes a race

GEN $15A9, 8 bytes a race

GEN $17E0, 8 bytes a race, races 1-5 only ($178A refuses 6+)

THAC0

GEN $1F1F, 4 rows x 9, LDA $1F1F,X with X = class * 9 + level

GEN $0E2C/$0E39/$0E46, 13 wide, indexed by level; the fighter group is arithmetic instead

GEN $106F/$107F/$108F, packed (not strided) rows of ceiling + 1; the fighter group is 21 - level at $1045, the same rule as Curse

hit dice

– (no class reaches the flat-hit-point rule)

GEN $161E die, $1626 first flat level, $162E flat amount

GEN $1845 die, $184D first flat level, $1855 flat amount

spell slots

GEN $222C cleric then $224C magic-user, 8 rows x 4

ECL65 $888D magic-user 11 rows then $88C4 cleric 10, x 5 – that overlay runs at $8000, so those are payload 0x88D and 0x8C4

not read (trainer input, #89)

saving throws

GEN $1FA2 level-1 row then two per-column bitmasks at $1FB6 and $1FCA

GEN $0F49 level-1 rows, $0F5D a four-byte two-bit-a-level improvement mask a column, $0F01 the paladin’s -2 (code)

GEN $1148 level-1 rows, $115C a five-byte two-bit-a-level improvement mask a column, $11C0 the paladin’s -2 (code), $11D8 the dwarf-only constitution bonus (code)

racial save bonus

GEN $2359, CON * 2 / 7 for the races flagged at $2380

GEN $0F19, same formula, races 1, 3 and 5 (CMP #$06 then AND #$01), columns 0, 2 and 4 only (DEX / DEX)

GEN $11D8, same formula, dwarf (race 3) alone

thief skills

GEN $102E, 9 rows of 8, plus a racial row at $1076

GEN $1004, 9 rows of 8, plus a dexterity row at `$10A4` (17 rows, max(0, DEX - 9)) and a racial row at $1064 whose gnome, half-elf, halfling and half-orc rows are not Pool of Radiance’s

GEN $126D, 17 rows of 8; the level clamps to 17 at $1213. Read, not attributed – see thief_skills below

hit die

GEN $20A7, 4 bytes in class-bit order

GEN $161E

GEN $1845

constitution hit-point bonus

GEN $247B fighter, $2486 everyone else, indexed by the score, consulted from 15

GEN $11D7, one row indexed by the score with no floor, signed; $126D caps a non-fighter’s score at 16 instead of keeping a second row

GEN $0E80, indexed by the score; not read into this module (trainer input)

wisdom bonus spells

GEN $10AD, indexed by the score

ECL65 $8906 (payload 0x906), the spell level each point of wisdom from 13 up buys; the loop is $88F6

not read (#89)

turning level

GEN $2399, indexed by cleric level

GEN $113F, arithmetic rather than a table: max(cleric, paladin - 2), + 1 from 4 up, capped at 10 – the same ten numbers

GEN $13A5, Curse’s arithmetic with a tail: + 1 from 4 up, 10 from 10 to 14, and 12 from 15 – which is Pool of Radiance’s fourteen numbers exactly

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) * bonus summed over the slots, one extra bonus for a ranger, then divided by how many classes the character has – against hp_rolled + level * bonus here. It disagrees with three of the six characters SSI shipped;

  • thief skills read dexterity ($0FC6), and thief_skill_row has no term for it;

  • spell capacity is never stored. Nothing in GEN, ECL64 or ECL65 writes 0x0EE-0x0F3; ECL65 $880D rebuilds 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, $0F49 + $0F5D

CONFIRMED

45 of 45 rows re-expanded, and 30 of 30 stored saves across six characters

constitution hit points, $11D7

CONFIRMED

6 of 6 shipped hp_max

turning level, $113F

CONFIRMED

2 of 2 shipped turn_power, and the same ten numbers Pool of Radiance tabulates

thief skills, $1004/$10A4/$1064

CONFIRMED

8 of 8 columns on the one shipped thief

experience clamp, $136E entry 13

CONFIRMED

the same table the 78 thresholds came from

wisdom bonus spells, ECL65 $8906

CONFIRMED, and no record can ever agree

the table read, plus the Players Handbook row; the bonus lands in RAM at $2BBB and is never stored

racial saving-throw bonus, $0F19

PROBABLE

the bytecode alone – no dwarf, gnome or halfling Curse character exists to check it against

hit die rolled twice, $15FC

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

UNLIMITED

What the racial-limit tables write for "no limit".

POOL_KEY

The one title whose trainer tables have been read byte for byte.

SAVE_COLUMNS

The five columns, in stored order at record offset 0x09A.

HP_BONUS_SCORE_CAP_CURSE

$126D CPX #$11 / LDX #$10, and the slot the test turns on.

POOL_OF_RADIANCE

Pool of Radiance offers four classes and no paladin or ranger, so bits 6 and 7 name nothing.

CURSE_OF_THE_AZURE_BONDS

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.

SECRET_OF_THE_SILVER_BLADES

Race 3 is the dwarf in this title (games.RACES_SILVER_BLADES), not the gnome it is in Pool of Radiance.

DEFAULT

What a caller gets when it says nothing.

TRAINER_MEASURED

The titles whose trainer has been read.

RACIAL_SAVE_BONUS_MEASURED

Titles whose racial saving-throw bonus is confirmed, which is a narrower question than TRAINER_MEASURED and the only one goldbox/c64_codec.py needs when it recomputes the five save columns for a converted character (#311).

Functions

at_level(class_name, level[, game])

base_thac0(class_levels[, game])

ceiling(class_name[, game])

clamp_threshold(class_name, level[, game])

constitution_hp_bonus(constitution[, ...])

Hit points a level from constitution.

constitution_save_bonus(constitution)

+1 saving throw per 3.5 points of constitution.

dos_base_thac0(class_levels[, game])

dos_thac0_at(class_name, level[, game])

for_game([game])

The tables for a title.

hit_die(class_name[, game])

How many sides the class rolls a level, or None for no such class.

next_threshold(class_name, level[, game])

Experience needed for the level after this one.

progress(class_name, level, experience[, game])

How far through the current level, 0.0 to 1.0.

racial_limit(race, class_name[, game])

racial_save_bonus_measured([game])

Is this title's racial saving-throw bonus confirmed in the game?

saving_throws(class_levels[, race, ...])

table(class_name[, game])

thief_skills(level, race[, game, dexterity])

trainer_measured([game])

Has this title's trainer been measured? None means the default title.

turning_level(cleric_level[, game, ...])

wisdom_bonus_spells(wisdom[, game])

Bonus cleric spells, by spell level.

Classes

Level

One row: what this level costs and what it confers.

LevelTables

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: object

One row: what this level costs and what it confers.

__init__(level, experience, hit_dice, thac0, attacks, saves, spells=())
Parameters:
Return type:

None

attacks: float
experience: int
hit_dice: str
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_dice cannot drift away from it.

level: int
saves: tuple[int, int, int, int, int]
spells: tuple[int, ...] = ()
thac0: int
class goldbox.levels.LevelTables[source]

Bases: object

One title’s progression, as data.

Pairs rather than dicts so the descriptor stays hashable and frozen, which is the shape goldbox/games.py settled on for the same reason.

class_order is class-bit order – index n is bit n of class_bits at 0x0EB and slot n of the per-class level array at 0x0C9 – so it is the index the game’s own tables use, and None marks a bit this title has no class for. racial_limits rows 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:
Return type:

None

at_level(class_name, level)[source]
Parameters:
  • class_name (str)

  • level (int)

Return type:

goldbox.levels.Level | None

attack_forms_overwritten: bool = False

Whether the recompute writes attack_forms outright or only raises it. Pool of Radiance’s $2342 refuses to lower (LDX #$03 / CPX $6BD9 / BCC skip) and never writes anything but 3; Curse’s $1909 stores what it computed, 2 or 3, whatever was there before.

base_thac0(class_levels)[source]

thac0_base as this title’s C64 engine computes it, best of the classes – the mirror of dos_base_thac0(), over self.classes rather than self.dos_thac0.

GEN $1EF3 clears the byte, walks the per-class level array at 0x0C9 and keeps the best row; this is that recompute, and it is CONFIRMED for all three titles’ C64 sides (self.classes is read off each title’s own GEN). A level past this title’s own table contributes nothing, the same floor saving_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

ceiling(class_name)[source]

The last level this title lets the class reach, or None if unknown.

Parameters:

class_name (str)

Return type:

int | None

ceilings: tuple[tuple[str, int], ...]
clamp_threshold(class_name, level)[source]

What GEN $23D4 reads for a class at that level, ceiling included.

Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

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 $23D4 reads 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 because next_threshold must 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_level stops at the ceiling, so nothing else in here can reach that thirteenth entry.

property class_names: tuple[str, ...]
class_order: tuple[str | None, ...]
classes: tuple[tuple[str, tuple[goldbox.levels.Level, ...]], ...]
constitution_hp_bonus(constitution, *, fighter=False, class_slot=None)[source]

Hit points a level from constitution, this title’s way.

class_slot is Curse’s selector and fighter is Pool of Radiance’s; see the module-level function of the same name for why they are not the same question.

Parameters:
  • constitution (int)

  • fighter (bool)

  • class_slot (int | None)

Return type:

int

constitution_save_columns: tuple[int, ...]

Which of the five save columns the racial constitution bonus reaches.

dos_base_thac0(class_levels)[source]

thac0_base as 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 what GAME.OVR:0x1A659 leaves 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_POOL for 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 whatever class_levels holds.

Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

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: past roll_to the 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.

Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

hit_dice_rolled(class_name, level)[source]

How many dice the class has rolled by levelmin(level, roll_to), which is the dice count _progression wrote into the row.

This is the same roll_to the trainer keeps at GEN $1282, and it is what stops a Curse constitution bonus counting past the level the dice stop at.

Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

hit_die_divide_floor: int = 1

What a divided roll is floored at. Pool of Radiance’s $20A2 BNE / LDA #$01; Curse’s $11CC is a bare LDA $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: $15E1 has no CMP #$04 in 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 in TRAINER_MEASURED may 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_FIGHTER and _HP_BONUS_OTHER from HP_BONUS_FROM up, 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.

hp_bonus_uncapped_from: int = 0
key: str
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. UNLIMITED is 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.

Parameters:
Return type:

int | None

racial_limits: tuple[tuple[int, tuple[int, ...]], ...]
saving_throws(class_levels, race=0, constitution=0)[source]

The five saves a record should store, or None for no known class.

class_levels maps a class name to its level – the per-class array at 0x0C9, not the single level byte at 0x0A0, because a multi-class character takes the best column from each of its classes.

Parameters:
  • race (int)

  • constitution (int)

Return type:

tuple[int, …] | None

stores_spell_capacity: bool = True

Whether spells_castable at 0x0EE is a field this title writes at all. False for Curse, where nothing in GEN, ECL64 or ECL65 writes those six bytes and all six shipped characters hold zero – ECL65 $880D rebuilds 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 from games.Game.races and would live better there; it is here because nothing else needs it yet and this module does not import that one.

table(class_name)[source]
Parameters:

class_name (str)

Return type:

tuple[goldbox.levels.Level, …]

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 and thief_skill_row returns None rather than reading row 0, which would be the adjustment for a dexterity of 9.

thief_skill_dexterity_from: int = 0

The score row 0 answers for; $0FC6 SBC #$09.

thief_skill_race: tuple[tuple[int, ...], ...] = ()
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 elseGEN $1FEC writes one, adds the other, and reads no ability score. Curse adds a dexterity row between them ($0FAD), so a title with thief_skill_dexterity returns 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 ADC leaves behind as $FB.

Parameters:
Return type:

tuple[int, …] | None

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.

title: str
turn_power: tuple[int, ...] = ()
turning_level(cleric_level, paladin_level=0)[source]

What 0x0A4 holds, or None where this title has no answer.

Pool of Radiance never writes the byte for a non-cleric: GEN $2388 is LDX level_cleric / BEQ straight to the RTS, so None here means “leave it alone” rather than “zero”.

Curse writes it for everybody, $113F ending in an unconditional STA $7CA4, and a paladin turns as a cleric paladin_turn_offset levels weaker – so a title with that offset answers 0 for a character who turns nothing, which is the byte the game stores.

Parameters:
  • cleric_level (int)

  • paladin_level (int)

Return type:

int | None

wisdom_bonus_from: int = 0
wisdom_bonus_level: tuple[int, ...] = ()

which spell level each point of wisdom from wisdom_bonus_from up buys a cleric. Empty means the title uses Pool of Radiance’s $10AD arithmetic in wisdom_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_from up, at the level wisdom_bonus_level names for that point – ECL65 $88F6, which loops DEY / CPY #$0D / BCS. Returns as many spell levels as the table can name.

Parameters:

wisdom (int)

Return type:

tuple[int, …]

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_MEASURED and the only one goldbox/c64_codec.py needs when it recomputes the five save columns for a converted character (#311).

Pool of Radiance’s whole trainer is measured. Curse’s $0F19 was 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 in TRAINER_MEASURED, whose own condition covers more than this.

Silver Blades’ $11D8 was 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 wrote 6 10 6 12 7 for the dwarf (twice) and 10 10 10 12 11 for the gnome, the halfling and the human – 25 of 25 columns what saving_throws computes. So the reading that only the dwarf takes the bonus in this title is measured, not read, and tests/test_ssblevels.py pins 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 – $178A refuses 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.py and tests/test_curselevels.py), and goldbox/levelup.py has 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 – $11AB divides 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 $0F19 and 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, at GEN $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_game deliberately 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:
  • class_name (str)

  • level (int)

Return type:

goldbox.levels.Level | None

goldbox.levels.base_thac0(class_levels, game=None)[source]
Return type:

int | None

goldbox.levels.ceiling(class_name, game=None)[source]
Parameters:

class_name (str)

Return type:

int | None

goldbox.levels.clamp_threshold(class_name, level, game=None)[source]
Parameters:
  • class_name (str)

  • level (int)

Return type:

int | 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_CURSE says how they differ. class_slot is Curse’s own selector: $126D clamps the score for slots 0-2 and reads the whole row for 3 and up, which is not the same question as fighter, 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; fighter still answers for a caller that has only the class bits, and picks the capped reading for a non-fighter.

Parameters:
  • constitution (int)

  • fighter (bool)

  • class_slot (int | None)

Return type:

int

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 * 2 by 7 and subtracts the quotient, which is this expression exactly. Only the races flagged at GEN $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, and 26 // 7 is 3.

Parameters:

constitution (int)

Return type:

int

goldbox.levels.dos_base_thac0(class_levels, game=None)[source]
Return type:

int | None

goldbox.levels.dos_thac0_at(class_name, level, game=None)[source]
Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

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

The tables for a title.

Takes a goldbox.games.Game, a game key, a LevelTables, or None. Deliberately duck-typed on .key rather than importing goldbox.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 in goldbox/games.py runs an engine whose progression has not been read.

Return type:

goldbox.levels.LevelTables

goldbox.levels.hit_die(class_name, game=None)[source]

How many sides the class rolls a level, or None for no such class.

Parameters:

class_name (str)

Return type:

int | None

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.

Parameters:
  • class_name (str)

  • level (int)

Return type:

int | None

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.

Parameters:
  • class_name (str)

  • level (int)

  • experience (int)

Return type:

float | None

goldbox.levels.racial_limit(race, class_name, game=None)[source]
Parameters:
Return type:

int | None

goldbox.levels.racial_save_bonus_measured(game=None)[source]

Is this title’s racial saving-throw bonus confirmed in the game?

Return type:

bool

goldbox.levels.saving_throws(class_levels, race=0, constitution=0, game=None)[source]
Parameters:
  • race (int)

  • constitution (int)

Return type:

tuple[int, …] | None

goldbox.levels.table(class_name, game=None)[source]
Parameters:

class_name (str)

Return type:

tuple[goldbox.levels.Level, …]

goldbox.levels.thief_skills(level, race, game=None, dexterity=0)[source]
Parameters:
Return type:

tuple[int, …] | None

goldbox.levels.trainer_measured(game=None)[source]

Has this title’s trainer been measured? None means the default title.

Return type:

bool

goldbox.levels.turning_level(cleric_level, game=None, paladin_level=0)[source]
Parameters:
  • cleric_level (int)

  • paladin_level (int)

Return type:

int | None

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 ruleECL65 $88F6 loops once for every point of wisdom from 13 up, each point buying one spell at the level $8906 names – 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; $10AD holds 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 on CPY #$0F, #$10 and #$11. See docs/125-bug-notes.md.

The bonus is only granted where the class table already gives a slot at that spell level (GEN $210A skips a zero), so a level-1 cleric gets no second-level spell however wise it is. That last clause is Curse’s too.

Parameters:

wisdom (int)

Return type:

tuple[int, …]