goldbox.portraits

The character sheet’s portrait, and the one enumeration both ports share.

A Pool of Radiance character is drawn on the sheet as a head over a body, chosen at creation from a menu of fourteen heads and twelve bodies. Both ports store that choice in the character record, and they store it differently:

  • the C64 keeps the art’s own id – portrait_head at 0x0FE is the two hex digits of a filename, so $2D is the file HEAD2D (docs/140-loaded- files-cache.md, loader slots 13 and 14);

  • DOS keeps the menu position, one-based – portrait_head at 0x0BB is 1 to 14 and portrait_body at 0x0BC is 1 to 12.

The art itself is the same art with the same numbering. DOS packs it into one .DAX container per disk instead of one file per portrait, and the block ids inside HEAD<n>.DAX are exactly the ids of the HEAD* files on the C64’s POOL<n>.D64 – 41 head ids and 21 body ids, equal as sets and equal disk by disk, all sixteen containers (docs/117-save-conversion.md, “The portrait”).

So the two ports are joined by the menu: DOS position n is C64 art id `heads[n - 1]`, and the table of ids is in the player’s own files on both sides. This module can read it off either – tables_from_dos, tables_from_c64, tables_from_disks – and also carries the twenty-six numbers themselves, POOL_OF_RADIANCE_MENU, read off both ports once and typed out, so a conversion needs no disk in reach to give a character his own face. stored_tables is the fallback; the block above it says why a handful of integers with their provenance is a measurement and not a copy of a game file.

Module Attributes

HEAD_COUNT

The menu is fourteen heads and twelve bodies in both ports' binaries.

POOL_OF_RADIANCE_KEY

The game keys whose C64 character sheet draws a portrait.

POOL_OF_RADIANCE_MENU

the fourteen head ids and the twelve body ids in menu order, read out of the game's own binaries once and written down here as numbers, so a conversion needs no disk in reach to give a character his own face.

STORED_MENUS

The stored menu per game key.

Functions

dos_art_ids(game)

Every head and body id the DOS game directory's containers hold.

draws_sheet_portrait([game])

Whether this title's C64 character sheet draws a portrait at all.

stored_tables([game])

The creation menu this module carries for game, or None.

tables_from_c64(disk)

The same tables out of the C64 GEN overlay, for a cross-check.

tables_from_disks(disks)

The menu tables off the player's C64 game sides, whichever holds GEN.

tables_from_dos(game)

The menu tables out of the DOS game directory's own executable.

Classes

PortraitTables

The fourteen heads and twelve bodies the creation menu offers.

Exceptions

PortraitError

The portrait tables could not be read out of the files given.

goldbox.portraits.HEAD_COUNT = 14

The menu is fourteen heads and twelve bodies in both ports’ binaries.

goldbox.portraits.POOL_OF_RADIANCE_KEY = 'pool-of-radiance'

The game keys whose C64 character sheet draws a portrait.

Only Pool of Radiance’s does, and that is a fact about the engine rather than about the art on a player’s disks: its LIBRARY $48A4 reads portrait_head and portrait_body out of the live record, asks the loader for the HEAD<xx> and BODY<xx> files they name, and draws them with ANIMATE00’s $8406 and $8409 entries at screen $CC44. Curse of the Azure Bonds’ and Secret of the Silver Blades’ LIBRARY never call the loader at all, and no file on any of their twelve sides calls either of those two ANIMATE entries – 558 and 571 files searched (tools/portraitdraw.py, which prints the census).

Watched as well as read: a Curse sheet and a Silver Blades sheet with $41 – art that exists on CURSE_B – written into both record bytes draw exactly as they do with the pair at zero, and loader slots 13 and 14 stay $FF, so no art was even asked for. The runs are in docs/188-the-sheet-portrait-per-title.md and the screenshots on #300.

Kept here rather than in the conversion so both directions read one fact: goldbox.dos decides today with shape is POOL_OF_RADIANCE in the C64-to-DOS direction and does not decide at all in the other, which is why a Curse import reports a portrait it never could have written. Spelled out rather than imported from goldbox.games: goldbox.traits does the same and says why – this package duck-types on .key to keep the import graph acyclic.

