goldbox.iconparts

The icon editor’s own model: an icon is a WEAPON and a HEAD, not 18 cells.

goldbox/icons.py reads the 36 bytes an icon is. This reads the much smaller set of icons the game can actually make, which is a different question and the one an editor has to answer. Offering 253 screen codes in each of 18 cells offers about 10^43 icons, essentially all of them nonsense; the game’s own ICON menu offers two lists.

Where this comes from. SPELLN64 (disk 3, loads at $AF00, entry $AF24) is the icon editor, reached by ENCAMP > ALTER > ICON and during character creation. Its data file is SPELLE64 at $A700. The menus are plain text in the overlay: ICON: PARTS COLOR SIZE EXIT, then PARTS: WEAPON HEAD EXIT.

Four option tables, chosen in pairs by size:

size

weapons

heads

small (0x099 bit 0 clear)

28 at $A9E0

14 at $AAD0

large (bit 0 set)

35 at $A800

23 at $A8F0

Both counts and pointers are read from the overlay rather than hardcoded here – $B0DA holds 1C 0E 23 17 and $B0DE the four addresses – so a different build would be read correctly rather than silently mis-parsed.

The game offers exactly two sizes, LARGE and SMALL, which is what its own ALTER > ICON > SIZE menu shows. GEN $0958 sets 0x099 from a race table – dwarf, gnome and halfling 0; elf, half-elf and human 1 – and the $A9E0 set, the one the 0 races get, is the one that draws a smaller head lower down. So 0 is small and 1 is large, which is also what the game shows for a dwarf.

Size is never written back. No STA $6B99 exists in SPELLN64: choosing SIZE only switches which lists this session offers. So an icon may legally mix a large body with a small head, and one on our disks does – HOGARTH’s. That is why legal_shapes explores both pairs together rather than one at a time.

Reconstruction is the evidence: 17 of the 18 distinct shapes on our disks come out of a (weapon, head) pair exactly, and the 18th is HOGARTH’s mixed-size one.

`SPELLE64` is byte-identical in Pool of Radiance, Curse of the Azure Bonds and Secret of the Silver Blades, and so are the four counts; what moves is where it loads. The later two put it at $8E00, $1900 below Pool of Radiance’s $A700, so their pointers read $8F00/$8FF0/$90E0/$91D0. That is why the base is fitted from the pointers rather than named here: the class table is the file’s first page and the first option table follows it, so base = lowest pointer - 0x100. Every one of the eight shipped icons in Curse and in Silver Blades then reconstructs from a (weapon, head) pair, where the hardcoded $A700 raised IndexError on both.

Module Attributes

CLASSES_SIZE

SPELLE64's own shape, as file offsets, which is what transfers.

COUNTS_OFFSET

Where the counts and the four pointers sit in `SPELLN64`, rather than in the address space it is relocated into.

TABLE_STRIDE

six 40-entry arrays, pose 1 then pose 2 of each.

WEAPON_ONLY_CELLS

The fourteen cells no head option in either list ever writes -- measured over all 37 head options, not assumed from HEAD_CELLS.

MULTICOLOUR

Bit 3 of a colour byte tells the VIC-II to draw that cell in multicolour.

DEFAULT_PART_COLOURS

One colour per part, read back out of the measured bytes by part_colours().

DEFAULT_BACKGROUND

The colour behind the four cells this figure leaves as spaces.

PROPOSAL_PATH

Donald's table, the single source.

REVERSE_PATH

Donald's table for the other direction, tools/iconreverse.yaml -- which DOS option each C64 one becomes, drafted by tools/iconreverse.py and corrected by hand the way PROPOSAL_PATH was (#320, "Draft it, you correct it").

DOS_PAIR_CLASSES

Record bytes 0x0C1-0x0C6 in order, and which C64 part class each one paints.

DOS_CAP_COLOUR

every DOS hat and plume is drawn in pixel values 5 and 13, which the recolour lookup never touches, so a DOS hat is always magenta.

DOS_SIZES

DOS size @0x0C0 is 1 small and 2 medium; the C64 keeps the same distinction one lower at 0x099.

