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 ( |
28 at |
14 at |
large (bit 0 set) |
35 at |
23 at |
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
|
|
Where the counts and the four pointers sit in `SPELLN64`, rather than in the address space it is relocated into. |
|
six 40-entry arrays, pose 1 then pose 2 of each. |
|
The fourteen cells no head option in either list ever writes -- measured over all 37 head options, not assumed from |
|
Bit 3 of a colour byte tells the VIC-II to draw that cell in multicolour. |
|
One colour per part, read back out of the measured bytes by |
|
The colour behind the four cells this figure leaves as spaces. |
|
Donald's table, the single source. |
|
Donald's table for the other direction, |
|
Record bytes |
|
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 |
|
The two parts whose high nibble is the colour a player sees, where every other part's is the low one. |
Functions
|
Read the reverse table out of |
|
Read the three tables out of |
|
The seven C64 part colours a DOS record's six colour pairs become. |
|
DOS |
Classes
Which DOS option a C64 one becomes, and which DOS colour pair a C64 colour becomes -- the reverse of |
|
What |
|
Which C64 option each DOS one becomes, and which colour each colour. |
|
What |
|
The four option tables and the drawing rules that use them. |
|
One entry in one of the four lists -- a whole weapon or a whole head. |
- class goldbox.iconparts.C64IconTables[source]¶
Bases:
objectWhich DOS option a C64 one becomes, and which DOS colour pair a C64 colour becomes – the reverse of
DosIconTables(#320).- __init__(weapons, heads, colours)¶
- colours: dict[int, tuple[int, int]]¶
C64 icon colour 0-7 -> the
(low, high)EGA pair a DOSicon_coloursbyte holds for it.
- 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 – sotools/iconreverse.yamlgives 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$F0apart; 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 17here, 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
$0Ethere, which is this 6 withMULTICOLOURset. 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, offPART_CLASSESrather 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-0x0C6in order, and which C64 part class each one paints.GAME.OVR:0x1E55Cbuilds its recolour lookup from the table atds:0x3CF5–0A 01 02 03 04 06 07– so0x0C1is the body,0x0C2the arm,0x0C3the leg,0x0C4the hair and face,0x0C5the shield and0x0C6the weapon (#130, and confirmed in the running game on #112).
- goldbox.iconparts.DOS_SIZES = {1: 'small', 2: 'large'}¶
DOS
size@0x0C0is 1 small and 2 medium; the C64 keeps the same distinction one lower at0x099. 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:
objectWhat
IconParts.dos_icon_from_c64()read a C64 icon into (#320).- __init__(head, body, colours, choice)¶
- Parameters:
head (int)
body (int)
colours (bytes)
choice (goldbox.iconparts.IconChoice)
- Return type:
None
- 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.
- class goldbox.iconparts.DosIconTables[source]¶
Bases:
objectWhich C64 option each DOS one becomes, and which colour each colour.
- __init__(weapons, heads, ega_to_c64)¶
- class goldbox.iconparts.IconChoice[source]¶
Bases:
objectWhat
IconParts.recognise()read back out of an icon’s cells.- __init__(weapon_size, weapon, head_size, head, alternatives=(), exact=True)¶
- class goldbox.iconparts.IconParts[source]¶
Bases:
objectThe four option tables and the drawing rules that use them.
- apply(shape, size, kind, option)[source]¶
shapewith one part changed, exactly as the ICON menu would.Changing the weapon preserves the head:
$B26F/$B29Bsave 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.
- base¶
Where
SPELLE64loads 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.
- 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_SIZEfor 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
CHARPIC00is 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:
- dos_icon(head, body, size, colours, tables=None)[source]¶
The 36 bytes a DOS character’s own combat figure becomes.
headandbodyare the DOS record’sicon_headandicon_body,sizeis"small"or"large"off itssizebyte, andcoloursis its sixicon_colourspairs. 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 (
SPELLN64has no store to0x099), 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.
- dos_icon_from_c64(icon, tables=None, prefer='large')[source]¶
The DOS
icon_head,icon_bodyand sixicon_coloursbytes a C64 character’s own combat icon becomes.iconis the 36 bytes a C64 record’s icon table holds – eighteen screen codes then eighteen colours, the shapedos_icon()anddefault_icon()both return. It is read back into the menu choices that drew it (recognise()) and each is looked up intools/iconreverse.yamlthroughtables, Donald’s own judgement (#320) the waytools/iconproposal.yamlis his for the DOS-to-C64 direction.Where the head is ambiguous,
recognisealready resolved it:IconChoice.headis 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 readschoice. alternativesoff the result; this method does not guess among pictures that differ, only among numbers that do not.Raises
ValueErrorwheniconwas not composed by the game’s own ICON menu (seerecognise()), or whentableshas no row for the weapon or headrecognisenamed – which none of the shipped table’s 100 rows should, sincetests/test_iconreverse.pypins one for every option the game offers; aKeyErrorhere meanstablescame from somewhere else.The colour half takes
part_colours()per class and looks each up intables.colours; a part this icon draws nothing of – an empty-handed weapon, no cap, no shield – has no colour to read, and getsDEFAULT_BACKGROUND’s own row, which is invisible either way because nothing of that part is drawn.- Parameters:
icon (bytes)
tables (goldbox.iconparts.C64IconTables | None)
prefer (str)
- Return type:
- 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.
- classmethod load(disk)[source]¶
Read both files off the character-creation disk (POOL3).
- Parameters:
disk (goldbox.d64.D64 | str)
- Return type:
- 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.
- 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_CELLSare 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.headis the first survivor, atprefer’s size where there is a choice, andalternativesnames the rest – an icon whose head this cannot pin down says so instead of handing back one number as though it were certain.exactis True when composing the two answers reproducesshapebyte 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
ValueErrorfor a shape no weapon option drew, which is a hand-authored icon or a figure with no weapon chosen at all.- Parameters:
- Return type:
- size_for(size, kind, option)[source]¶
size, unless only the large list is long enough to holdoption.Public because
tools/iconproposal.pyneeds 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 ownc64_figurerecomputing this for the weapon and never for the head, so a head past the small list hit_apply’s guard instead of composing large.
- goldbox.iconparts.MULTICOLOUR = 8¶
Bit 3 of a colour byte tells the VIC-II to draw that cell in multicolour.
colours_forsets it from the glyph’s own class byte; the cells holding no part carry whatever the table was seeded with, which is whereDEFAULT_BACKGROUNDcomes in.
- class goldbox.iconparts.Option[source]¶
Bases:
objectOne entry in one of the four lists – a whole weapon or a whole head.
- 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.pydraws it anddos_icon_tablesreads 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 throughgoldbox.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._MEIPASSwhen frozen, this checkout otherwise.wish.spec’sDATAScarriestools/iconproposal.yamlalongside 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 bytools/iconreverse.pyand corrected by hand the wayPROPOSAL_PATHwas (#320, “Draft it, you correct it”). Reached the same way and for the same reason: a frozen build has notools/checkout, sogoldbox.assets.asset_pathpluswish.spec’sDATASis 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 makesIconParts.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 waydos_icon_tables()is independent oftools/iconproposal.py’s: that module’sload_tablesalso 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, andsmall: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.dos_icon_tables(path=None, title=None, size=None)[source]¶
Read the three tables out of
PROPOSAL_PATH.titleis agoldbox.games.Game.keysuch as"secret-of-the-silver-blades", andsizeis"small"or"large". With neither, this is exactly the base table every conversion has always read (#330). Where a title’s ownoverrides:section names a row, itsc64replaces 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:orlarge: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 ownoverrides:section may hold asmall:orlarge: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
sizegiven, neither size section is applied, which keepsdos_icon_tables()with no arguments meaning exactly what it has always meant.No caller passes `title` or `size` yet.
IconParts.dos_iconknows its own character’s size – it is a parameter of that method – but callsdos_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 ownsmall:section does not, any more than a title’soverrides:section does. Donald picked Silver Blades’ head 10 on 2026-09-05 and it reaches a document but not a conversion; giving_icon_foringoldbox/dos.pythe 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:
path (pathlib.Path | str | None)
title (str | None)
size (str | None)
- Return type:
- 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 – seeDOS_HIGH_NIBBLE_PARTS.- Parameters:
icon_colours (bytes)
tables (goldbox.iconparts.DosIconTables | None)
- Return type: