goldbox.levelup

What the training hall writes, reproduced field by field.

The training hall is the authority and this module copies it. GEN $1B8C is the sequence a level-up runs, and every routine it calls has been read:

what it does

routine

where it is here

per-class level +1 at 0x0C9

$1FDE

plan

level is the maximum of those

$2021

plan

THAC0, best of the classes

$1EF3

plan

the five saving throws

$1F44, $2359

levels.saving_throws

attack_level, and 3/2 attacks at fighter 7

$2342

plan

the turning level at 0x0A4

$2388

levels.turning_level

spell capacity, plus the wisdom bonus

$20BC

_spells_castable

the cleric’s new spell level, granted whole

$20CF

_cleric_spell_ids

the magic-user’s one new spell, chosen

$215A

learnable

the eight thief skills

$1FEC

levels.thief_skills

a hit die, and hit points from it

$2037, $2079

roll_hit_points

experience clamped to the next threshold

$23D4

_experience

Curse of the Azure Bonds runs the same eight steps at different addresses and by eight different rules, all of them read off its own GEN and ECL65 (#18, docs/135-levelling.md). Each is a per-title field on goldbox.levels.LevelTables rather than a branch here:

rule

Pool of Radiance

Curse

where

the hit die

one roll ($2037)

two, keep the higher ($15FC)

hit_die_rolls

a lone fighter’s floor

4 (CMP #$04)

none

hit_die_fighter_floor

a divided roll’s floor

1 ($20A2)

none ($11CC)

hit_die_divide_floor

hp_max

hp_rolled + level * bonus ($2079)

per class slot, summed and divided ($11F1)

_hit_point_maximum

the constitution bonus

two banded rows from 15 ($247B)

one signed row, no floor ($11D7)

hp_bonus_by_score

thief skills

level and race ($1FEC)

level, dexterity and race ($0FAD)

thief_skill_dexterity

attack_forms

raised to 3, never lowered ($2342)

written outright, 2 or 3 ($1909)

attack_forms_overwritten

spells_castable

written ($20BC)

never stored

stores_spell_capacity

Curse is still refused, because levels.TRAINER_MEASURED has one entry – but no longer because nothing has been watched. Five Curse level-ups were driven and diffed on 2026-09-05 and 75 derived fields and 5 spellbooks come back out of this module and goldbox.levels with no mismatches, and a dual-classed character was trained eight more times the same night (docs/192-curse-dual-class.md). What stands between the measurement and the key is in that page’s last section.

One field is a die and cannot be anything else. hp_rolled at 0x0ED takes a fresh roll of the class’s hit die at every training, so this module rolls one too rather than pretending to derive it. Everything the roll feeds – hp_max, and the roster’s current hit points – follows from it exactly, by hp_max = hp_rolled + level * constitution bonus, which is $2079.

A multi-class character’s roll is divided between the classes, and the two titles round up on different comparisons. $208D and $11AB both roll 1..class_count out of the same resident routine and compare it with the remainder; Pool of Radiance increments on <= and Curse on <. So Pool of Radiance rounds up with chance remainder / class_count and Curse with `(remainder - 1) / class_count`, which is never for a two-class character. divide_between_classes still implements Pool of Radiance’s rule for both titles, and what that needs is written up there.

Money is not touched, and the trainer does touch it. A training costs a flat 1000 gold at every level and the rest of the character’s coin is converted to platinum – measured across all twenty-nine. That is what walking into a school costs, not what gaining a level costs, so none of the seven coin fields at 0x0BB is written here.

Healing is done, because the trainer does it: current hit points end at the new maximum. MAGNUS went into the school at 2 of 9 and came out at 13 of 13. The order matters and is the trainer’s: roll, raise hp_rolled and hp_max, then heal to the new maximum.

Module Attributes

CLASS_LEVEL_FIELD

0x0C9 upwards, in class-bit order -- the order every one of the game's own tables is indexed in.

SAVE_FIELDS

The five saving throws, in stored order at 0x09A.

THIEF_FIELDS

The eight thief skills, in stored order at 0x0A5.

CLASS_CLERIC

The cleric's bit in the mask at 0x0EB, for asking goldbox.spells.capacity about a cleric without a record in hand.

Functions

apply_to(record, plan_)

A copy of the record with the plan written into it.

best_class(record[, game])

The class plan would raise if it were not told which.

best_next_class(ready, class_levels[, game])

Which of the ready classes to train, when nobody has said.

class_level(record, class_name)

The stored level for one class, falling back to the single level byte.

classes_of(record[, game])

The character's classes, in class-bit order.

divide_between_classes(value, class_count[, ...])

Split hit points between a multi-class character's classes.

dual_class_old(record[, game])

The class a character trained out of, and the level it stopped at.

learnable(record[, game, level])

The magic-user spells the trainer would offer, in the order it offers.

plan(record[, class_name, game, rng, learn, ...])

What one level in class_name would write.

ready_classes(record[, game])

Which of the character's classes have the experience for another level.

roll_hit_points(class_name[, class_count, ...])

One hit die, the way the title's trainer rolls it.

Classes

Plan

Every field a level-up writes, and what it would write there.

Exceptions

CannotLevel

The character cannot take this level, and why.

goldbox.levelup.CLASS_CLERIC = 2

The cleric’s bit in the mask at 0x0EB, for asking goldbox.spells.capacity about a cleric without a record in hand.

goldbox.levelup.CLASS_LEVEL_FIELD = {'cleric': 'level_cleric', 'fighter': 'level_fighter', 'magic-user': 'level_magic_user', 'paladin': 'level_paladin', 'ranger': 'level_ranger', 'thief': 'level_thief'}

0x0C9 upwards, in class-bit order – the order every one of the game’s own tables is indexed in. Paladin and ranger are here because Curse has both and classes_of names them for a title whose class_order does (#18).

exception goldbox.levelup.CannotLevel[source]

Bases: Exception

The character cannot take this level, and why.

class goldbox.levelup.Plan[source]

Bases: object

Every field a level-up writes, and what it would write there.

fields is record field name to value; spellbook is the mask at 0x078 when it changes, and None when it does not – as many bytes of it as the title uses, so seven on Pool of Radiance and thirteen on Curse. Nothing here has touched a machine: a caller turns it into writes, and a test compares it with what the trainer produced for the same character.

__init__(class_name, from_level, to_level, fields, hit_points_rolled, thac0_delta=0, hp_max=0, spellbook=None, learned_spell=None, experience_lost=0, classes_disqualified=(), notes=())
Parameters:
Return type:

None

class_name: str
classes_disqualified: tuple[str, ...] = ()

Classes that had the experience for a level before this training and do not after, because the clamp lowered the number past their threshold. Empty for a single-class character; see _experience.

experience_lost: int = 0

What the clamp at GEN $23D4 throws away – experience before minus experience after. Never negative; usually large, because the trainer leaves a character one point short of its next level whatever it arrived with. A caller that offers this as a button should say so.

fields: dict[str, object]
from_level: int
hit_points_rolled: int
hp_max: int = 0

The new hp_max. Current hit points are set to it, which is what the trainer does – MAGNUS went into the school at 2 of 9 and came out at 13 of 13 – and the order matters: the die is rolled, hp_max and hp_rolled rise, and only then does the heal happen, or the character is healed to the maximum it used to have.

learned_spell: int | None = None
notes: tuple[str, ...] = ()
spellbook: bytes | None = None
thac0_delta: int = 0

How much better thac0_base got. The roster’s cached THAC0 at +0x0E carries strength and the readied weapon on top of it, so it is moved by this delta rather than overwritten – which is what GEN does too.

to_level: int
goldbox.levelup.SAVE_FIELDS = ('save_paralysis', 'save_petrification', 'save_wands', 'save_breath', 'save_spell')

The five saving throws, in stored order at 0x09A.

goldbox.levelup.THIEF_FIELDS = ('thief_pick_pockets', 'thief_open_locks', 'thief_find_traps', 'thief_move_silently', 'thief_hide_in_shadows', 'thief_hear_noise', 'thief_climb_walls', 'thief_read_languages')

The eight thief skills, in stored order at 0x0A5.

goldbox.levelup.apply_to(record, plan_)[source]

A copy of the record with the plan written into it.

Parameters:

plan_ (goldbox.levelup.Plan)

goldbox.levelup.best_class(record, game=None)[source]

The class plan would raise if it were not told which. None if none is ready.

Return type:

str | None

goldbox.levelup.best_next_class(ready, class_levels, game=None)[source]

Which of the ready classes to train, when nobody has said.

The one whose threshold after the level it is about to gain is largest. The clamp at GEN $23D4 reads the new per-class levels – $1FDE has already written them – so the number that decides the ceiling is the one a class will have once it is raised, not the one it has now. Raising the class with the highest post-level threshold leaves the ceiling as high as it can be, which is what keeps the other classes above their own thresholds.

The two readings genuinely differ: a magic-user 4 / thief 5 wants the magic-user by current threshold (22,501 against 20,001) and the thief by post-level threshold (42,501 against 40,001), and the thief is right – with 42,500 points the thief-first order reaches magic-user 6 / thief 6 where magic-user-first stalls at 5 / 6. Across every two- and three-class combination in Pool of Radiance’s tables the post-level rule never gains fewer levels and sometimes gains more.

Ties break in class-bit order – magic-user, cleric, thief, fighter, and then Curse’s paladin and ranger – which is the order 0x0C9 stores and the order every one of the game’s own tables is indexed in. Repeated calls then walk down the order themselves, because each level raises the class it just picked.

Return type:

str | None

goldbox.levelup.class_level(record, class_name)[source]

The stored level for one class, falling back to the single level byte.

Parameters:

class_name (str)

Return type:

int

goldbox.levelup.classes_of(record, game=None)[source]

The character’s classes, in class-bit order.

Which bits exist is the title’s, and LevelTables.class_order is that list: index n is bit n of 0x0EB and slot n of the per-class level array at 0x0C9, with None for a bit the title has no class for. Pool of Radiance’s four are the first four, which is CLASS_BITS_CLASSIC exactly; Curse adds the paladin at 0x40 and the ranger at 0x80, and without them plan told a Curse paladin it was not one (#18).

A title goldbox.levels has no tables for falls back to Pool of Radiance’s order, which would miss a Krynn knight at 0x10. Nothing reaches that: _tables_for refuses such a title before plan asks.

Return type:

list[str]

goldbox.levelup.divide_between_classes(value, class_count, rng=None, game=None)[source]

Split hit points between a multi-class character’s classes.

GEN $208D in Pool of Radiance and $11AB in Curse, and they are the same routine twice: divide, then round up at random against the remainder. Pool of Radiance increments when the roll is at or below the remainder (CMP $6E3F / BEQ inc / BCS out) and Curse when it is below it (CMP $7F3F / BCS out).

Both rolls run `1..class_count`, which is now read rather than guessed. The random routine is in LIBRARY, not in GEN: LIBRARY $2F46 in Curse and $2DBC in Pool of Radiance, the same code twice, masking a random byte to the bit width of Y and retrying while it exceeds Y, so it returns 0..Y. Two entry points sit above it one byte apart, DEY then the call, and both titles reach the lower one. Curse’s LIBRARY runs at $2DC8, which is exact: $2DC8 plus its 7480 bytes is $4B00, where SAVEAZURE loads. Aligning Pool of Radiance’s copy against it puts its LIBRARY at $2C48, which lands four bytes below the $4900 its own saved game loads at – close enough to corroborate the technique and not an independent check of the base, since a four-byte error in the alignment would look exactly like this.

So the two titles differ only in the comparison, and Curse’s is not random for a two-class character. Pool of Radiance rounds up with chance remainder / class_count; Curse’s BCS with no BEQ in front of it makes that (remainder - 1) / class_count, which is zero whenever the remainder is 1 – and a two-class character’s remainder is only ever 0 or 1. CONFIRMED from the bytecode and from 40 engine-written divides on 2026-09-05: 14 of 14 round-downs at two classes, 12 of 12 at three with remainder 1, and 5 round-ups in 14 at three with remainder 2, against the 1 in 3 this rule predicts and the 2 in 3 the code below gives.

This function still implements Pool of Radiance’s rule for both titles. Curse needs LevelTables to carry the comparison beside hit_die_divide_floor – Pool of Radiance rounds up when the roll is at or below the remainder, Curse only when it is below – and goldbox/levels.py was another agent’s file the night this was measured. Nothing reaches the error today, because _tables_for refuses Curse outright (docs/192-curse-dual-class.md).

Pool of Radiance then floors the result at 1 ($20A2 BNE / LDA #$01) and Curse does not ($11CC is a bare LDA $4C / RTS), so a Curse character with three classes can come out of a training with nothing.

Parameters:
  • value (int)

  • class_count (int)

Return type:

int

goldbox.levelup.dual_class_old(record, game=None)[source]

The class a character trained out of, and the level it stopped at.

0x0BA holds that level and a zero means “has not dual-classed”GEN $18EB LDY #$FF / LDA $7CBA / BEQ / LDY $7CB9, which is what stops slot 0, the magic-user, being ambiguous. 0x0B9 holds the slot, so the name comes from the title’s own class_order (#224).

Curse, Silver Blades and Gateway to the Savage Frontier write the pair; Pool of Radiance, Champions of Krynn and Death Knights of Krynn never reference either byte on any of their disks, and 13 of 13 Pool of Radiance records in tests/fixtures/ hold zero there. So this answers (None, 0) for a title that has no such rule without needing to know which titles those are.

Return type:

tuple[str | None, int]

goldbox.levelup.learnable(record, game=None, level=None)[source]

The magic-user spells the trainer would offer, in the order it offers.

The trainer does not roll and it does not grant. GEN $215A walks the spellbook bitmask, keeps every id the character does not already know whose spell level is at or below (level + 1) // 2, drops every cleric spell, and puts the survivors on a menu. The player picks one and the level-up does not finish until they do – which is why a magic-user needs a dialog and a cleric does not.

How far the walk goes is the title’s, SpellTable.last_spellbook_spell – 55, 100 or 117. Pool of Radiance’s 55 was the module constant here, so a Curse magic-user reaching 7 was offered no fourth-level spell at all: ids 81-90 are past the end of a list nobody told this function had grown (issue #87).

`level` is the level being trained *to*. GEN $1FDE writes the new per-class levels before the menu is built, so a magic-user reaching 3 is offered second-level spells at that same training. Defaults to what the record already holds, for a caller asking what is on offer now.

Not every title builds a menu at all. Silver Blades’ 0x0C9 routine ORs a whole row into the mask instead of listing choices – see _magic_user_grant_row, which plan calls for the actual write – so its trainer offers nothing to pick and this returns empty rather than Pool of Radiance’s rule applied to the wrong list (#89). Curse is left alone: its GEN carries no grant loop for 0x0C9 at all, so whether it menus or grants is UNKNOWN, and this keeps treating it as a menu rather than guess.

Parameters:

level (int | None)

Return type:

list[int]

goldbox.levelup.plan(record, class_name=None, *, game=None, rng=None, learn=None, rolled=None)[source]

What one level in class_name would write. Raises rather than guessing.

An absent `class_name` means “the best one”best_next_class picks it – and not “refuse because two are ready”. An explicit name still works and is what the byte-for-byte replay of the measured trainings passes.

learn is the spell a magic-user picks. It is required whenever the trainer’s own menu would have offered one, because the game does not finish the level-up until the choice is made and neither should we.

rolled replaces the hit-die roll with a number already known, which is what replaying a measured training needs: the die is the one field nothing derives, so a test that wants to compare every other byte hands the roll in rather than hoping.

Parameters:
  • class_name (str | None)

  • learn (int | None)

  • rolled (int | None)

Return type:

goldbox.levelup.Plan

goldbox.levelup.ready_classes(record, game=None)[source]

Which of the character’s classes have the experience for another level.

The class a dual-classed character left is never ready, GEN $1321, and that holds after $20A3 has put its level back in the array. Watched: PHILIPPE, magic-user 6 turned fighter, was refused with UNABLE TO ADVANCE holding 150,000 experience and a restored magic-user 6 – 15,000 more than the magic-user’s ninth level asks for (#18).

Experience is not divided between classes. The trainer reads the whole stored number against the single-class table – LADY KATHERINE, magic-user 1 / thief 7 with 70,100 points, was offered thief 8, whose single-class threshold is 70,001 (docs/119-test-party.md). So each class is measured against the same number.

`>=`, against the game’s own number, which is the published one plus 1. GEN $1BBC walks the class’s threshold column downwards and takes the first row it is not below (SBC then BCS), and the rows themselves hold 2501 for magic-user 2 where AD&D prints 2500 – so 2500 exactly is refused and 2501 is offered. goldbox/levels.py stores the game’s numbers, which is why the comparison here is a plain >=.

Return type:

list[str]

goldbox.levelup.roll_hit_points(class_name, class_count=1, fighter_only=False, rng=None, game=None, level=None)[source]

One hit die, the way the title’s trainer rolls it.

The die is the class’s own – GEN $20A7 in Pool of Radiance, $161E in Curse – and the roll is then split between the character’s classes by divide_between_classes. Three things are the title’s:

  • how many dice. Pool of Radiance rolls one ($2037); Curse rolls two and keeps the higher ($15FC), which is hit_die_rolls. PROBABLE, from the bytecode alone: a roll leaves no trace of itself in a record.

  • the single-class fighter’s floor of 4. Pool of Radiance’s CMP #$04 against class_bits == 8 and nothing else, which is why no fighter in twenty-nine trainings gained fewer than four hit points. Curse has no floor of any kind – $15E1 carries no CMP #$04 in its 61 bytes.

  • when the dice stop. Past roll_to a class adds a flat number a level instead of rolling ($15F2 CMP $1626,X / BCC roll), and the flat number goes through the same divide. level is the level being trained to; without it this always rolls, which is right for Pool of Radiance, where no class reaches its own roll_to.

Parameters:
  • class_name (str)

  • class_count (int)

  • fighter_only (bool)

  • level (int | None)

Return type:

int