goldbox.portraits.POOL_OF_RADIANCE_MENU = PortraitTables(heads=(0, 8, 9, 13, 16, 18, 22, 34, 45, 51, 53, 57, 67, 68), bodies=(1, 2, 3, 4, 7, 8, 18, 24, 26, 33, 35, 37), source='the stored Pool of Radiance creation menu (goldbox/portraits.py)')

the fourteen head ids and the twelve body ids in menu order, read out of the game’s own binaries once and written down here as numbers, so a conversion needs no disk in reach to give a character his own face. Donald, 2026-09-06: “Can we not just store all these art id tables ourselves? … It’s just a handful of numbers, right? Just pull them from each title so you can cross reference them. Then you don’t need the disks at all.” And, asked whether that crossed AGENTS.md’s line: “We don’t need to refuse game disks. Just store the IDs we would otherwise be looking up. They are 40 years old and they are not going to change.” And, the same day: “A table of 26 numbers doesn’t break any rules. It’s not art, it’s just two dozen numbers.” (.claude/rules/conversions.md, “A small table of numbers read out of the game is a measurement”.)

Why this is a measurement and not a copy of a data file. AGENTS.md bans the game’s data files – a map, a table, a script, a record – as committed bytes, because a slice of a game file under a new name is the file. What is below is not a slice of anything: it is twenty-six integers that tables_from_c64() found in GEN and tables_from_dos() found in START.EXE, by the shape of the run and by checking every value against the art beside it, and then typed out – the same class of thing as the field offsets in goldbox/layout.py, the $49FF switch below, and every address cited in docs/. It describes where the menu’s fourteenth head lives; it does not carry the head. No byte of GEN, of START.EXE or of any HEAD<xx> file is here, and nothing here would let anybody rebuild one.

Where the numbers came from, so anybody can re-derive them. tools/portraitmenu.py reads both binaries and prints this block; tools/portraitmenu.py --check says whether the disks on the machine still agree with it. Read 2026-09-06 off:

  • the C64’s POOL3.D64:GEN, run at file offset 2877, bodies first – the one Pool of Radiance rip on this machine, present at two paths with the same SHA-256 (51b6fac7...);

  • DOS’s START.EXE, run at file offset 58297, heads first – the Forgotten Realms: The Archives release, and the same bytes again in Donald’s play directory.

The two ports agree byte for byte (tests/test_portraits.py::test_the_menu_found_from_the_disks_is_the_one_dos_offers), and test_the_stored_menu_is_what_the_disks_carry pins this block to what the disks say wherever the disks are present, so the two cannot drift apart in silence. One release of each port has been read. A release whose GEN orders the menu differently would make this table wrong for its owner; none is known, and the check above is how one would be found.

Only Pool of Radiance needs one. Curse of the Azure Bonds’ and Secret of the Silver Blades’ C64 sheets draw no portrait for any character (SHEET_PORTRAIT_TITLES, #300), neither title’s GEN or START.EXE carries a fourteen-and-twelve run – tables_from_disks and tables_from_dos both raise on both, measured 2026-09-06 on every copy on this machine – so there is nothing to store and nothing a stored table would give a player.

Type:

Pool of Radiance’s creation menu

exception goldbox.portraits.PortraitError[source]

Bases: ValueError

The portrait tables could not be read out of the files given.

class goldbox.portraits.PortraitTables[source]

Bases: object

The fourteen heads and twelve bodies the creation menu offers.

heads and bodies are art ids in menu order, so the index into either is the DOS record’s value less one, and the value at that index is the C64 record’s byte.

__init__(heads, bodies, source)
Parameters:
Return type:

None

agrees_with(other)[source]

Whether another port’s tables are the same menu in the same order.

Parameters:

other (goldbox.portraits.PortraitTables)

Return type:

bool

bodies: tuple[int, ...]
body_art(position)[source]

The C64 BODY<xx> id a DOS portrait_body names, or None.

Parameters:

position (int)

Return type:

int | None

body_position(art_id)[source]

Where a C64 portrait_body sits in the menu, or None.

Parameters:

art_id (int)

Return type:

int | None

head_art(position)[source]

The C64 HEAD<xx> id a DOS portrait_head names, or None.

Parameters:

position (int)

Return type:

int | None

head_position(art_id)[source]

Where a C64 portrait_head sits in the menu, or None.

Parameters:

art_id (int)

Return type:

int | None

heads: tuple[int, ...]
source: str
goldbox.portraits.STORED_MENUS: dict[str, goldbox.portraits.PortraitTables] = {'pool-of-radiance': PortraitTables(heads=(0, 8, 9, 13, 16, 18, 22, 34, 45, 51, 53, 57, 67, 68), bodies=(1, 2, 3, 4, 7, 8, 18, 24, 26, 33, 35, 37), source='the stored Pool of Radiance creation menu (goldbox/portraits.py)')}

The stored menu per game key. A title with no entry has no sheet portrait to convert, and stored_tables() answers None for it.

goldbox.portraits.dos_art_ids(game)[source]

Every head and body id the DOS game directory’s containers hold.

Parameters:

game (str | pathlib.Path)

Return type:

tuple[set[int], set[int]]

goldbox.portraits.draws_sheet_portrait(game=None)[source]

Whether this title’s C64 character sheet draws a portrait at all.

game is a goldbox.games.Game, anything else carrying a key, or the key itself. A title this answers False for has nothing to convert and nothing to report: a character arriving there without a face is a character arriving correct, because the engine draws none for any character, including one it made itself.

`None` means Pool of Radiance, which is the answer every other resolver in this package gives – goldbox.spells.for_game, goldbox.levels.for_game, goldbox.traits.for_game, goldbox.c64_save.container_for and goldbox.c64_codec.record_shape all resolve it that way, because every caller that passes no title predates the second game and means the first. Answering False for None here would have been the one predicate in the family that disagreed, and it would disagree in the direction that silently drops a portrait Pool of Radiance really does draw.

Return type:

bool

goldbox.portraits.stored_tables(game=None)[source]

The creation menu this module carries for game, or None.

game is a goldbox.games.Game, anything else carrying a key, or the key itself, and None means Pool of Radiance for the same reason draws_sheet_portrait() says it does. This is what a conversion falls back to when nobody handed it tables read off the player’s own disks: goldbox.dos.to_neutral asks here before it gives up on the portrait, so a DOS Pool of Radiance party converts with every face its own whether or not a POOL<n>.D64 is anywhere in reach. Reading the player’s disks (tables_from_disks()) is still there for anybody who wants the table off their own copy.

Return type:

goldbox.portraits.PortraitTables | None

goldbox.portraits.tables_from_c64(disk)[source]

The same tables out of the C64 GEN overlay, for a cross-check.

GEN is on POOL3, the character-creation side, beside the HEAD* and BODY* files its two tables name. The order is the other way round there – bodies first – which is the only difference between the ports.

This checks a found table against the one disk given, which is right for POOL3GEN and the art it names are both there. A title that keeps them on different sides needs tables_from_disks, which checks against every side of the title instead of just this one.

Parameters:

disk (str | pathlib.Path)

Return type:

goldbox.portraits.PortraitTables

goldbox.portraits.tables_from_disks(disks)[source]

The menu tables off the player’s C64 game sides, whichever holds GEN.

The import direction – a DOS save becoming a .d64 – needs the tables to turn the DOS record’s menu position into the art id the C64 record stores, and the thing it has in its hand is the directory this title’s sides live in – the one goldbox.dos.write_dos_save already reads the combat icon and ANIMATE00 out of. tables_from_c64 wants the one side that carries GEN; this finds it, for whichever of the three importable titles the directory turns out to hold (#300).

Every side of the matching title is tried and the first that answers wins, rather than hardcoding POOL3 or a POOL<n>.D64 name: which side carries GEN is the game’s business, and Curse keeps GEN on one side and its HEAD*/BODY* art on the others. So the ids a found table is checked against are pooled from every side of the title, not only the one GEN happened to be on – a table found is still a table naming portraits that exist somewhere on the player’s own disks.

Parameters:

disks (str | pathlib.Path)

Return type:

goldbox.portraits.PortraitTables

goldbox.portraits.tables_from_dos(game)[source]

The menu tables out of the DOS game directory’s own executable.

game is the directory holding START.EXE and the HEAD<n>.DAX files – the one goldbox.dos.write_dos_save already takes so the party’s own area script can be staged.

Parameters:

game (str | pathlib.Path)

Return type:

goldbox.portraits.PortraitTables