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 |
|
|
|
|
|
THAC0, best of the classes |
|
|
the five saving throws |
|
|
|
|
|
the turning level at |
|
|
spell capacity, plus the wisdom bonus |
|
|
the cleric’s new spell level, granted whole |
|
|
the magic-user’s one new spell, chosen |
|
|
the eight thief skills |
|
|
a hit die, and hit points from it |
|
|
experience clamped to the next threshold |
|
|
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 ( |
two, keep the higher ( |
|
a lone fighter’s floor |
4 ( |
none |
|
a divided roll’s floor |
1 ( |
none ( |
|
|
|
per class slot, summed and divided ( |
|
the constitution bonus |
two banded rows from 15 ( |
one signed row, no floor ( |
|
thief skills |
level and race ( |
level, dexterity and race ( |
|
|
raised to 3, never lowered ( |
written outright, 2 or 3 ( |
|
|
written ( |
never stored |
|
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
|
|
The five saving throws, in stored order at |
|
The eight thief skills, in stored order at |
|
The cleric's bit in the mask at |
Functions
|
A copy of the record with the plan written into it. |
|
The class |
|
Which of the ready classes to train, when nobody has said. |
|
The stored level for one class, falling back to the single level byte. |
|
The character's classes, in class-bit order. |
|
Split hit points between a multi-class character's classes. |
|
The class a character trained out of, and the level it stopped at. |
|
The magic-user spells the trainer would offer, in the order it offers. |
|
What one level in |
|
Which of the character's classes have the experience for another level. |
|
One hit die, the way the title's trainer rolls it. |
Classes
Every field a level-up writes, and what it would write there. |
Exceptions
The character cannot take this level, and why. |
- goldbox.levelup.CLASS_CLERIC = 2¶
The cleric’s bit in the mask at
0x0EB, for askinggoldbox.spells.capacityabout 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'}¶
0x0C9upwards, 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 andclasses_ofnames them for a title whoseclass_orderdoes (#18).
- exception goldbox.levelup.CannotLevel[source]¶
Bases:
ExceptionThe character cannot take this level, and why.
- class goldbox.levelup.Plan[source]¶
Bases:
objectEvery field a level-up writes, and what it would write there.
fieldsis record field name to value;spellbookis the mask at0x078when 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=())¶
- 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 $23D4throws 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.
- 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_maxandhp_rolledrise, and only then does the heal happen, or the character is healed to the maximum it used to have.
- 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
planwould 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 $23D4reads the new per-class levels –$1FDEhas 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
0x0C9stores 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.
- 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_orderis that list: indexnis bitnof0x0EBand slotnof the per-class level array at0x0C9, with None for a bit the title has no class for. Pool of Radiance’s four are the first four, which isCLASS_BITS_CLASSICexactly; Curse adds the paladin at0x40and the ranger at0x80, and without themplantold a Curse paladin it was not one (#18).A title
goldbox.levelshas no tables for falls back to Pool of Radiance’s order, which would miss a Krynn knight at0x10. Nothing reaches that:_tables_forrefuses such a title beforeplanasks.
- goldbox.levelup.divide_between_classes(value, class_count, rng=None, game=None)[source]¶
Split hit points between a multi-class character’s classes.
GEN $208Din Pool of Radiance and$11ABin 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 inGEN:LIBRARY $2F46in Curse and$2DBCin Pool of Radiance, the same code twice, masking a random byte to the bit width ofYand retrying while it exceedsY, so it returns0..Y. Two entry points sit above it one byte apart,DEYthen the call, and both titles reach the lower one. Curse’sLIBRARYruns at$2DC8, which is exact:$2DC8plus its 7480 bytes is$4B00, whereSAVEAZUREloads. Aligning Pool of Radiance’s copy against it puts itsLIBRARYat$2C48, which lands four bytes below the$4900its 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’sBCSwith noBEQin 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
LevelTablesto carry the comparison besidehit_die_divide_floor– Pool of Radiance rounds up when the roll is at or below the remainder, Curse only when it is below – andgoldbox/levels.pywas another agent’s file the night this was measured. Nothing reaches the error today, because_tables_forrefuses 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 ($11CCis a bareLDA $4C / RTS), so a Curse character with three classes can come out of a training with nothing.
- goldbox.levelup.dual_class_old(record, game=None)[source]¶
The class a character trained out of, and the level it stopped at.
0x0BAholds 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.0x0B9holds the slot, so the name comes from the title’s ownclass_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.
- 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 $215Awalks 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 $1FDEwrites 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’
0x0C9routine ORs a whole row into the mask instead of listing choices – see_magic_user_grant_row, whichplancalls 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: itsGENcarries no grant loop for0x0C9at all, so whether it menus or grants is UNKNOWN, and this keeps treating it as a menu rather than guess.
- goldbox.levelup.plan(record, class_name=None, *, game=None, rng=None, learn=None, rolled=None)[source]¶
What one level in
class_namewould write. Raises rather than guessing.An absent `class_name` means “the best one” –
best_next_classpicks 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.learnis 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.rolledreplaces 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:
- Return type:
- 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$20A3has put its level back in the array. Watched: PHILIPPE, magic-user 6 turned fighter, was refused withUNABLE TO ADVANCEholding 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 $1BBCwalks the class’s threshold column downwards and takes the first row it is not below (SBCthenBCS), 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.pystores the game’s numbers, which is why the comparison here is a plain>=.
- 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 $20A7in Pool of Radiance,$161Ein Curse – and the roll is then split between the character’s classes bydivide_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 ishit_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 #$04againstclass_bits == 8and nothing else, which is why no fighter in twenty-nine trainings gained fewer than four hit points. Curse has no floor of any kind –$15E1carries noCMP #$04in its 61 bytes.when the dice stop. Past
roll_toa 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.levelis the level being trained to; without it this always rolls, which is right for Pool of Radiance, where no class reaches its ownroll_to.