DOS_HIGH_NIBBLE_PARTS

The two parts whose high nibble is the colour a player sees, where every other part's is the low one.

Functions

c64_icon_tables([path])

Read the reverse table out of REVERSE_PATH.

dos_icon_tables([path, title, size])

Read the three tables out of PROPOSAL_PATH.

dos_part_colours(icon_colours[, tables])

The seven C64 part colours a DOS record's six colour pairs become.

dos_size(size_byte)

DOS size @0x0C0 as the C64's own word for it.

Classes

C64IconTables

Which DOS option a C64 one becomes, and which DOS colour pair a C64 colour becomes -- the reverse of DosIconTables (#320).

DosIcon

What IconParts.dos_icon_from_c64() read a C64 icon into (#320).

DosIconTables

Which C64 option each DOS one becomes, and which colour each colour.

IconChoice

What IconParts.recognise() read back out of an icon's cells.

IconParts

The four option tables and the drawing rules that use them.

Option

One entry in one of the four lists -- a whole weapon or a whole head.

class goldbox.iconparts.C64IconTables[source]

Bases: object

Which DOS option a C64 one becomes, and which DOS colour pair a C64 colour becomes – the reverse of DosIconTables (#320).

__init__(weapons, heads, colours)
Parameters:
Return type:

None

colours: dict[int, tuple[int, int]]

C64 icon colour 0-7 -> the (low, high) EGA pair a DOS icon_colours byte holds for it.

heads: dict[tuple[str, int], int]

(size, C64 head option) -> DOS icon_head, the same shape.

weapons: dict[tuple[str, int], int]

(size, C64 weapon option) -> DOS icon_body. Keyed by size because a C64 option number means a different drawing at each size – large weapon 3 and small weapon 3 are different pictures out of different tables – so tools/iconreverse.yaml gives the two sizes complete, separate lists rather than one table with exceptions.

goldbox.iconparts.CLASSES_SIZE = 256

SPELLE64’s own shape, as file offsets, which is what transfers. The class byte per glyph fills the first page; the four option tables follow at $F0 apart; the filler strings follow those. All three hold in every title that ships the file, because the file is the same bytes in each.

goldbox.iconparts.COUNTS_OFFSET = 474

Where the counts and the four pointers sit in `SPELLN64`, rather than in the address space it is relocated into. Pool of Radiance, Curse and Silver Blades all carry 1C 0E 23 17 here, though the three overlays differ in length and in 285 of the bytes around it.

goldbox.iconparts.DEFAULT_BACKGROUND = 6

The colour behind the four cells this figure leaves as spaces. It is not zero: the seeded table holds $0E there, which is this 6 with MULTICOLOUR set. A space draws nothing, so it is invisible either way – but writing it is what makes the composed icon the engine’s bytes rather than merely one that looks like them.

goldbox.iconparts.DEFAULT_PART_COLOURS = {1: 6, 3: 7, 5: 6, 6: 6}

One colour per part, read back out of the measured bytes by part_colours(). Weapon 0 is empty hands and head 1 wears nothing, so the weapon, cap and shield classes own no cell in this figure and no colour of theirs was measured. Keyed by class index, off PART_CLASSES rather than written as numbers, so reordering that tuple cannot silently repaint the figure.

goldbox.iconparts.DOS_CAP_COLOUR = 4

every DOS hat and plume is drawn in pixel values 5 and 13, which the recolour lookup never touches, so a DOS hat is always magenta. Purple is the C64’s magenta.

Type:

The C64’s seventh part, CAP, which DOS has no pair for

goldbox.iconparts.DOS_HIGH_NIBBLE_PARTS = frozenset({'leg', 'shield'})

The two parts whose high nibble is the colour a player sees, where every other part’s is the low one.

A DOS colour byte holds two 4-bit colours, and the C64 has one colour for the whole part, so the conversion has to pick the one that covers more of the shape. Counted pixel by pixel over the shipped art (tools/dosnibbles.py): the high nibble covers 56-65% of the leg in 32 of 32 bodies and 68-72% of the shield in 8 of 8 that carry one, and the low nibble wins everywhere else.

Reading the low nibble for all six was invisible for 222 of 296 records, because both nibbles of the default colour set land on the same C64 colour through Donald’s own match table – and then MAGNUS’s yellow shield came out black, which is what made it findable (#130).

goldbox.iconparts.DOS_PAIR_CLASSES = ('body', 'arm', 'leg', 'hair', 'shield', 'weapon')

Record bytes 0x0C1-0x0C6 in order, and which C64 part class each one paints. GAME.OVR:0x1E55C builds its recolour lookup from the table at ds:0x3CF50A 01 02 03 04 06 07 – so 0x0C1 is the body, 0x0C2 the arm, 0x0C3 the leg, 0x0C4 the hair and face, 0x0C5 the shield and 0x0C6 the weapon (#130, and confirmed in the running game on #112).

goldbox.iconparts.DOS_SIZES = {1: 'small', 2: 'large'}

DOS size @0x0C0 is 1 small and 2 medium; the C64 keeps the same distinction one lower at 0x099. Anything else is a record this reader does not understand, and a monster’s zero is one of them.

class goldbox.iconparts.DosIcon[source]

Bases: object

What IconParts.dos_icon_from_c64() read a C64 icon into (#320).

__init__(head, body, colours, choice)
Parameters:
Return type:

None

body: int
choice: goldbox.iconparts.IconChoice

The menu choices the C64 icon itself decoded to, so a caller can say which C64 weapon and head this DOS figure came from and whether the head was ambiguous.

colours: bytes
head: int
class goldbox.iconparts.DosIconTables[source]

Bases: object

Which C64 option each DOS one becomes, and which colour each colour.

__init__(weapons, heads, ega_to_c64)
Parameters:
Return type:

None

ega_to_c64: tuple[int, ...]
heads: dict[int, int]
weapons: dict[int, int]
class goldbox.iconparts.IconChoice[source]

Bases: object

What IconParts.recognise() read back out of an icon’s cells.

__init__(weapon_size, weapon, head_size, head, alternatives=(), exact=True)
Parameters:
Return type:

None

alternatives: tuple[tuple[str, int], ...] = ()

Every other (size, option) head that draws cells 1 and 10 the same way, so a caller can see that the head was not decidable rather than being handed one number as though it were.

exact: bool = True

Whether composing weapon then head reproduces the icon exactly.

head: int
head_size: str
weapon: int
weapon_size: str
class goldbox.iconparts.IconParts[source]

Bases: object

The four option tables and the drawing rules that use them.

__init__(parts, editor)[source]
Parameters:
apply(shape, size, kind, option)[source]

shape with one part changed, exactly as the ICON menu would.

Changing the weapon preserves the head: $B26F/$B29B save cells 0, 1, 9 and 10 before drawing and restore them into whatever the new weapon left as space. Without that the two menu items would not be independent, and the reachable set would be much smaller than it is.

Parameters:
Return type:

bytes

base

Where SPELLE64 loads in this title, fitted from the pointers the editor overlay carries. Never assumed: a wrong base makes every table offset negative, and a negative index reads the file’s tail rather than raising, so the drawing comes out as plausible rubbish.

colours_for(shape, per_class, existing=b'')[source]

The 18 colour bytes a shape must carry, given a colour per part.

colour[cell] = C[class(glyph)] | (8 if the glyph's class byte has bit 7)$B2F0/$B400. So the colour half is not free either: every cell of one part shares a colour, and bit 3 belongs to the glyph, not to the player.

Cells holding no part are left alone. A space has class $0F, the rule says nothing about it, and the byte there is whatever it last was. Computing one anyway is what made this disagree with all eight icons in a save: it invented colour 1 for background cells carrying 14.

Parameters:
Return type:

bytes

compose(size, weapon, head)[source]

A whole icon shape from scratch: weapon first, then head.

Parameters:
Return type:

bytes

count(size, kind)[source]
Parameters:
Return type:

int

default_icon()[source]

The 36 bytes the game gives a character it has just rolled (#57).

Eighteen screen codes and eighteen colours, composed out of this disk’s own option tables rather than stored – see DEFAULT_SIZE for what was measured and on what sample.

This is what a conversion from a port with no C64 icon writes. Zero is refused: screen code 0 in CHARPIC00 is a real glyph, so a zeroed icon draws as a 3x3 block of black hooks on the combat floor (#57, seen in a fight).

Return type:

bytes

dos_icon(head, body, size, colours, tables=None)[source]

The 36 bytes a DOS character’s own combat figure becomes.

head and body are the DOS record’s icon_head and icon_body, size is "small" or "large" off its size byte, and colours is its six icon_colours pairs. The result is eighteen screen codes and eighteen colours, composed out of this disk’s own option tables the way the ICON menu composes one – so every icon this returns is an icon the game itself can make.

A row that lands past a small character’s own list is composed large. The C64 offers a small character 28 weapons and 14 heads against a large one’s 35 and 23, and six of the thirty-two weapon rows and three of the fourteen head rows name an option only the large list has. Size is never written back by the ICON menu (SPELLN64 has no store to 0x099), so a mixed icon is one the game’s own menus reach and one is on the player’s disks already – HOGARTH’s. The head glyph starts at cell 1 in both lists, so a large head on a small figure sits where a head always sits.

The colour half takes the low nibble of each pair, which is the part’s main colour; the high nibble is a highlight and the C64 keeps one colour a part, so it has nowhere to go. The cap has no DOS pair at all – a DOS hat is drawn in pixel values the record cannot recolour – and gets DOS_CAP_COLOUR.

Parameters:
Return type:

bytes

dos_icon_from_c64(icon, tables=None, prefer='large')[source]

The DOS icon_head, icon_body and six icon_colours bytes a C64 character’s own combat icon becomes.

icon is the 36 bytes a C64 record’s icon table holds – eighteen screen codes then eighteen colours, the shape dos_icon() and default_icon() both return. It is read back into the menu choices that drew it (recognise()) and each is looked up in tools/iconreverse.yaml through tables, Donald’s own judgement (#320) the way tools/iconproposal.yaml is his for the DOS-to-C64 direction.

Where the head is ambiguous, recognise already resolved it: IconChoice.head is the first of the candidates that compose back into this icon’s own bytes exactly, which is what the two candidates share – they draw the same picture here, so either answers the question “what does this icon look like” the same way. A caller that wants to know it was ambiguous reads choice. alternatives off the result; this method does not guess among pictures that differ, only among numbers that do not.

Raises ValueError when icon was not composed by the game’s own ICON menu (see recognise()), or when tables has no row for the weapon or head recognise named – which none of the shipped table’s 100 rows should, since tests/test_iconreverse.py pins one for every option the game offers; a KeyError here means tables came from somewhere else.

The colour half takes part_colours() per class and looks each up in tables.colours; a part this icon draws nothing of – an empty-handed weapon, no cap, no shield – has no colour to read, and gets DEFAULT_BACKGROUND’s own row, which is invisible either way because nothing of that part is drawn.

Parameters:
Return type:

goldbox.iconparts.DosIcon

legal_shapes(sizes=('small', 'large'))[source]

Every shape reachable by any sequence of menu choices.

Not the product of the two lists. A weapon preserves the head cells, so the order of edits matters and mixing the two size pairs reaches shapes neither pair reaches alone – 15328 against the 805 + 392 a naive “one weapon times one head” count would predict.

Parameters:

sizes (tuple[str, ...])

Return type:

set[bytes]

classmethod load(disk)[source]

Read both files off the character-creation disk (POOL3).

Parameters:

disk (goldbox.d64.D64 | str)

Return type:

goldbox.iconparts.IconParts

multicolour(glyph)[source]

Bit 7 of the class byte: add 8 to whatever colour the part carries.

Parameters:

glyph (int)

Return type:

bool

options(size, kind)[source]
Parameters:
Return type:

list[goldbox.iconparts.Option]

part_class(glyph)[source]

0 weapon, 1 body, 2 cap, 3 hair, 4 shield, 5 arm, 6 leg; $0F none.

Parameters:

glyph (int)

Return type:

int

part_colours(icon_colours, shape)[source]

The seven COLOR-menu values implied by an icon, keyed by part class.

The menu offers one colour per part – WEAPON BODY CAP HAIR SHIELD ARM LEG – and every cell of a class carries it, so reading any one cell of a class back gives the value the menu was left on. Cells disagreeing (only hand-authored icons do) are resolved by majority.

Parameters:
Return type:

dict[int, int]

recognise(shape, prefer='large')[source]

Which menu choices drew these eighteen screen codes.

A C64 record stores the drawn cells rather than an index, so the conversion out of the C64 has to read the choices back. The arithmetic is not a search: the fourteen cells of WEAPON_ONLY_CELLS are the weapon’s alone, and all 63 weapon options draw a different fourteen – so the weapon, and which of the two lists it came from, are read straight out of a dict.

The head is not always decidable and this says so rather than guessing quietly. Seven of the 23 large heads are another head with a hair glyph added in cells 0 and 9 – (0,18), (4,22), (5,17), (7,20), (8,13), (9,14), (12,19) – and small heads 0 and 5 are the identical drawing. So the head is first matched on cells 1 and 10, which no weapon in either list ever writes, and then narrowed to whichever of those compose with this weapon into exactly shape. head is the first survivor, at prefer’s size where there is a choice, and alternatives names the rest – an icon whose head this cannot pin down says so instead of handing back one number as though it were certain.

exact is True when composing the two answers reproduces shape byte for byte. False means the icon carries a cell left behind by an earlier choice, which the weapon that came after would not paint over – legal, on the player’s own disks, and drawn by the game exactly as stored. 47 of the 222 icons on this machine’s three C64 disk sets are like that, over 7 of their 35 distinct shapes.

Raises ValueError for a shape no weapon option drew, which is a hand-authored icon or a figure with no weapon chosen at all.

Parameters:
Return type:

goldbox.iconparts.IconChoice

size_for(size, kind, option)[source]

size, unless only the large list is long enough to hold option.

Public because tools/iconproposal.py needs the same rule to draw a mixed row on a proposal sheet – the crash #325 (The small head sheet will not draw at all, because two of its rows use a head the small list does not have) fixed was that sheet’s own c64_figure recomputing this for the weapon and never for the head, so a head past the small list hit _apply’s guard instead of composing large.

Parameters:
Return type:

str

tables: dict[tuple[str, str], tuple[int, int]]
goldbox.iconparts.MULTICOLOUR = 8

Bit 3 of a colour byte tells the VIC-II to draw that cell in multicolour. colours_for sets it from the glyph’s own class byte; the cells holding no part carry whatever the table was seeded with, which is where DEFAULT_BACKGROUND comes in.

class goldbox.iconparts.Option[source]

Bases: object

One entry in one of the four lists – a whole weapon or a whole head.

__init__(index, kind, size)
Parameters:
Return type:

None

index: int
kind: str
property label: str
size: str
goldbox.iconparts.PROPOSAL_PATH = PosixPath('/home/docs/checkouts/readthedocs.org/user_builds/wish-goldbox/checkouts/latest/tools/iconproposal.yaml')

Donald’s table, the single source. tools/iconproposal.py draws it and dos_icon_tables reads it; nothing else may hold a second copy.

It stays in tools/ – Donald edits it where he has already been shown it – and reaches a frozen build through goldbox.assets.asset_path, the resolver #351 (The Windows build shows no logo in About and a black square on the taskbar, because the artist's SVGs are not in the package) added: sys._MEIPASS when frozen, this checkout otherwise. wish.spec’s DATAS carries tools/iconproposal.yaml alongside it, which is what #315 (A frozen Wish cannot convert a combat figure, because the table it needs lives outside the package) was waiting on.

goldbox.iconparts.REVERSE_PATH = PosixPath('/home/docs/checkouts/readthedocs.org/user_builds/wish-goldbox/checkouts/latest/tools/iconreverse.yaml')

Donald’s table for the other direction, tools/iconreverse.yaml – which DOS option each C64 one becomes, drafted by tools/iconreverse.py and corrected by hand the way PROPOSAL_PATH was (#320, “Draft it, you correct it”). Reached the same way and for the same reason: a frozen build has no tools/ checkout, so goldbox.assets.asset_path plus wish.spec’s DATAS is what #315’s resolver was for.

goldbox.iconparts.TABLE_STRIDE = 40

six 40-entry arrays, pose 1 then pose 2 of each.

Type:

One option table

goldbox.iconparts.WEAPON_ONLY_CELLS = (2, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 16, 17)

The fourteen cells no head option in either list ever writes – measured over all 37 head options, not assumed from HEAD_CELLS. A shape’s bytes here are the weapon’s alone, which is what makes IconParts.recognise() able to name the weapon exactly.

goldbox.iconparts.c64_icon_tables(path=None)[source]

Read the reverse table out of REVERSE_PATH.

Independent of tools/iconreverse.py’s own reader, the way dos_icon_tables() is independent of tools/iconproposal.py’s: that module’s load_tables also draws the sheets Donald corrects, and a second copy of the parsing here would go out of step with a YAML structure change nobody remembered to mirror.

The base section (weapons:/heads: at the top level) is the large lists in full, and small: is the small lists in full – not a base plus exceptions, because a C64 option number is a different drawing at each size and there is no size-free answer to fall back to.

Parameters:

path (pathlib.Path | str | None)

Return type:

goldbox.iconparts.C64IconTables

goldbox.iconparts.dos_icon_tables(path=None, title=None, size=None)[source]

Read the three tables out of PROPOSAL_PATH.

title is a goldbox.games.Game.key such as "secret-of-the-silver-blades", and size is "small" or "large". With neither, this is exactly the base table every conversion has always read (#330). Where a title’s own overrides: section names a row, its c64 replaces the base table’s for that DOS index only; every other row is untouched.

The base tables serve both sizes, which is right for all but a handful of rows: the C64 draws a small character from a 28-weapon and 14-head list where a large one has 35 and 23, and the shared designs are redrawn rather than scaled. So a row chosen against the large picture can be the wrong answer for a halfling, and the base table carries a top-level small: or large: section for those, applying to every title alike – Curse ships the identical art to Pool of Radiance’s and Silver Blades’ own redraws change none of these answers (#330, #335). A title’s own overrides: section may hold a small: or large: subsection too, for a row that is only right for that title at one size. Either way, a size-specific row wins over a size-free one at the same level.

The merge order is base, then base size, then title, then title size – each level replacing only the rows it names, so a row no level touches keeps whatever the level below it said. With no size given, neither size section is applied, which keeps dos_icon_tables() with no arguments meaning exactly what it has always meant.

No caller passes `title` or `size` yet. IconParts.dos_icon knows its own character’s size – it is a parameter of that method – but calls dos_icon_tables() with neither, so today’s base weapons and heads reach every conversion (moving them out of a per-title override is what made that happen, #130) while the base table’s own small: section does not, any more than a title’s overrides: section does. Donald picked Silver Blades’ head 10 on 2026-09-05 and it reaches a document but not a conversion; giving _icon_for in goldbox/dos.py the title and size it is composing for is the rest of #335 (Two combat-figure rows describe Pool of Radiance's art, and Silver Blades draws those two options differently).

Parameters:
Return type:

goldbox.iconparts.DosIconTables

goldbox.iconparts.dos_part_colours(icon_colours, tables=None)[source]

The seven C64 part colours a DOS record’s six colour pairs become.

Keyed by part class, which is what IconParts.colours_for() takes. Each DOS byte holds two colours and the C64 keeps one, so the nibble that covers more of that part is the one taken – see DOS_HIGH_NIBBLE_PARTS.

Parameters:
Return type:

dict[int, int]

goldbox.iconparts.dos_size(size_byte)[source]

DOS size @0x0C0 as the C64’s own word for it.

Parameters:

size_byte (int)

Return type:

str