goldbox.layout¶
Declarative field table for the Pool of Radiance (C64) character record.
This module is the single source of truth for the on-disk shape of a
character record. Nothing else in the project may hard-code an offset:
decoders, documentation generators and (later) ImHex pattern emitters all
derive from LAYOUT.
The C64 record is 580 bytes, stored uncompressed. On disk it is preceded by
a 2-byte little-endian PRG load address ($6B00); that header is not part
of the record and is handled in goldbox.record.
Adding a newly discovered field is a one-line edit: append a _field(...)
entry to _DECLARED. Every byte not covered by a declared entry is
automatically filled in as an UNKNOWN region at import time, so the record
is always fully accounted for and overlaps are impossible to introduce
silently (_build() raises on overlap or out-of-range entries).
Confidence levels¶
CONFIRMED Verified against a real specimen (tests/fixtures/brutus.chr).
PROBABLE Strong evidence, not yet verified end to end.
GUESS Hypothesis worth testing; treat as unknown for any real purpose.
UNKNOWN Explicitly not understood. Bytes are preserved verbatim.
No offset in this table comes from a published Gold Box hex-editing guide.
Those describe the DOS record, which is a different record – 285 bytes for
Pool of Radiance against the C64’s 580, growing to 510 by Pools of Darkness.
Where a note below cites the DOS layout it is as corroboration of a reading
we measured ourselves, never as the source of an offset:
docs/127-community-formats.md works out where the two records line up and
where they do not, and says which claims the community documentation earned.
Module Attributes
Size of the character record proper, in bytes. |
|
Size of the record as stored in a PRG file (record + 2-byte load address). |
|
Load address seen in |
|
Width of the character-name field. |
|
every one of the 580 bytes belongs to exactly one entry. |
|
Lookup by field name. |
Functions
Unknown regions explicitly flagged as worth investigating. |
|
|
Report how many of the 580 bytes are understood. |
|
Return the field called name. |
|
All fields at exactly level. |
Human-readable coverage summary. |
|
|
Render the layout as a fixed-width text table (documentation source). |
Yield every field in offset order. |
|
Fields that have a claimed meaning (CONFIRMED / PROBABLE / GUESS). |
|
Fields we do not understand. |
Classes
How much we trust a field definition. |
|
Byte counts per confidence level. |
|
One entry in the record layout. |
|
How the bytes of a field are encoded. |
- class goldbox.layout.Confidence[source]¶
Bases:
enum.EnumHow much we trust a field definition.
- CONFIRMED = 'CONFIRMED'¶
- GUESS = 'GUESS'¶
- PROBABLE = 'PROBABLE'¶
- UNKNOWN = 'UNKNOWN'¶
- class goldbox.layout.Coverage[source]¶
Bases:
objectByte counts per confidence level.
- __init__(total, by_confidence=<factory>)¶
- Parameters:
total (int)
by_confidence (dict[goldbox.layout.Confidence, int])
- Return type:
None
- by_confidence: dict[goldbox.layout.Confidence, int]¶
- percent(level)[source]¶
- Parameters:
level (goldbox.layout.Confidence)
- Return type:
- goldbox.layout.FIELDS_BY_NAME: dict[str, goldbox.layout.Field] = {'abilities_second': Field(offset=101, size=7, kind=<Kind.RAW: 'raw'>, name='abilities_second', label='Abilities (second copy)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a second copy of the seven ability scores -- STR, INT, WIS, DEX, CON, CHA, exceptional STR -- mirroring 0x014-0x01A. CONFIRMED in Curse: all six of SSI's pre-generated characters carry it and the import routine writes it (docs/116 sec 2.2). Death Knights moved its editor's ability fields here rather than to 0x014. Seven zeroes in every Pool of Radiance specimen. **Which of the two arrays the game treats as current is not established** -- they are equal in every specimen, and MacGyver's Curse trainer writes both because he did not know either", candidate=False), 'age': Field(offset=116, size=2, kind=<Kind.U16LE: 'u16le'>, name='age', label='Age', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE; 21 for two humans, 176 for an elf -- long-lived, as expected', candidate=False), 'alignment': Field(offset=216, size=1, kind=<Kind.U8: 'u8'>, name='alignment', label='Alignment', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="0-based index into the game's own table at $32B3: LAWFUL GOOD=0 LAWFUL NEUTRAL=1 LAWFUL EVIL=2 NEUTRAL GOOD=3 TRUE NEUTRAL=4 NEUTRAL EVIL=5 CHAOTIC GOOD=6 CHAOTIC NEUTRAL=7 CHAOTIC EVIL=8. All six of Donald's characters decode to the alignment he chose", candidate=False), 'armour_class': Field(offset=271, size=1, kind=<Kind.U8: 'u8'>, name='armour_class', label='AC current (60 - AC)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="current armour class including armour, shield and dexterity, stored as 60 - AC. Present only in an exported .chr -- it lies beyond the 256 bytes a save slot stores -- and it agrees exactly with the SAVEDGAME1 roster's +0x0F for the same character: BRUTUS 9, MALCYON 8, LADY KATHERINE 8. Base and current again, in different places", candidate=False), 'armour_class_base': Field(offset=225, size=1, kind=<Kind.U8: 'u8'>, name='armour_class_base', label='AC base (60 - AC)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note='base armour class, stored as 60 - AC, the same encoding used for THAC0 at 0x071 and for the current AC in the SAVEDGAME1 roster. It is 10 for every player character ever seen -- unarmoured, before dexterity -- which is why it looked like a constant. Monsters use the same record layout and put their real armour class here: kobold 7, orc 6, troll 4, zombie 8, matching the Monster Manual on all eight creatures checked. The DOS record names AC_Base at the offset this one aligns to, between the eight attack-form bytes and the per-character value that precedes experience -- corroboration, not proof, and the Monster Manual check is the stronger of the two', candidate=False), 'attack_forms': Field(offset=217, size=8, kind=<Kind.RAW: 'raw'>, name='attack_forms', label='Attack forms', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the two attack forms, as four parallel two-entry arrays: attacks per round **doubled** at 0x0D9, damage dice at 0x0DB, die at 0x0DD, signed modifier at 0x0DF, each holding form 0 then form 1. COMBAT $0CAD rolls damage through LDA $6C13,Y / LDX $6C15,Y with a stride of 2, which is what proves there are exactly two forms, and twenty creatures match the Monster Manual: GHOUL 04 02 / 01 01 / 03 06 is two 1d3 claws and a 1d6 bite, TROLL 04 02 / 01 02 / 04 06 / 04 00 is two 1d4+4 claws and a 2d6 bite. A form with no damage dice is not an attack. Decoded in goldbox/monster.py; kept as one raw block here because the character sheet has no use for eight separate monster fields.\nThis region read UNKNOWN for a long while on a note recording the specimen as '03 02 00 01 00 02 00 00', which was said to contradict the attacks reading because BRUTUS, a one-attack level-1 fighter, appeared to hold 3. **That note was off by one**: it was a dump of 0x0D8-0x0E0, and the leading 03 is the alignment byte at 0x0D8. BRUTUS reads 02 00 01 00 02 00 00 00 -- one attack per round for 1d2 unarmed, exactly what GEN $0BBE writes and what the reading predicts. The contradiction never existed.\nIndependently corroborated: the DOS record spells the same eight bytes out in the same order at the offset this one aligns to -- attack count, dice count, die, modifier, each as a two-entry array of form 0 then form 1 -- and every C64 player character reads 02 00 01 00 02 00 00 00", candidate=False), 'attack_level': Field(offset=152, size=1, kind=<Kind.U8: 'u8'>, name='attack_level', label='Fighting level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the level the attack tables are indexed by, which is not always the character level: 5 for Curse's level-5 paladin and ranger, 4 for its level-5 fighter/thief, 0 for pure casters, 1 for an imported level-1 fighter. CONFIRMED in docs/116 sec 2.2, and it matches the DOS record's attackLevel at the same place in the cluster, where the community documentation calls it LVL_Sweep -- the AD&D rule that lets a fighter make one attack per level against creatures under one hit die, which is what a separate 'level the attack tables are indexed by' is for. It reads the fighter level on every high-level fighter we hold (MAD MAN 8, GRON 7, two others 4) and zero on every level-1 one. Zero in every Pool of Radiance player specimen", candidate=False), 'char_class': Field(offset=115, size=1, kind=<Kind.U8: 'u8'>, name='char_class', label='Class', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="0-based, standard Gold Box order: CLERIC=0 DRUID=1 FIGHTER=2 PALADIN=3 RANGER=4 MAGIC-USER=5 THIEF=6 MONK=7. 0, 2 and 5 are verified by saving-throw tables; 6 is verified by the monster files, which contain NPCs literally named '1ST LVL THIEF' and '7TH LVL THIEF' carrying code 6. DRUID=1, PALADIN=3, RANGER=4 and MONK=7 appear in NO character anywhere -- not in twenty player characters, not in 108 monster records -- and Donald reports that paladin and ranger were left unfinished in the game data, so those four names rest on the Gold Box convention alone. Codes above 7 are multi-class: 8 = cleric/fighter, 9 = cleric/fighter/magic-user, 10 and 11 = cleric/magic-user, 12 = cleric/thief, 13 = fighter/magic-user, 14 = fighter/thief, 15 = fighter/magic-user/thief, 16 = magic-user/thief. That enumeration is the table the 1989 BASIC editor on poolce.d64 displays, and it agrees with all four multi-class codes we had already read off the bitmask at 0x0EB. Two caveats: the editor lists 3, 4 and 5 all as MAGIC-USER, which is its author's gap rather than the game's, and listing both 10 and 11 as cleric/magic-user looks like a slip in his table. class_bits stays the field to prefer. **Curse of the Azure Bonds' own `GEN` stops maintaining this byte once a character has ever trained**: its class-code routine at `$1939` means to `STX` the class code it just found by walking its own table at `$1951`, but stores `STA` instead -- one bit apart -- so what lands is the `EOR` residue (zero, on the matching path) or the dual-class level, never the code itself. Measured on the two Curse disks written after a training-hall visit or a class change, where 8 of 30 characters carry a code that disagrees with the class they hold levels in; the same records read clean one action earlier. `docs/187-the-class-code-byte.md` has the reading (#310 (A trained C64 Curse character arrives in DOS with the wrong class on his sheet))", candidate=False), 'charisma': Field(offset=25, size=1, kind=<Kind.U8: 'u8'>, name='charisma', label='CHA', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='13 in specimen', candidate=False), 'class_bits': Field(offset=235, size=1, kind=<Kind.U8: 'u8'>, name='class_bits', label='Class bitmask', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='magic-user=1 cleric=2 thief=4 fighter=8, OR-ed together. This is how multi-class is really represented: LADY KATHERINE is 5 (magic-user/thief, confirmed by Donald) and LARA SPELLSWORD is 9 (magic-user/fighter -- her name says so). Far more usable than the single char_class code at 0x073', candidate=False), 'combat_side': Field(offset=268, size=1, kind=<Kind.U8: 'u8'>, name='combat_side', label='Combat side', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="bit 0 is the side the character fights on, 0 the party's and 1 the enemy's; bit 7 is the quickfight flag, set by QUICK and never cleared (goldbox-bugs.md bug 3). COM.PREP $0F15 reads `AND #$7F` as the side; 115 of 116 MON* records hold $81; 104 occupied roster slots across 17 save images hold $00 in 99 and $80 in 5, never $81. The DOS engine's own script-field accessor (GAME.OVR:0x7DE9, 0x8074) reads and writes this exact byte over DOS 0x10E/0x10F -- docs/169-dos-combat-side.md. Bits 1-6 are unused by every writer seen; masked (`& 0x81`) rather than asserted zero (#235 (Two unattributed DOS byte ranges in the combat tail are dropped converting to C64, and nobody knows what they hold))", candidate=False), 'constitution': Field(offset=24, size=1, kind=<Kind.U8: 'u8'>, name='constitution', label='CON', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16 in specimen', candidate=False), 'copper': Field(offset=187, size=2, kind=<Kind.U16LE: 'u16le'>, name='copper', label='Copper', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 100 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), 'dexterity': Field(offset=23, size=1, kind=<Kind.U8: 'u8'>, name='dexterity', label='DEX', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='14 in specimen', candidate=False), 'dual_class_level': Field(offset=186, size=1, kind=<Kind.U8: 'u8'>, name='dual_class_level', label='Dual class: old level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the level the dual-classed character left its old class at, and the sentinel for the pair: GEN $18EB reads LDY #$FF / LDA 0x0BA / BEQ / LDY 0x0B9, so **zero here means 'not dual-classed'** and 0x0B9 is a real slot number only when this byte is non-zero. That is what stops slot 0, the magic-user, being ambiguous. Written by Curse's GEN $23D2 with the whole of the gate dual_class_slot describes; zero in every Pool of Radiance specimen because that title never touches it. CONFIRMED for the reason dual_class_slot gives: PHILIPPE holds 6 here, the level he left magic-user at", candidate=False), 'dual_class_slot': Field(offset=185, size=1, kind=<Kind.U8: 'u8'>, name='dual_class_slot', label='Dual class: old class slot', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the slot in the per-class level array at 0x0C9 that a dual-classed human left behind, with the level it was left at in 0x0BA. Curse of the Azure Bonds' GEN writes the pair, and the gate at $2387 is AD&D 1st edition's rule read off the machine: LDA race / CMP #$07 (human), a check that refuses on carry clear, LDA 0x0BA / BNE -- so a character dual-classes at most once -- and LDA level / CMP #$02, level 2 or better. Only then does $23C9 store the slot and $23D2 the level. The three consumers are the ones docs/135 names: $1470 leaves the old slot out of the experience clamp, $1321 out of eligibility, $15E7 out of the hit-die roll, and $20A3 writes the old level back into that slot and ORs its class bit into class_bits. Secret of the Silver Blades (GEN $1FB7/$1FBD) and Gateway to the Savage Frontier (GEN $23D3) carry the same stores. **Pool of Radiance does not use these two bytes at all**: no absolute-mode instruction in any of the 589 files on its disks references $6BB9 or $6BBA, against 42 references to $6BB8 beside them, and Champions of Krynn and Death Knights of Krynn are the same. That -- not a marker -- is why every Pool of Radiance specimen reads zero here; see region_0e3 and #224 (0x0B9 and 0x0BA are documented both as an NPC marker and as the dual-class slot). **CONFIRMED, no longer PROBABLE**: `WISH-SPEC-curse-dual-classed` holds PHILIPPE, a magic-user 6 who chose FIGHTER off Curse's own training-hall menu (#234 (A dual-classed Curse or Silver Blades character converted to DOS loses the class he trained out of), #291 (A Curse save disk will not load through the game's own front end in a pooled session, so no C64 Curse party can be got in)), watched being written. His record reads slot 0 (magic-user) and level 6 -- exactly the meaning above -- with level_magic_user zeroed to 0 and class_bits holding fighter's bit alone, both matching the DOS specimens on #234 (A dual-classed Curse or Silver Blades character converted to DOS loses the class he trained out of) at the same moment of change. **Still open**: what $20A3 does once the new class passes the old level -- PHILIPPE's fighter is level 1, one short of it -- so the old class's slot being frozen at zero rather than restored is CONFIRMED and the *regaining* half stays PROBABLE (#256 (The neutral record has nowhere to put a dual-classed character's former levels), M2)", candidate=False), 'electrum': Field(offset=191, size=2, kind=<Kind.U16LE: 'u16le'>, name='electrum', label='Electrum', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 100 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), 'exceptional_strength': Field(offset=26, size=1, kind=<Kind.U8: 'u8'>, name='exceptional_strength', label='STR %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="98 -> '18/98'", candidate=False), 'experience': Field(offset=232, size=3, kind=<Kind.UINT_LE: 'uint_le'>, name='experience', label='XP', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='24-bit LE. After one orc fight the party holds 17 each and LADY KATHERINE 8 -- non-zero and differing, which is what confirms it', candidate=False), 'flags_0b8': Field(offset=184, size=1, kind=<Kind.U8: 'u8'>, name='flags_0b8', label='Flags', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="bit 7 is the real 'this is an NPC or a monster' flag, and bit 0 records that an ability score was altered at the trainer. npc_party.d64 splits three players from five NPCs exactly on bit 7; the code counts player characters with it and enforces CMP #$06, which is the six-PC party limit in code rather than in anecdote; NPC money is zeroed by it. Bit 0 is set by GEN $155D straight after INC/DEC $6B14,X and cleared again if the change is cancelled. **Nothing anywhere reads bit 0 back**, so the forum rumour that altering a score carries a penalty in play has no code behind it on this port. **Bit 0 is the only meaning the low bits have for a player character, and that is not the whole byte.** For a character the engine drives -- bit 7 set -- the low seven bits are his morale instead, stored halved: `COMBAT $211C` masks them with AND #$7F and doubles them before comparing, and DOS keeps the same byte in the same encoding at `field_83_87`'s control byte -- #303 (The DOS record may hold the NPC flag that the conversion reports as having nowhere to go). npc_party.d64's five NPCs read $80, $B1 and $B2 twice more, none of which is bit 0 set on any reading", candidate=False), 'gap_01b': Field(offset=27, size=5, kind=<Kind.RAW: 'raw'>, name='gap_01b', label='unallocated @0x01b', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_06c': Field(offset=108, size=5, kind=<Kind.RAW: 'raw'>, name='gap_06c', label='unallocated @0x06c', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_088': Field(offset=136, size=16, kind=<Kind.RAW: 'raw'>, name='gap_088', label='unallocated @0x088', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_0b7': Field(offset=183, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0b7', label='unallocated @0x0b7', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_0ce': Field(offset=206, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0ce', label='unallocated @0x0ce', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_0d1': Field(offset=209, size=4, kind=<Kind.RAW: 'raw'>, name='gap_0d1', label='unallocated @0x0d1', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_0d7': Field(offset=215, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0d7', label='unallocated @0x0d7', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_0f4': Field(offset=244, size=10, kind=<Kind.RAW: 'raw'>, name='gap_0f4', label='unallocated @0x0f4', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_101': Field(offset=257, size=11, kind=<Kind.RAW: 'raw'>, name='gap_101', label='unallocated @0x101', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gap_11c': Field(offset=284, size=4, kind=<Kind.RAW: 'raw'>, name='gap_11c', label='unallocated @0x11c', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), 'gems': Field(offset=197, size=2, kind=<Kind.U16LE: 'u16le'>, name='gems', label='Gems', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 10 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), 'gold': Field(offset=193, size=2, kind=<Kind.U16LE: 'u16le'>, name='gold', label='Gold', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fell for all six when they bought equipment', candidate=False), 'hp_current': Field(offset=281, size=2, kind=<Kind.U16LE: 'u16le'>, name='hp_current', label='HP now', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE, and genuinely current hit points rather than a second copy of the maximum: GEN $0BD0 initialises it from hp_max, and both the trainer and the drain routine move it independently afterwards. It equals hp_max in every specimen only because no wounded character has yet been exported. Note it lies beyond the 256 bytes a save slot holds, so it exists in an export and not in a save', candidate=False), 'hp_lost_to_drain': Field(offset=162, size=1, kind=<Kind.U8: 'u8'>, name='hp_lost_to_drain', label='HP lost to drain', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='hit points removed by level drain, restored alongside 0x0A1', candidate=False), 'hp_max': Field(offset=118, size=2, kind=<Kind.U16LE: 'u16le'>, name='hp_max', label='HP max', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE. 11 = 9 rolled + 2 CON. The high byte was long read as filler because no character has yet exceeded 255 hit points; the drain routine in SPELLE02 decrements the pair, which is what settles the width', candidate=False), 'hp_rolled': Field(offset=237, size=1, kind=<Kind.U8: 'u8'>, name='hp_rolled', label='HP rolled', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='the accumulated hit-die rolls, without the constitution bonus. hp_max at 0x076 is this plus level x the bonus, recomputed from here at every training -- so this is the stored half and hp_max the derived one, not the other way round. GEN $2037 adds one roll of the class hit die (GEN $20A7: d4, d8, d6, d10 in class-bit order) and GEN $2079 writes hp_max from it. Confirmed on twenty-nine trainings across four classes and three constitution scores; the roll itself is a die and derives from nothing', candidate=False), 'identity_pair': Field(offset=230, size=2, kind=<Kind.RAW: 'raw'>, name='identity_pair', label='Identity pair', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="two bytes GEN draws from the random generator at character creation and never rewrites: $0C01 JSR $2D88 / STA $6BE6, $0C07 JSR $2D88 / STA $6BE7. CONFIRMED that nothing on the C64 reads them back: a census of 589 files finds no reference beside those two stores (plus a copy of the same code in the boot disk's POOLRE), and three load watchpoints on $6BE6-$6BE7 in the running game never moved except in step with the unreferenced $6BE4-$6BE5 beside them, while the experience control moved alone. The add screen's own duplicate test (GEN $1897) compares a candidate's name against every occupied slot's name and nothing else -- in the running game a different character under a party member's name was starred and refused, and the same character under a new name was let in carrying the identical pair, unnoticed. This is the C64 home of DOS 0x0AB, the byte the DOS engine uses for the same tie-break, and it is Pool of Radiance's alone: Curse of the Azure Bonds' and Secret of the Silver Blades' GEN never write it, and both hold 00 00 in every shipped party. #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it), docs/170-c64-identity-pair.md", candidate=False), 'infravision': Field(offset=213, size=1, kind=<Kind.U8: 'u8'>, name='infravision', label='Infravision (tens of feet)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='6 for every dwarf/elf/half-elf, 0 for every human, across 12 specimens -- i.e. 60 feet', candidate=False), 'intelligence': Field(offset=21, size=1, kind=<Kind.U8: 'u8'>, name='intelligence', label='INT', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16 in specimen', candidate=False), 'inventory': Field(offset=288, size=256, kind=<Kind.RAW: 'raw'>, name='inventory', label='Items carried', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="sixteen item slots of sixteen bytes, which is goldbox/items.py's ITEM_SIZE and ITEMS_PER_CHARACTER and the same 16 x 16 page the save file gives each character at $5900 + slot * $100. The shopping trip decoded the slot format field by field; slot +0 zero means empty, +4 is the plus and +10 the quantity. Pinning the extent here matters because it leaves 0x11C-0x11F **outside** the item area and outside the roster block, which ends at 0x11B -- four bytes still unaccounted for. Kept RAW because goldbox/items.py is where an item is read", candidate=False), 'item_effects': Field(offset=173, size=10, kind=<Kind.RAW: 'raw'>, name='item_effects', label='Character Traits', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="ten trait slots -- racial abilities and readied passive items. **Not the save's active effects**, which is what this field was called until P3-EFFECTS.D64 was saved with twenty-six spells running and every character's ten slots came out exactly as they went in. Nothing here has a duration and nothing here expires; the running effects are four 64-entry arrays in SAVEDGAME0 (docs/133-active-effects.md). The two share one code namespace, which is why one table names both. **The namespace is named**: the DOS guide enumerates ids 1-127 and goldbox/traits.py carries the whole table, 44 of them CONFIRMED because a MON* record or a saved item carries the id on exactly the creature or item its meaning demands -- AHNKHEG 121 'anhkheg acid squirt', TROLL 100 and 101, WIGHT 96 'silver or magic' against WRAITH 123 'silver does half', which is the Monster Manual distinction between them. The rest are PROBABLE: the guide names them and no C64 record exercises them. Three overlays loop LDX #$09 over it, and XAVIER carrying 107 in the first slot and 89 in the tenth proves the extent. GEN $0BF3 seeds it per race from the table [1, 0, 107, 0, 124, 0, 0, 0], **indexed by the race byte itself**: race is 1-based, so elf (2) is born with 107 and half-elf (4) with 124, and the leading 1 sits at index 0 where no created character reaches it. That retires the old reading of the 1 as a dwarf's seed -- MAGNUS, a dwarf, has an empty trait block.\n**The seeding is per title, and so is the namespace (#31 (Cold-read Curse and Silver Blades for the fields the editor shows)).** The read is the same three instructions in all three measured titles -- LDX <staging>+0x072 / LDA <table>,X / STA <staging>+0x0AD -- and what differs is how many slots it fills and what it fills them with. Curse GEN $24EA seeds three from $24FF, $2506 and $250D; Silver Blades GEN $0C4B seeds two from $0C5B and $0C62, and both clear all ten first where Pool of Radiance does not. **Curse's codes are Pool of Radiance's** -- dwarf 26, 47, 97, gnome 18, 48, 97, elf 107, half-elf 124, every one landing on the race its Pool of Radiance name demands. **Silver Blades' are not**: its elf is seeded 95 and its half-elf 18, which read as 'fights on from -6 to 0 hit points' and a gnome's bonus against kobolds. Four ids agree (26, 47, the gnome's 48 and the paladin's 45) and five do not (95, 18, 7, 92 and the ranger's 105, where Curse GEN $2515 writes 134), so it is a reassignment and not an offset. The class seed is written after the racial one, by Silver Blades GEN $0FF0. #186 (The character sheet gives a Silver Blades elf a Pool of Radiance ability) gave Silver Blades its own table in goldbox/traits.py: the six codes an approved string already covered are named -- 18 and 95 borrowing the wording of Pool of Radiance's 124 and 107 -- and 7, 92 and 105 show their number, because nobody has established what they mean. tools/coldread.py traits reads the tables off any title's own disks.\nIt shares storage with item byte +14 -- SPELLE04 $ADD4 copies a readied passive item's +14 verbatim into a free slot -- and shares its meaning only for **passive** items, which item byte +15 bit 7 marks. CLOAK OF DISPLACEMENT reads +14 89 / +15 $85 and 89 is 'displaced'; TWO-HANDED SWORD +1 +3 VS UNDEAD reads 3 / $88 and 3 is 'wielding an undead-slaying weapon'. A consumable's +14 is a spell id instead: POTION OF HEALING reads 85 / $00 and 85 is a level drain only in the effect table", candidate=False), 'jewelry': Field(offset=199, size=2, kind=<Kind.U16LE: 'u16le'>, name='jewelry', label='Jewelry', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 10 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), 'level': Field(offset=160, size=1, kind=<Kind.U8: 'u8'>, name='level', label='Level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="character level. Promoted from PROBABLE on the game's own data: **twenty-one shipped MON* records state their level in their name**, and 0x0A0 agrees with the name in nineteen of them -- 1ST LVL THIEF 1, 2ND LVL CLERIC 2, LEVEL 3 MU 3, 4TH LVL FIGHTER 4, LEVEL 5 CLERIC 5, 6TH LVL FIGHTER 6, 7TH LVL DW FIGHTER 7, 8TH LVL FIGHTER 8. The two that differ are both 6TH LVL THIEF (MON33, MON5D), where the byte reads 7 **and so does the per-class array**, so the disagreement is between the designer's label and his data rather than between two fields. Corroborated three further ways: the 1989 BASIC editor on poolce.d64 and the QUANTUM LEAPER machine-code trainer both poke exactly this byte as LEVEL; across the eight characters of npc_party.d64 it equals the per-class level at four distinct values; and docs/80 reads the drain routine writing it down from the per-class array. Every early specimen was level 1, which is why it long read as a constant 01. Still not distinguishable from 'the single class's level' -- no multi-class specimen above level 1 has been seen", candidate=False), 'level_cleric': Field(offset=202, size=1, kind=<Kind.U8: 'u8'>, name='level_cleric', label='Cleric level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every cleric, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels)', candidate=False), 'level_fighter': Field(offset=204, size=1, kind=<Kind.U8: 'u8'>, name='level_fighter', label='Fighter level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every fighter, 0 otherwise. One entry of the per-class level array (the per-class levels). Previously guessed to be an exceptional-strength flag, because the only fighters seen then were the only characters with exceptional strength', candidate=False), 'level_knight': Field(offset=205, size=1, kind=<Kind.U8: 'u8'>, name='level_knight', label='Class-level slot 4 (druid; knight in the Krynn titles)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="slot 4 of the per-class level array, i.e. class_bits bit 4 (16). **In the Realms titles this is the druid slot.** The engine's own item-restriction bit array -- the same bit order this array is indexed by -- reads 0 magic-user, 1 cleric, 2 thief, 3 fighter, 4 druid, 5 monk, 6 paladin, 7 ranger. Bits 0-3 are CONFIRMED here from six specimens and bits 6-7 from Curse's pre-generated paladin and ranger, so six of the eight positions are checked and the two left over are druid and monk in that order. Pool of Radiance never instantiates a druid, so the byte is zero in every specimen we hold; the name of the slot is PROBABLE and the *ordering* that puts druid in it is CONFIRMED.\nThe field keeps the name `level_knight` because the Death Knights of Krynn editor calls it that -- it cycles nine class names over the eight-byte array at 0x0C9 as MAGE, CLERIC, THIEF, FIGHTER, KNIGHT, -, PALADIN, RANGER, and bounds the array with CPY #$08. Knights of Solamnia are a Krynn class in a world with no druids, so the Krynn games reuse the slot. The shipped Champions and Death Knights parties carry class_bits 0x10 with the whole array zero, so no record has yet shown a value in this byte on any title", candidate=False), 'level_magic_user': Field(offset=201, size=1, kind=<Kind.U8: 'u8'>, name='level_magic_user', label='Magic-user level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="1 for every magic-user, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels).\n**The array is permuted between the ports and a save converter must permute it back.** The C64 indexes 0x0C9-0x0D0 by the bit number in class_bits -- magic-user, cleric, thief, fighter, druid, monk, paladin, ranger -- and DOS indexes the same eight bytes by the class number at 0x073: cleric, druid, fighter, paladin, ranger, magic-user, thief, monk. Six specimens settle the C64 side: MALCYON (class 5, magic-user) fills slot 0, ROLAND (class 0, cleric) slot 1, BRUTUS (class 2, fighter) slot 3, and LADY KATHERINE (class 16, magic-user/thief) slots 0 and 2. A converter that copies the array straight across turns every cleric into a druid.\n**CONFIRMED**: twenty-nine trainings raised exactly the entry of the class trained and no other, and LADY KATHERINE ended magic-user 6 / thief 9 with two different non-zero entries -- which is what separates this array from 'the single class's level'. docs/119-test-party.md", candidate=False), 'level_paladin': Field(offset=207, size=1, kind=<Kind.U8: 'u8'>, name='level_paladin', label='Paladin level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="slot 6 of the per-class level array, class_bits bit 6 (64). CONFIRMED on SSI's own pre-generated Curse party, whose paladin holds 0x0CF = 5 with class_bits = 64 (docs/116 sec 2.3). Zero in every Pool of Radiance specimen -- the game names PALADIN in its class table and never instantiates one", candidate=False), 'level_ranger': Field(offset=208, size=1, kind=<Kind.U8: 'u8'>, name='level_ranger', label='Ranger level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="slot 7 of the per-class level array, class_bits bit 7 (128). CONFIRMED on Curse's pre-generated ranger, 0x0D0 = 5 with class_bits = 128. Silver Blades and Death Knights use both this slot and the paladin one; Pool of Radiance leaves both zero", candidate=False), 'level_thief': Field(offset=203, size=1, kind=<Kind.U8: 'u8'>, name='level_thief', label='Thief level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every thief, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels)', candidate=False), 'levels_drained': Field(offset=161, size=1, kind=<Kind.U8: 'u8'>, name='levels_drained', label='Levels drained', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="how many levels undead have drained, not a second copy of the level. The pair is current-plus-delta, which is why no 'true level' was ever found. SPELLE02 computes hp_max / total levels, loops that many times doing DEC $6B76 / DEC $6BED / INC $6BA2 / DEC $6C19, then INC $6BA1 and DEC $6BC9,X. RESTORATION in SPELLE04 reverses it exactly and prints string 94, which SPELLN00 gives as IS RESTORED", candidate=False), 'missile_attack_adjustment': Field(offset=236, size=1, kind=<Kind.I8: 'i8'>, name='missile_attack_adjustment', label='Missile attack adjustment', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="what dexterity is worth to hit with a ranged weapon, signed: $FF is -1. **CONFIRMED from both sides of the byte.** COM.PREP $1633 writes it -- `LDX $6B17` (dexterity) / `LDA $1682,X` / `STA $6BEC` -- out of a sixteen-entry table that is the AD&D 1st edition missile attack adjustment exactly: -3 at DEX 3, -2 at 4, -1 at 5, nothing from 6 to 15, +1 at 16, +2 at 17, +3 at 18, and on past 18 to +5 at 25 for a magically raised score. LIBRARY $36A8 reads it -- `LDA $6BEC` / `ADC $6C0E` -- and adds it to the roster's THAC0 when the readied weapon's type +14 has bit 1, ranged; the block below it adds the strength bonus instead when bit 2 is set. COMBAT $0899 adds it again in the attack roll.\n**It is a cache, and it goes stale**, because COM.PREP is the combat-preparation overlay: the byte is rewritten when a fight starts and at no other time anybody has found. Measured in the running game -- $7F poked into all six records of the PORSAVE13 party read back as 3, 1, 0, 0, 0, 0 four steps later in the Slums ambush, against dexterities 18, 16, 13, 12, 15, 14; and un-readying MALCYON's darts moved nothing. 96 of the 114 character records on the player's 19 save disks hold the table value for their own dexterity; all 18 that disagree hold a value *below* it, and every one belongs to a character whose dexterity wish edited upwards with no fight since. MALCYON is the clearest: 1 at DEX 16 in PORSAVE2, still 1 after the edit to 18 in PORSAVE4-9, and 3 in PORSAVE11 after the next fight.\nThis byte was read for a long time as spell state, on the observation that it went 0 -> 1 after combat for MALCYON and LADY KATHERINE 'and nobody else -- exactly the two spellcasters'. They are also exactly the two characters with a dexterity of 16; the other four are 12 to 15, whose table entry is zero, so there was nothing for their byte to do. #202 (Name record offset 0x0EC, which is what moves a THAC0 when darts are readied)", candidate=False), 'movement': Field(offset=159, size=1, kind=<Kind.U8: 'u8'>, name='movement', label='Movement', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='12 in all three specimens', candidate=False), 'name': Field(offset=0, size=20, kind=<Kind.ASCII_NUL: 'ascii_nul'>, name='name', label='Name', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="NUL-padded; 'BRUTUS'", candidate=False), 'party_order': Field(offset=269, size=1, kind=<Kind.U8: 'u8'>, name='party_order', label='Party order', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="the only byte where an export and the roster block disagree. In a roster block it is the record slot index, which is how the combat code finds a combatant's record; 8 means not in a party. It was read as marching order in an export, because across a six-character party the values form a complete 0-5 permutation -- but #160 (The automapper and the editor list the party backwards) showed the marching order is carried by the slot arrangement itself (the party lists from the highest occupied slot down) and no order table exists anywhere in the machine, and the slot indices of a packed six-character party are also a complete 0-5 permutation. So that evidence cannot separate the two readings. Treat it as the slot index until an export is taken from a party whose occupied slots are not 0..n-1", candidate=False), 'platinum': Field(offset=195, size=2, kind=<Kind.U16LE: 'u16le'>, name='platinum', label='Platinum', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='changed for three characters across a shopping trip', candidate=False), 'portrait_body': Field(offset=255, size=1, kind=<Kind.U8: 'u8'>, name='portrait_body', label='Portrait body', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='index into the BODY* files, the same way. Head and body are adjacent and independent', candidate=False), 'portrait_head': Field(offset=254, size=1, kind=<Kind.U8: 'u8'>, name='portrait_head', label='Portrait head', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='index into the HEAD* files on the game disks, in hex: 0x2D is HEAD2D. All eleven values across our exports name a file that exists, and the odds of that happening by chance are negligible -- the ids used include $2D, $43, $44 and $67, not just small numbers. BRUTUS carries the same pair on two unrelated disks, and the two female half-elves share a portrait', candidate=False), 'race': Field(offset=114, size=1, kind=<Kind.U8: 'u8'>, name='race', label='Race', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="1-based: DWARF=1 ELF=2 GNOME=3 HALF-ELF=4 HALFLING=5 HALF-ORC=6 HUMAN=7 MONSTER=8. BRUTUS/ZARRADA=7 human, LARA=2 elf. HALF-ORC is real but NPC-only: it is not on the character-creation menu, and the only two half-orcs in the game are the named NPCs MACE and NORRIS THE GRAY. Two values outside that list matter. **0 is the commonest race in the game**, carried by 75 of the 135 distinct monster records -- every generic creature and some humanoid NPCs -- so it reads as 'not applicable' rather than as a race, and a 0 is not evidence that a record was tampered with. **8 (MONSTER) is used by nothing anywhere**, player or monster: the table enumerates it and the game never instantiates it, the same way it names DRUID, PALADIN, RANGER and MONK", candidate=False), 'region_0e4': Field(offset=228, size=2, kind=<Kind.RAW: 'raw'>, name='region_0e4', label='unknown @0x0E4', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note="between the strength gate and the identity pair. Referenced by nothing in 589 files and $00 $00 in every player character; $FF $FF in the five shipped MON* NPC records, which used to be read as two bytes of an eight-byte NPC marker together with 0x0B9, 0x0BA and others. **There is no marker**, only the author's $FF fill of the monster files intersected with what a player character happens to hold zero at -- see strength_bonus_flag and dual_class_slot, two of the eight that turned out to be real fields once read rather than assumed. #224 (0x0B9 and 0x0BA are documented both as an NPC marker and as the dual-class slot), #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it). Not a candidate region: unlike the identity pair it once sat beside, it shows no structure of its own -- zero in every player character we hold, BRUTUS included", candidate=False), 'region_220': Field(offset=544, size=36, kind=<Kind.RAW: 'raw'>, name='region_220', label='unknown @0x220 (record tail)', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='E4 A0 02 6B 04 05 06 07 08 20 A0 0B 20 0D E9 06 10 11 00 0F 08 0E 0E 08 0E 0E 0E 0E 0F 08 0E 0E 00 0E 0E 0E - densest region in the specimen; runs to the final byte of the record', candidate=True), 'roster_in_use': Field(offset=256, size=1, kind=<Kind.U8: 'u8'>, name='roster_in_use', label='Roster in use', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="record 0x100-0x11F **is** the SAVEDGAME1 roster block. An exported .chr and the roster page agree in 31 of those 32 bytes for every character, differing only at 0x10D. Two agents reached that independently -- one from LIBRARY $3189/$319A, which copies $8300 + N*$20 in and out, the other from matching exports against saves by name. So a record is four blocks the game saves separately: 256 + 32 + 256 + 36 = 580.\n**This is the character's STATUS, and the name is now wrong.** Zero means the slot is empty -- DROP CHARACTER writes it and nothing else (#104 (A converted DOS party arrives with the template save's spare characters still in it)) -- and any other value is a state the sheet puts into words. LIBRARY $38BE draws that line: `LDA $6C00 / AND #$07 / CLC / ADC #$29 / TAX / LDA #$16 / JMP $322D`, and the string table at LIBRARY $3439 (low) and $347B (high) holds ids $29-$30 as HITPOINTS, OK, GONE, DEAD, DYING, UNCONSIOUS -- the game's own spelling -- RUNNING, STONED. So the low three bits are the state and **bit 7 is a separate flag, masked off for display**: $1BF6 skips a slot whose bit 7 is set when it sums the party's strength, and the party panel draws such a name in **red**. That colour was recorded here as grey and it is not: LIBRARY $3E47 is `LDX $6E34 / CMP #$80 / BCC + / LDX #$02`, and 2 is red. **CONFIRMED, and the flag is independent of the low three bits** (#235 (Two unattributed DOS byte ranges in the combat tail are dropped converting to C64, and nobody knows what they hold)): $81 -- OK with bit 7 set -- draws OK on the sheet and red in the panel, and $05 -- unconscious with bit 7 clear -- draws UNCONSIOUS on the sheet and the panel's ordinary colour, both in one boot beside three controls at $01. That is the same effect DOS gets from its own separate byte at DOS 0x10D, where 0 draws the name red, so the two ports hold one flag at opposite polarities.\n**The panel is drawn from roster slot 7 down to 0**, so panel position 0 is the highest occupied slot: $3E21 is `LDA #$07 / STA $6DB4`, $3E30 calls $3189, which copies $8300 + $6DB4 * $20 into the staging page, and $3EAD decrements it. A run that reads the sheets back by panel position and calls the position a slot gets every staged value against the wrong character.\n1 OK, $82 GONE, $83 DEAD, $84 DYING, $85 UNCONSIOUS, $86 RUNNING, $87 STONED. Each is written by a routine that says what it means: POST.COM $216A sets $82 after comparing the age at 0x074 against a per-race maximum at $218E; COMBAT $0C4B turns $84 into $83 when the bleed counter $A6C0,X reaches 10, and DUNGEON $2626 chooses $85 over $83 by the same ten points below zero, which is AD&D's 0 to -9 dying and -10 dead; COMBAT $2161 walks slots 7 to 0 turning every $84 into $85 as the fight ends, which is the party binding its dying; COM.PREP $0F1F writes $86 to the side that is fleeing; SPELLE01 $AC38 writes $87 beside message 6, TURNS TO STONE.\n**Measured in the running game, not only read.** One character in PORSAVE13's Slums ambush was wounded to 1 hit point through the monitor and nothing else touched; an orc took him to 0 and the byte went 01 -> 84 on that turn, then 84 -> 85 when the party won, and 85 is what the game wrote to the save disk. Loading that disk, his sheet reads UNCONSIOUS. tools/statusdrive.py.\nFour values carry an upper bit that is not bit 7 and are not explained: SPELLE04 $AA11 writes $03 beside creature type 4, undead -- Animate Dead, a DEAD thing with bit 7 clear so it still acts -- and $09 where it forces hit points to 1; SPELLE01 $ACDA writes $11 beside GETS BACK UP; SPELLE02 $AA05 writes $21. All four mask to OK or DEAD, so nothing a player sees separates them.\nThe DOS enumeration is a different one at a different offset: 0 Okay, 1 Animated, 2 tempgone, 3 Running, 4 Unconscious, 5 Dying, 6 Dead, 7 Stoned, 8 Gone, at DOS 0x10C. Both are real; converting between them is a table, not an alignment", candidate=False), 'roster_movement': Field(offset=283, size=1, kind=<Kind.U8: 'u8'>, name='roster_movement', label='Movement (roster)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="roster +0x1B, the movement rate as encumbered. Long recorded as '12 in every specimen', which held only because every specimen was the same six characters: PORSAVE10's exports read 9 in banded mail", candidate=False), 'roster_tail': Field(offset=272, size=9, kind=<Kind.RAW: 'raw'>, name='roster_tail', label='Roster +0x10..+0x18', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="roster +0x10 the armour bonus, then +0x11 to +0x18 the **current attack form** -- the running copy of attack_forms at 0x0D9, in the engine's own order: two attack counts, two dice counts, two die sizes, two damage bonuses. All decoded in goldbox/savegame.py, and kept RAW here because the roster is the place to read them.\nThe die-size byte is +0x15, which this project called EQUIPMENT for a long time because it 'rises with what is readied'. It does: across thirteen of Donald's save disks it reads 3 for MALCYON's dart, 6 for LADY KATHERINE's short sword, 6 for ROLAND's mace, 8 for three long swords and 2 for every character with nothing readied -- 1d3, 1d6, 1d6+1, 1d8 and the unarmed 1d2, matched to the ITEMS table entry of the item each of them had equipped.\nThe first byte does NOT line up with DOS: DOS spends it on armour class from behind and the C64 on the armour bonus, 48 + bonus, which goldbox/savegame.py established by putting armour on (none 48, leather 50, banded 54, the AD&D bonuses exactly, and unmoved by a shield). Read the C64 byte the DOS way and those become 12, 10 and 6, two worse than each armour's real class and meaning nothing. docs/127-community-formats.md", candidate=False), 'save_breath': Field(offset=157, size=1, kind=<Kind.U8: 'u8'>, name='save_breath', label='Save vs breath weapon', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 17, cleric 16', candidate=False), 'save_paralysis': Field(offset=154, size=1, kind=<Kind.U8: 'u8'>, name='save_paralysis', label='Save vs para/poison/death', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="fighter 14, cleric 10 -- both match the AD&D 1e L1 tables.\nAll five saves at 0x09A-0x09E are now **derivable**, which goldbox/levels.py's docstring says they were not: the stored number is the class table row for the character's level in that class, taking the **best number in each column** across every class it holds, minus the AD&D constitution bonus (+1 per 3.5 points) when the character is a **dwarf, gnome or halfling**. 78 of the 79 distinct records on this machine satisfy that exactly; the one miss is MAD MAN, a level-8 NPC carrying the level-1 fighter row. The multi-class rule shows on its own: LADY KATHERINE (magic-user 1 / thief 1) reads 13 12 11 15 12, which is neither class's row but the column-wise minimum of the two. The racial adjustment shows as a uniform shift -- MAGNUS the dwarf (CON 13) is 3 lower than an identical human fighter in Donald's own party, and HOGARTH and TANARAKIS on SSI's shipped demo party agree with no adjustment at all. Only the +3/+4/+5 bands are exercised and the high-level cases all come from npc_party.d64, whose values this project treats as worthless (docs/90), so tests/test_communityformats.py asserts the rule on the fixtures alone, which are clean. docs/127-community-formats.md", candidate=False), 'save_petrification': Field(offset=155, size=1, kind=<Kind.U8: 'u8'>, name='save_petrification', label='Save vs petrify/polymorph', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 15, cleric 13', candidate=False), 'save_spell': Field(offset=158, size=1, kind=<Kind.U8: 'u8'>, name='save_spell', label='Save vs spell', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 17, cleric 15', candidate=False), 'save_wands': Field(offset=156, size=1, kind=<Kind.U8: 'u8'>, name='save_wands', label='Save vs rod/staff/wand', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 16, cleric 14', candidate=False), 'sex': Field(offset=214, size=1, kind=<Kind.U8: 'u8'>, name='sex', label='Sex', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='0 = male, 1 = female. LADY KATHERINE is 1 and confirmed female by Donald; LARA SPELLSWORD and ZARRADA are also 1', candidate=False), 'silver': Field(offset=189, size=2, kind=<Kind.U16LE: 'u16le'>, name='silver', label='Silver', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='25-26 each after looting orcs, where it was 0 before', candidate=False), 'size_small': Field(offset=153, size=1, kind=<Kind.U8: 'u8'>, name='size_small', label='Size', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="0 small, 1 large -- the only two the game offers, and the two its ALTER > ICON > SIZE menu shows. The only byte in the stored 256 that separates dwarves, gnomes and halflings from humans, elves and half-elves -- the AD&D size categories exactly. This is the icon large/small flag the Gold Box Companion exposes. Donald confirmed MAGNUS, a dwarf, shows as small in game, and that the visible difference is the head: a small character's body is the same size and its head is smaller, which is why the icon looks small without being smaller. 0 for every dwarf, gnome and halfling and 1 for every elf, half-elf and human in all 79 records we hold. The DOS record splits this in two -- an icon *dimension* (1 = one square) at the offset this one aligns to and an icon *size* (1 small, 2 medium) much later -- and the C64 byte sits at the first offset carrying the second meaning, one lower. Which is why the DOS documentation cannot be transcribed onto this byte", candidate=False), 'spells_castable': Field(offset=238, size=6, kind=<Kind.RAW: 'raw'>, name='spells_castable', label='Spells castable', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="how many spells of each level the character may memorise, one byte per spell level, **nibble-packed**: cleric in the high nibble, magic-user in the low. ROLAND, a level-1 cleric with wisdom 16, reads $30 -- three first-level spells, one base plus two for wisdom, which is exactly what his sheet allows. MALCYON and LADY KATHERINE, both level-1 magic-users, read $01. The three fighters read zero throughout. Found while surveying Curse of the Azure Bonds, which uses the same offsets; the docs had this down as not stored anywhere.\nPromoted from PROBABLE on three further lines (docs/127). **Multi-class specimens set both nibbles at once**, which no single-class specimen can distinguish from two separate bytes: TANARAKIS (cleric 1 / magic-user 1) and DELILIA (cleric 1 / fighter 1 / magic-user 1) both read $31. The wisdom bonus is exact at level 6, where the base row is no longer 1: DIRTEN, cleric 6 with wisdom 16, reads 5/5/2 -- AD&D's 3/3/2 plus +2/+2/+0 -- and SIMON, cleric 6 with wisdom 18, reads 5/5/3. Fifteen casters across nine parties agree; the one exception is DELILIA, wisdom 13 with three first-level slots where the rule gives two, which is what a cache not recomputed on an ability change looks like. And the DOS record carries the same six quantities as six separate bytes -- SPL_Count_CL_1..3 then SPL_Count_MU_1..3 at DOS 0x0B2 -- in the position this offset aligns to; 66 real DOS records agree. 0x0F1-0x0F3 are spell levels 4-6 and are zero in all 79 C64 records, which is what a game that stops at third-level spells should look like", candidate=False), 'spells_known': Field(offset=120, size=7, kind=<Kind.RAW: 'raw'>, name='spells_known', label='Spellbook', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a bitmask of the spells the character KNOWS, indexed by spell id: bit (id & 7) of byte 0x078 + (id >> 3). Confirmed on every caster we hold -- clerics know every spell of every level they can cast (8 at level 1, 24 at level 6) and magic-users know a subset, which is how AD&D 1st edition works. No cleric has a magic-user id set and no magic-user has a cleric one. MALCYON, a starting mage, knows detect magic, read magic, shield and sleep. Distinct from spells_memorised at 0x020, which is what is currently prepared. Bit 0 of 0x078 is deliberately unused: the QUANTUM LEAPER trainer's LEARN ALL SPELLS writes $FE here and $FF to the other six, i.e. spell id 0 does not exist.\n**Seven bytes is Pool of Radiance's width, not the engine's**, and the field stops here because seven is what this game reads and what goldbox/spells.py encodes. The later titles continue into spells_known_high at 0x07F; each title's width is goldbox.spells.SpellTable.spellbook_size, measured in that title's own code. docs/127-community-formats.md", candidate=False), 'spells_known_high': Field(offset=127, size=9, kind=<Kind.RAW: 'raw'>, name='spells_known_high', label='Spellbook (high ids)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the rest of the spellbook bitmask, for the titles whose spell list outgrew 56. The same indexing continued -- byte 0x078 + (id >> 3) -- so 0x07F carries ids 56-63 and 0x087 ids 120-127. **Zero throughout Pool of Radiance**: no character in any Pool of Radiance save sets even 0x07F, which is why this read as a gap for so long. It is declared beside spells_known rather than folded into it so that the seven bytes this game uses stay their own field, and because `set_raw` is width-exact: widening one declared field would have changed what every writer emitted in one step, with nothing to catch a caller that had not been looked at. The writers have since been taken across deliberately -- `c64_codec`, `yaml_io` and `levelup` all encode the open title's width over both fields (#85 (A YAML export of a Curse or Silver Blades party stops at spell 55), #87 (Levelling a Curse or Silver Blades caster offers only Pool of Radiance's spells)), filling only as far as that title reaches.\nPer title, each measured in that game's own code and not carried across from another: **Pool of Radiance 7 (CONFIRMED)** -- 56 bits for a 56-spell list, of which id 56 RESTORATION has no bit, and the QUANTUM LEAPER trainer writes exactly seven. **Curse of the Azure Bonds 13 (CONFIRMED)** -- CAMP $2A25 builds the memorise list by walking spell ids from 1 with INY / CPY #$65 / BCC, so it stops after id 100, and reads the mask as TYA / LSR x3 / TAX / LDA $7C78,X; id 100 puts X at 12, so the game itself reads 0x078-0x084. GEN $232A writes there too, ORing $E0 into $7C81 and $01 into $7C82 to grant the four first-level druid spells 77-80. Whether 0x085-0x087 are also mask in Curse is UNKNOWN -- its GEN has no clear loop. **Secret of the Silver Blades 16 (CONFIRMED)** -- GEN $09DC clears sixteen bytes (LDX #$0F / LDA #$00 / STA $7C78,X / DEX / BPL), GEN $18C9 walks the same sixteen, and CAMP $2871 is Curse's memorise loop with its ceiling moved to CPY #$76, id 117, reaching 0x086.\n**Every address in the paragraph above was corrected on 2026-09-02 (#31 (Cold-read Curse and Silver Blades for the fields the editor shows))**, having been written at each overlay's PRG header base -- $1000, $1220, $3000, $4000 -- where GEN and CAMP both run at $0800 in all three titles. The instructions and the widths were right and the addresses named bytes in the middle of something else, so nobody checking one with tools/overlay.py, whose --base is $0800, would have found it. The base is settled by the operands: Silver Blades' GEN $18C9 reads its own scratch at $1BFD and its ceiling table at $17D0, and Curse's CAMP $2A25 calls $1CE9 and stores to $2A1F -- every one of them inside the file at $0800 and outside it at the header's address.\n**The near-miss, written down so it is not walked into twice**: Curse's GEN $220F copies 32 bytes out of $7C78 and looks like proof of a sixteen-byte mask. Pool of Radiance's GEN $216B copies the identical 32 out of $6B78, where the mask is seven. A copy wider than the field says nothing about the field. tests/test_curse.py and tests/test_silverblades.py pin all three readings.", candidate=False), 'spells_memorised': Field(offset=32, size=69, kind=<Kind.RAW: 'raw'>, name='spells_memorised', label='Spells memorized', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a packed list of memorised spell ids, highest spell level first. Ids are CONFIRMED against the game's own SPELLN00 table and against spells Donald memorised on purpose: 1 BLESS, 3 CURE LIGHT WOUNDS, 21 SLEEP. Cleric and magic-user ids fall in disjoint ranges; see goldbox/spells.py. **The roster block's +0x03 onwards -- record 0x103-0x10B -- are a per-level count of this list, one byte per spell level, and the earlier retraction of that reading is withdrawn.** COM.PREP $15ED clears nine counters and rebuilds them from this field for every live combatant at the start of a fight: `LDX #$08 / LDA #$00 / STA $6C03,X / DEX / BPL`, then `LDY #$50 / LDX $6B20,Y / BEQ + / JSR $1751 / TAX / INC $6C02,X`. $1751 gives a spell id its level with thresholds $16, $24 and $38 -- goldbox/spells.py's own _GROUPS_POOL boundaries, arrived at from the spell names alone. Nothing else recomputes them and CAMP touches none of them, which is what made PORSAVE4 read 0/0/0 beside a set list: it was saved after memorising and resting, and before the next fight. Watched happening: +0x03 went 0 -> 1 for two casters between the world screen and the first round of one fight, ROLAND's stayed at 3, and the three non-casters stayed at 0 (tools/rosterspellcount.py, #365 (Three roster bytes have no established meaning, and a C64 party converted to DOS is told so with no way to check it)). The most seen in use is 13 bytes, and 0x02D-0x070 is zero in every specimen we hold.\n**69 bytes is what every measured title shares, not the whole list.** This field used to be declared as sixteen and the note used to say the length was unproven and PROBABLE at 21, on the argument that the DOS record allots 21 slots. Both were wrong and are deleted: the width is not a guess and it is not 21. Each title's own CAMP walks the list with a count-down loop whose immediate is the last index, so the width reads straight out of the code -- 'LDX #$50 / LDA $6B20,X' at CAMP $1450 in Pool of Radiance, '#$44 / LDA $7C20,X' at $15E2 in Curse of the Azure Bonds, '#$49 / CMP $7C1B,X' at $1412 in Secret of the Silver Blades, with twelve to sixteen sites in each and one immediate across all of them. So Pool of Radiance has **81 slots at 0x020-0x070**, Curse **69 at 0x020-0x064**, and Silver Blades **74 at 0x01B-0x064** -- a different start as well as a different width, because Silver Blades' GEN copies seven ability bytes back to 0x014 where Curse's copies twelve, leaving 0x01B-0x01F free for spells. All three CONFIRMED; tools/memorisedwidth.py reads them again off the disks. This field is declared as the run they all agree about, and the per-title span is goldbox.c64_codec.RecordShape.memorised, which every reader and the writer both go through. #268 (A character with more than sixteen memorised spells loses the rest, because the layout gives the list sixteen bytes and the game gives it eighty-one)\n**Position within the list carries no meaning.** The engine scans all of it, ignores zeros wherever they fall, draws the list in descending spell id rather than in byte order, and repacks the field forward from 0x020 in that same order once it is finished with it -- by the first time the party makes camp, which is what every save goes through. Measured in the running game on the C64: one spell swept across all sixteen positions of the region as this field was then declared, for a cleric and two magic-users, listed 48 times out of 48, was cast from position 15, and was consumed. A record written with entries at 7, at 3 and 9, and at 15 came back from the game's own save at 0, at 0 and 1, and at 0. So a conversion that keeps the ids and loses the arrangement loses nothing a player can see. Measured on the C64 engine only: the DOS reader's own rule about filling backwards from the end has not been put through the same sweep. #110 (Memorised spells lose their slot positions on the way through the neutral record)", candidate=False), 'strength': Field(offset=20, size=1, kind=<Kind.U8: 'u8'>, name='strength', label='STR', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='18 in specimen', candidate=False), 'strength_bonus_flag': Field(offset=227, size=1, kind=<Kind.U8: 'u8'>, name='strength_bonus_flag', label='Strength bonus gate', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="gates the to-hit and damage bonus a high strength gives: GEN $0B79 writes 1 here at creation (Curse's GEN $0C31 the same at 0x7CE3), and LIBRARY's roster recompute at $375C reads it, indexing strength_index into the AD&D strength tables at $3651/$3670 only when this is non-zero -- 18 gives +1/+2, 21 gives +2/+4, 23 gives +3/+6. Curse of the Azure Bonds makes the same gate at LIBRARY $394B on $7CE3, and its two tables at $3840/$385F are byte-identical to Pool of Radiance's over all 26 rows. CONFIRMED: every engine-made player character on the C64 disks of all three titles reads 1 here, and the running game settled what it is worth -- two converted records identical but for this byte, both 18/75 fighters, came out of the first ambush's roster recompute at THAC0 20 with no damage bonus and at THAC0 18 with +3. Nothing recomputes at load, at BEGIN ADVENTURING or on VIEW, so a record with this zero looks right on the sheet until the party's first fight. `goldbox.c64_codec.write` left it zero until #277 (A DOS character converted to the C64 loses the strength bonus to hit and damage, because 0x0E3 is written zero) and now writes 1, which is what the C64's own character creation writes. Named here by #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it)", candidate=False), 'strength_index': Field(offset=226, size=1, kind=<Kind.U8: 'u8'>, name='strength_index', label='Effective STR', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note='equals STR below 18; 18/80 and 18/81 give 21, 18/98 gives 22 -- the AD&D exceptional-strength bands collapsed to one number. The DOS record has a boolean STR_Bonus at the aligned offset, reading 1 in all 66 DOS specimens; this byte holds 15-22 across ours, so they are two different fields and this reading stands', candidate=False), 'thac0': Field(offset=270, size=1, kind=<Kind.U8: 'u8'>, name='thac0', label='THAC0 current (60 - value)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="current THAC0 including strength and the readied weapon, stored as 60 - THAC0, sitting immediately before the current armour class at 0x10F. Matches the AD&D table on all eleven exports we hold. Like 0x10F it exists only in an export, and it agrees with the SAVEDGAME1 roster's +0x0E for the same character -- so an exported .chr does carry both combat numbers after all, which is worth knowing given the 1989 editor's author reported he could never find either", candidate=False), 'thac0_base': Field(offset=113, size=1, kind=<Kind.U8: 'u8'>, name='thac0_base', label='THAC0 base (60 - value)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="base THAC0, stored as 60 - THAC0, the same encoding the SAVEDGAME1 roster uses for the current value at +0x0E. Matches the AD&D 1st edition table for all twelve of Donald's characters and all six shipped on POOL1; the only three that differ are on the editor-hacked npc_party.d64. This was retired once as 'not THAC0' because MALCYON's sheet shows 20 where the byte reads 39 -- but 39 is 60-21, his base as a level-1 magic-user, and the 20 on screen is the current value after readying a dart. Base and current are different fields in different files.\n**CONFIRMED**: the trainer rewrote it at every one of twenty-nine level-ups, always to the row of the game's own table at GEN $1F1F and, for a multi-class character, to the best of its classes -- GEN $1EF3 keeps a value only when it beats what is already there. docs/119-test-party.md.\n**The DOS build stores a different number for the same character, and it is the table rather than the routine.** GEN $1EF3 clears the byte (SPELLE04 $0CFF writes the zero), walks the four class slots at 0x0C9 and keeps the best row; GAME.OVR:0x1A659 does exactly the same with `mul 11` into DS:0x3C7C, so neither engine clamps and neither leaves the byte stale. The two tables differ: DOS ships 40, THAC0 20, in the magic-user's rows 1-5 and the thief's rows 1-4 where the C64 ships 39, THAC0 21. 190 of 190 DOS records reproduce from the DOS table and every low-level magic-user or thief the C64 engine wrote holds 21; the only C64 records anywhere holding 20 for one are the five disks this project converted from DOS. goldbox.levels.dos_base_thac0 is the DOS number, and #366 (A converted magic-user or thief arrives with the other port's THAC0, because the two ports ship different tables and the conversion copies the byte) is what a player sees", candidate=False), 'thief_climb_walls': Field(offset=171, size=1, kind=<Kind.I8: 'i8'>, name='thief_climb_walls', label='Climb walls %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='85 at L1', candidate=False), 'thief_find_traps': Field(offset=167, size=1, kind=<Kind.I8: 'i8'>, name='thief_find_traps', label='Find/remove traps %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='20 at L1', candidate=False), 'thief_hear_noise': Field(offset=170, size=1, kind=<Kind.I8: 'i8'>, name='thief_hear_noise', label='Hear noise %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='10 at L1', candidate=False), 'thief_hide_in_shadows': Field(offset=169, size=1, kind=<Kind.I8: 'i8'>, name='thief_hide_in_shadows', label='Hide in shadows %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='10 at L1', candidate=False), 'thief_move_silently': Field(offset=168, size=1, kind=<Kind.I8: 'i8'>, name='thief_move_silently', label='Move silently %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='20 at L1', candidate=False), 'thief_open_locks': Field(offset=166, size=1, kind=<Kind.I8: 'i8'>, name='thief_open_locks', label='Open locks %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='25 at L1', candidate=False), 'thief_pick_pockets': Field(offset=165, size=1, kind=<Kind.I8: 'i8'>, name='thief_pick_pockets', label='Pick pockets %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='30 at L1. The eight percentages at 0x0A5-0x0AC come from a nine-level table of eight columns in the engine, in this order, plus a racial row: HOGARTH, a dwarf thief 1, matches the base row plus the AD&D dwarf adjustments in all eight columns, including read languages -5 stored as $FB, and takes no dexterity adjustment at dexterity 17. The half-elf, gnome and halfling specimens do not match their published racial rows, so **how the C64 applies the racial modifier is UNKNOWN**; the per-level progression is not. docs/127-community-formats.md', candidate=False), 'thief_read_languages': Field(offset=172, size=1, kind=<Kind.I8: 'i8'>, name='thief_read_languages', label='Read languages %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='5 at L1', candidate=False), 'turn_class': Field(offset=163, size=1, kind=<Kind.U8: 'u8'>, name='turn_class', label='Undead turning class', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='which row of the AD&D 1e turning table a creature answers to. Non-zero in exactly twelve of the 121 distinct MON* records, every one undead, and it matches the published table on all of them: skeleton 1, zombie 2, ghoul 3, wight 5, wraith 7, mummy 8, spectre 9, vampire 10, with giant skeleton 8 and juju zombie 9. Eleven are named creatures; the twelfth is FERRAN MARTINEZ (MON13), an NPC carrying 9, the spectre row. **This offset was challenged and it survived.** docs/116 read the neighbouring 0x0A4 as the turning field because 0x0A3 is zero in every *player* specimen of either game -- which it is, because no player character is undead. Across the monster records the two are disjoint: 0x0A3 is non-zero only on undead and 0x0A4 only on clerics, and no record sets both. They are two fields, one per side of the same rule', candidate=False), 'turn_power': Field(offset=164, size=1, kind=<Kind.U8: 'u8'>, name='turn_power', label='Turn undead (caster side)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the caster's half of turning, sitting beside the undead's half at 0x0A3. Non-zero in eight of the eleven records carrying the cleric class bit and in nothing else -- ACOLYTE and 1ST LVL CLERIC 1, 2ND LVL CLERIC 2, MACE 4, CURATE and WILLIAM D'OR and DIRTEN 6 -- plus the player cleric ROLAND at 1. docs/116 sees the same population in Curse: 6 for its level-5 cleric, 3 for its level-5 paladin, zero for everyone else. What the *number* means is not settled and is not the cleric's level: three level-5 clerics read 1, 4 and 6, and 7TH LVL CLERIC reads 0.\n**CONFIRMED, and the value is a table lookup rather than the level.** GEN $2388 writes it from the fourteen bytes at $2399, indexed by the cleric level: 1 2 3 5 6 7 8 9 10 10 10 10 10 12. That is the row of the AD&D turning table the cleric reads, which is why it skips 4. ROLAND's six cleric trainings moved this byte every time and never moved 0x0A3, which is what separates the two fields. docs/119-test-party.md", candidate=False), 'wisdom': Field(offset=22, size=1, kind=<Kind.U8: 'u8'>, name='wisdom', label='WIS', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='13 in specimen', candidate=False)}¶
Lookup by field name.
- class goldbox.layout.Field[source]¶
Bases:
objectOne entry in the record layout.
- Variables:
offset (int) – Byte offset from the start of the 580-byte record.
size (int) – Width in bytes.
kind (goldbox.layout.Kind) – Encoding (
Kind).name (str) – Python identifier. Named fields become attributes on
goldbox.record.CharacterRecord.label (str) – Human-readable label for dumps and documentation.
confidence (goldbox.layout.Confidence) –
Confidencelevel.note (str) – Free-form evidence / observation text.
candidate (bool) – True for an explicitly declared unknown region that shows structure in a specimen and is worth investigating, as opposed to an auto-generated filler gap.
- __init__(offset, size, kind, name, label, confidence, note='', candidate=False)¶
- Parameters:
offset (int)
size (int)
kind (goldbox.layout.Kind)
name (str)
label (str)
confidence (goldbox.layout.Confidence)
note (str)
candidate (bool)
- Return type:
None
- confidence: goldbox.layout.Confidence¶
- kind: goldbox.layout.Kind¶
- class goldbox.layout.Kind[source]¶
Bases:
enum.EnumHow the bytes of a field are encoded.
- ASCII_NUL = 'ascii_nul'¶
Fixed-width, NUL-padded ASCII text (see
goldbox.petscii).
- I8 = 'i8'¶
a halfling’s read-languages sits at -5, stored as 251.
- Type:
Signed 8-bit integer, two’s complement. Thief skills use it
- RAW = 'raw'¶
Opaque bytes, passed through untouched.
- U16LE = 'u16le'¶
Unsigned 16-bit little-endian integer.
- U8 = 'u8'¶
Unsigned 8-bit integer.
- UINT_LE = 'uint_le'¶
Unsigned little-endian integer of whatever width the field declares.
U16LEwas always width-generic in the codec – it encodes withf.size– so this is the same machinery under an honest name, for the 24-bit experience total.
- goldbox.layout.LAYOUT: tuple[goldbox.layout.Field, ...] = (Field(offset=0, size=20, kind=<Kind.ASCII_NUL: 'ascii_nul'>, name='name', label='Name', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="NUL-padded; 'BRUTUS'", candidate=False), Field(offset=20, size=1, kind=<Kind.U8: 'u8'>, name='strength', label='STR', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='18 in specimen', candidate=False), Field(offset=21, size=1, kind=<Kind.U8: 'u8'>, name='intelligence', label='INT', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16 in specimen', candidate=False), Field(offset=22, size=1, kind=<Kind.U8: 'u8'>, name='wisdom', label='WIS', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='13 in specimen', candidate=False), Field(offset=23, size=1, kind=<Kind.U8: 'u8'>, name='dexterity', label='DEX', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='14 in specimen', candidate=False), Field(offset=24, size=1, kind=<Kind.U8: 'u8'>, name='constitution', label='CON', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16 in specimen', candidate=False), Field(offset=25, size=1, kind=<Kind.U8: 'u8'>, name='charisma', label='CHA', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='13 in specimen', candidate=False), Field(offset=26, size=1, kind=<Kind.U8: 'u8'>, name='exceptional_strength', label='STR %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="98 -> '18/98'", candidate=False), Field(offset=27, size=5, kind=<Kind.RAW: 'raw'>, name='gap_01b', label='unallocated @0x01b', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=32, size=69, kind=<Kind.RAW: 'raw'>, name='spells_memorised', label='Spells memorized', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a packed list of memorised spell ids, highest spell level first. Ids are CONFIRMED against the game's own SPELLN00 table and against spells Donald memorised on purpose: 1 BLESS, 3 CURE LIGHT WOUNDS, 21 SLEEP. Cleric and magic-user ids fall in disjoint ranges; see goldbox/spells.py. **The roster block's +0x03 onwards -- record 0x103-0x10B -- are a per-level count of this list, one byte per spell level, and the earlier retraction of that reading is withdrawn.** COM.PREP $15ED clears nine counters and rebuilds them from this field for every live combatant at the start of a fight: `LDX #$08 / LDA #$00 / STA $6C03,X / DEX / BPL`, then `LDY #$50 / LDX $6B20,Y / BEQ + / JSR $1751 / TAX / INC $6C02,X`. $1751 gives a spell id its level with thresholds $16, $24 and $38 -- goldbox/spells.py's own _GROUPS_POOL boundaries, arrived at from the spell names alone. Nothing else recomputes them and CAMP touches none of them, which is what made PORSAVE4 read 0/0/0 beside a set list: it was saved after memorising and resting, and before the next fight. Watched happening: +0x03 went 0 -> 1 for two casters between the world screen and the first round of one fight, ROLAND's stayed at 3, and the three non-casters stayed at 0 (tools/rosterspellcount.py, #365 (Three roster bytes have no established meaning, and a C64 party converted to DOS is told so with no way to check it)). The most seen in use is 13 bytes, and 0x02D-0x070 is zero in every specimen we hold.\n**69 bytes is what every measured title shares, not the whole list.** This field used to be declared as sixteen and the note used to say the length was unproven and PROBABLE at 21, on the argument that the DOS record allots 21 slots. Both were wrong and are deleted: the width is not a guess and it is not 21. Each title's own CAMP walks the list with a count-down loop whose immediate is the last index, so the width reads straight out of the code -- 'LDX #$50 / LDA $6B20,X' at CAMP $1450 in Pool of Radiance, '#$44 / LDA $7C20,X' at $15E2 in Curse of the Azure Bonds, '#$49 / CMP $7C1B,X' at $1412 in Secret of the Silver Blades, with twelve to sixteen sites in each and one immediate across all of them. So Pool of Radiance has **81 slots at 0x020-0x070**, Curse **69 at 0x020-0x064**, and Silver Blades **74 at 0x01B-0x064** -- a different start as well as a different width, because Silver Blades' GEN copies seven ability bytes back to 0x014 where Curse's copies twelve, leaving 0x01B-0x01F free for spells. All three CONFIRMED; tools/memorisedwidth.py reads them again off the disks. This field is declared as the run they all agree about, and the per-title span is goldbox.c64_codec.RecordShape.memorised, which every reader and the writer both go through. #268 (A character with more than sixteen memorised spells loses the rest, because the layout gives the list sixteen bytes and the game gives it eighty-one)\n**Position within the list carries no meaning.** The engine scans all of it, ignores zeros wherever they fall, draws the list in descending spell id rather than in byte order, and repacks the field forward from 0x020 in that same order once it is finished with it -- by the first time the party makes camp, which is what every save goes through. Measured in the running game on the C64: one spell swept across all sixteen positions of the region as this field was then declared, for a cleric and two magic-users, listed 48 times out of 48, was cast from position 15, and was consumed. A record written with entries at 7, at 3 and 9, and at 15 came back from the game's own save at 0, at 0 and 1, and at 0. So a conversion that keeps the ids and loses the arrangement loses nothing a player can see. Measured on the C64 engine only: the DOS reader's own rule about filling backwards from the end has not been put through the same sweep. #110 (Memorised spells lose their slot positions on the way through the neutral record)", candidate=False), Field(offset=101, size=7, kind=<Kind.RAW: 'raw'>, name='abilities_second', label='Abilities (second copy)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a second copy of the seven ability scores -- STR, INT, WIS, DEX, CON, CHA, exceptional STR -- mirroring 0x014-0x01A. CONFIRMED in Curse: all six of SSI's pre-generated characters carry it and the import routine writes it (docs/116 sec 2.2). Death Knights moved its editor's ability fields here rather than to 0x014. Seven zeroes in every Pool of Radiance specimen. **Which of the two arrays the game treats as current is not established** -- they are equal in every specimen, and MacGyver's Curse trainer writes both because he did not know either", candidate=False), Field(offset=108, size=5, kind=<Kind.RAW: 'raw'>, name='gap_06c', label='unallocated @0x06c', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=113, size=1, kind=<Kind.U8: 'u8'>, name='thac0_base', label='THAC0 base (60 - value)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="base THAC0, stored as 60 - THAC0, the same encoding the SAVEDGAME1 roster uses for the current value at +0x0E. Matches the AD&D 1st edition table for all twelve of Donald's characters and all six shipped on POOL1; the only three that differ are on the editor-hacked npc_party.d64. This was retired once as 'not THAC0' because MALCYON's sheet shows 20 where the byte reads 39 -- but 39 is 60-21, his base as a level-1 magic-user, and the 20 on screen is the current value after readying a dart. Base and current are different fields in different files.\n**CONFIRMED**: the trainer rewrote it at every one of twenty-nine level-ups, always to the row of the game's own table at GEN $1F1F and, for a multi-class character, to the best of its classes -- GEN $1EF3 keeps a value only when it beats what is already there. docs/119-test-party.md.\n**The DOS build stores a different number for the same character, and it is the table rather than the routine.** GEN $1EF3 clears the byte (SPELLE04 $0CFF writes the zero), walks the four class slots at 0x0C9 and keeps the best row; GAME.OVR:0x1A659 does exactly the same with `mul 11` into DS:0x3C7C, so neither engine clamps and neither leaves the byte stale. The two tables differ: DOS ships 40, THAC0 20, in the magic-user's rows 1-5 and the thief's rows 1-4 where the C64 ships 39, THAC0 21. 190 of 190 DOS records reproduce from the DOS table and every low-level magic-user or thief the C64 engine wrote holds 21; the only C64 records anywhere holding 20 for one are the five disks this project converted from DOS. goldbox.levels.dos_base_thac0 is the DOS number, and #366 (A converted magic-user or thief arrives with the other port's THAC0, because the two ports ship different tables and the conversion copies the byte) is what a player sees", candidate=False), Field(offset=114, size=1, kind=<Kind.U8: 'u8'>, name='race', label='Race', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="1-based: DWARF=1 ELF=2 GNOME=3 HALF-ELF=4 HALFLING=5 HALF-ORC=6 HUMAN=7 MONSTER=8. BRUTUS/ZARRADA=7 human, LARA=2 elf. HALF-ORC is real but NPC-only: it is not on the character-creation menu, and the only two half-orcs in the game are the named NPCs MACE and NORRIS THE GRAY. Two values outside that list matter. **0 is the commonest race in the game**, carried by 75 of the 135 distinct monster records -- every generic creature and some humanoid NPCs -- so it reads as 'not applicable' rather than as a race, and a 0 is not evidence that a record was tampered with. **8 (MONSTER) is used by nothing anywhere**, player or monster: the table enumerates it and the game never instantiates it, the same way it names DRUID, PALADIN, RANGER and MONK", candidate=False), Field(offset=115, size=1, kind=<Kind.U8: 'u8'>, name='char_class', label='Class', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="0-based, standard Gold Box order: CLERIC=0 DRUID=1 FIGHTER=2 PALADIN=3 RANGER=4 MAGIC-USER=5 THIEF=6 MONK=7. 0, 2 and 5 are verified by saving-throw tables; 6 is verified by the monster files, which contain NPCs literally named '1ST LVL THIEF' and '7TH LVL THIEF' carrying code 6. DRUID=1, PALADIN=3, RANGER=4 and MONK=7 appear in NO character anywhere -- not in twenty player characters, not in 108 monster records -- and Donald reports that paladin and ranger were left unfinished in the game data, so those four names rest on the Gold Box convention alone. Codes above 7 are multi-class: 8 = cleric/fighter, 9 = cleric/fighter/magic-user, 10 and 11 = cleric/magic-user, 12 = cleric/thief, 13 = fighter/magic-user, 14 = fighter/thief, 15 = fighter/magic-user/thief, 16 = magic-user/thief. That enumeration is the table the 1989 BASIC editor on poolce.d64 displays, and it agrees with all four multi-class codes we had already read off the bitmask at 0x0EB. Two caveats: the editor lists 3, 4 and 5 all as MAGIC-USER, which is its author's gap rather than the game's, and listing both 10 and 11 as cleric/magic-user looks like a slip in his table. class_bits stays the field to prefer. **Curse of the Azure Bonds' own `GEN` stops maintaining this byte once a character has ever trained**: its class-code routine at `$1939` means to `STX` the class code it just found by walking its own table at `$1951`, but stores `STA` instead -- one bit apart -- so what lands is the `EOR` residue (zero, on the matching path) or the dual-class level, never the code itself. Measured on the two Curse disks written after a training-hall visit or a class change, where 8 of 30 characters carry a code that disagrees with the class they hold levels in; the same records read clean one action earlier. `docs/187-the-class-code-byte.md` has the reading (#310 (A trained C64 Curse character arrives in DOS with the wrong class on his sheet))", candidate=False), Field(offset=116, size=2, kind=<Kind.U16LE: 'u16le'>, name='age', label='Age', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE; 21 for two humans, 176 for an elf -- long-lived, as expected', candidate=False), Field(offset=118, size=2, kind=<Kind.U16LE: 'u16le'>, name='hp_max', label='HP max', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE. 11 = 9 rolled + 2 CON. The high byte was long read as filler because no character has yet exceeded 255 hit points; the drain routine in SPELLE02 decrements the pair, which is what settles the width', candidate=False), Field(offset=120, size=7, kind=<Kind.RAW: 'raw'>, name='spells_known', label='Spellbook', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="a bitmask of the spells the character KNOWS, indexed by spell id: bit (id & 7) of byte 0x078 + (id >> 3). Confirmed on every caster we hold -- clerics know every spell of every level they can cast (8 at level 1, 24 at level 6) and magic-users know a subset, which is how AD&D 1st edition works. No cleric has a magic-user id set and no magic-user has a cleric one. MALCYON, a starting mage, knows detect magic, read magic, shield and sleep. Distinct from spells_memorised at 0x020, which is what is currently prepared. Bit 0 of 0x078 is deliberately unused: the QUANTUM LEAPER trainer's LEARN ALL SPELLS writes $FE here and $FF to the other six, i.e. spell id 0 does not exist.\n**Seven bytes is Pool of Radiance's width, not the engine's**, and the field stops here because seven is what this game reads and what goldbox/spells.py encodes. The later titles continue into spells_known_high at 0x07F; each title's width is goldbox.spells.SpellTable.spellbook_size, measured in that title's own code. docs/127-community-formats.md", candidate=False), Field(offset=127, size=9, kind=<Kind.RAW: 'raw'>, name='spells_known_high', label='Spellbook (high ids)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the rest of the spellbook bitmask, for the titles whose spell list outgrew 56. The same indexing continued -- byte 0x078 + (id >> 3) -- so 0x07F carries ids 56-63 and 0x087 ids 120-127. **Zero throughout Pool of Radiance**: no character in any Pool of Radiance save sets even 0x07F, which is why this read as a gap for so long. It is declared beside spells_known rather than folded into it so that the seven bytes this game uses stay their own field, and because `set_raw` is width-exact: widening one declared field would have changed what every writer emitted in one step, with nothing to catch a caller that had not been looked at. The writers have since been taken across deliberately -- `c64_codec`, `yaml_io` and `levelup` all encode the open title's width over both fields (#85 (A YAML export of a Curse or Silver Blades party stops at spell 55), #87 (Levelling a Curse or Silver Blades caster offers only Pool of Radiance's spells)), filling only as far as that title reaches.\nPer title, each measured in that game's own code and not carried across from another: **Pool of Radiance 7 (CONFIRMED)** -- 56 bits for a 56-spell list, of which id 56 RESTORATION has no bit, and the QUANTUM LEAPER trainer writes exactly seven. **Curse of the Azure Bonds 13 (CONFIRMED)** -- CAMP $2A25 builds the memorise list by walking spell ids from 1 with INY / CPY #$65 / BCC, so it stops after id 100, and reads the mask as TYA / LSR x3 / TAX / LDA $7C78,X; id 100 puts X at 12, so the game itself reads 0x078-0x084. GEN $232A writes there too, ORing $E0 into $7C81 and $01 into $7C82 to grant the four first-level druid spells 77-80. Whether 0x085-0x087 are also mask in Curse is UNKNOWN -- its GEN has no clear loop. **Secret of the Silver Blades 16 (CONFIRMED)** -- GEN $09DC clears sixteen bytes (LDX #$0F / LDA #$00 / STA $7C78,X / DEX / BPL), GEN $18C9 walks the same sixteen, and CAMP $2871 is Curse's memorise loop with its ceiling moved to CPY #$76, id 117, reaching 0x086.\n**Every address in the paragraph above was corrected on 2026-09-02 (#31 (Cold-read Curse and Silver Blades for the fields the editor shows))**, having been written at each overlay's PRG header base -- $1000, $1220, $3000, $4000 -- where GEN and CAMP both run at $0800 in all three titles. The instructions and the widths were right and the addresses named bytes in the middle of something else, so nobody checking one with tools/overlay.py, whose --base is $0800, would have found it. The base is settled by the operands: Silver Blades' GEN $18C9 reads its own scratch at $1BFD and its ceiling table at $17D0, and Curse's CAMP $2A25 calls $1CE9 and stores to $2A1F -- every one of them inside the file at $0800 and outside it at the header's address.\n**The near-miss, written down so it is not walked into twice**: Curse's GEN $220F copies 32 bytes out of $7C78 and looks like proof of a sixteen-byte mask. Pool of Radiance's GEN $216B copies the identical 32 out of $6B78, where the mask is seven. A copy wider than the field says nothing about the field. tests/test_curse.py and tests/test_silverblades.py pin all three readings.", candidate=False), Field(offset=136, size=16, kind=<Kind.RAW: 'raw'>, name='gap_088', label='unallocated @0x088', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=152, size=1, kind=<Kind.U8: 'u8'>, name='attack_level', label='Fighting level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the level the attack tables are indexed by, which is not always the character level: 5 for Curse's level-5 paladin and ranger, 4 for its level-5 fighter/thief, 0 for pure casters, 1 for an imported level-1 fighter. CONFIRMED in docs/116 sec 2.2, and it matches the DOS record's attackLevel at the same place in the cluster, where the community documentation calls it LVL_Sweep -- the AD&D rule that lets a fighter make one attack per level against creatures under one hit die, which is what a separate 'level the attack tables are indexed by' is for. It reads the fighter level on every high-level fighter we hold (MAD MAN 8, GRON 7, two others 4) and zero on every level-1 one. Zero in every Pool of Radiance player specimen", candidate=False), Field(offset=153, size=1, kind=<Kind.U8: 'u8'>, name='size_small', label='Size', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="0 small, 1 large -- the only two the game offers, and the two its ALTER > ICON > SIZE menu shows. The only byte in the stored 256 that separates dwarves, gnomes and halflings from humans, elves and half-elves -- the AD&D size categories exactly. This is the icon large/small flag the Gold Box Companion exposes. Donald confirmed MAGNUS, a dwarf, shows as small in game, and that the visible difference is the head: a small character's body is the same size and its head is smaller, which is why the icon looks small without being smaller. 0 for every dwarf, gnome and halfling and 1 for every elf, half-elf and human in all 79 records we hold. The DOS record splits this in two -- an icon *dimension* (1 = one square) at the offset this one aligns to and an icon *size* (1 small, 2 medium) much later -- and the C64 byte sits at the first offset carrying the second meaning, one lower. Which is why the DOS documentation cannot be transcribed onto this byte", candidate=False), Field(offset=154, size=1, kind=<Kind.U8: 'u8'>, name='save_paralysis', label='Save vs para/poison/death', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="fighter 14, cleric 10 -- both match the AD&D 1e L1 tables.\nAll five saves at 0x09A-0x09E are now **derivable**, which goldbox/levels.py's docstring says they were not: the stored number is the class table row for the character's level in that class, taking the **best number in each column** across every class it holds, minus the AD&D constitution bonus (+1 per 3.5 points) when the character is a **dwarf, gnome or halfling**. 78 of the 79 distinct records on this machine satisfy that exactly; the one miss is MAD MAN, a level-8 NPC carrying the level-1 fighter row. The multi-class rule shows on its own: LADY KATHERINE (magic-user 1 / thief 1) reads 13 12 11 15 12, which is neither class's row but the column-wise minimum of the two. The racial adjustment shows as a uniform shift -- MAGNUS the dwarf (CON 13) is 3 lower than an identical human fighter in Donald's own party, and HOGARTH and TANARAKIS on SSI's shipped demo party agree with no adjustment at all. Only the +3/+4/+5 bands are exercised and the high-level cases all come from npc_party.d64, whose values this project treats as worthless (docs/90), so tests/test_communityformats.py asserts the rule on the fixtures alone, which are clean. docs/127-community-formats.md", candidate=False), Field(offset=155, size=1, kind=<Kind.U8: 'u8'>, name='save_petrification', label='Save vs petrify/polymorph', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 15, cleric 13', candidate=False), Field(offset=156, size=1, kind=<Kind.U8: 'u8'>, name='save_wands', label='Save vs rod/staff/wand', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 16, cleric 14', candidate=False), Field(offset=157, size=1, kind=<Kind.U8: 'u8'>, name='save_breath', label='Save vs breath weapon', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 17, cleric 16', candidate=False), Field(offset=158, size=1, kind=<Kind.U8: 'u8'>, name='save_spell', label='Save vs spell', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fighter 17, cleric 15', candidate=False), Field(offset=159, size=1, kind=<Kind.U8: 'u8'>, name='movement', label='Movement', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='12 in all three specimens', candidate=False), Field(offset=160, size=1, kind=<Kind.U8: 'u8'>, name='level', label='Level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="character level. Promoted from PROBABLE on the game's own data: **twenty-one shipped MON* records state their level in their name**, and 0x0A0 agrees with the name in nineteen of them -- 1ST LVL THIEF 1, 2ND LVL CLERIC 2, LEVEL 3 MU 3, 4TH LVL FIGHTER 4, LEVEL 5 CLERIC 5, 6TH LVL FIGHTER 6, 7TH LVL DW FIGHTER 7, 8TH LVL FIGHTER 8. The two that differ are both 6TH LVL THIEF (MON33, MON5D), where the byte reads 7 **and so does the per-class array**, so the disagreement is between the designer's label and his data rather than between two fields. Corroborated three further ways: the 1989 BASIC editor on poolce.d64 and the QUANTUM LEAPER machine-code trainer both poke exactly this byte as LEVEL; across the eight characters of npc_party.d64 it equals the per-class level at four distinct values; and docs/80 reads the drain routine writing it down from the per-class array. Every early specimen was level 1, which is why it long read as a constant 01. Still not distinguishable from 'the single class's level' -- no multi-class specimen above level 1 has been seen", candidate=False), Field(offset=161, size=1, kind=<Kind.U8: 'u8'>, name='levels_drained', label='Levels drained', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="how many levels undead have drained, not a second copy of the level. The pair is current-plus-delta, which is why no 'true level' was ever found. SPELLE02 computes hp_max / total levels, loops that many times doing DEC $6B76 / DEC $6BED / INC $6BA2 / DEC $6C19, then INC $6BA1 and DEC $6BC9,X. RESTORATION in SPELLE04 reverses it exactly and prints string 94, which SPELLN00 gives as IS RESTORED", candidate=False), Field(offset=162, size=1, kind=<Kind.U8: 'u8'>, name='hp_lost_to_drain', label='HP lost to drain', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='hit points removed by level drain, restored alongside 0x0A1', candidate=False), Field(offset=163, size=1, kind=<Kind.U8: 'u8'>, name='turn_class', label='Undead turning class', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='which row of the AD&D 1e turning table a creature answers to. Non-zero in exactly twelve of the 121 distinct MON* records, every one undead, and it matches the published table on all of them: skeleton 1, zombie 2, ghoul 3, wight 5, wraith 7, mummy 8, spectre 9, vampire 10, with giant skeleton 8 and juju zombie 9. Eleven are named creatures; the twelfth is FERRAN MARTINEZ (MON13), an NPC carrying 9, the spectre row. **This offset was challenged and it survived.** docs/116 read the neighbouring 0x0A4 as the turning field because 0x0A3 is zero in every *player* specimen of either game -- which it is, because no player character is undead. Across the monster records the two are disjoint: 0x0A3 is non-zero only on undead and 0x0A4 only on clerics, and no record sets both. They are two fields, one per side of the same rule', candidate=False), Field(offset=164, size=1, kind=<Kind.U8: 'u8'>, name='turn_power', label='Turn undead (caster side)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the caster's half of turning, sitting beside the undead's half at 0x0A3. Non-zero in eight of the eleven records carrying the cleric class bit and in nothing else -- ACOLYTE and 1ST LVL CLERIC 1, 2ND LVL CLERIC 2, MACE 4, CURATE and WILLIAM D'OR and DIRTEN 6 -- plus the player cleric ROLAND at 1. docs/116 sees the same population in Curse: 6 for its level-5 cleric, 3 for its level-5 paladin, zero for everyone else. What the *number* means is not settled and is not the cleric's level: three level-5 clerics read 1, 4 and 6, and 7TH LVL CLERIC reads 0.\n**CONFIRMED, and the value is a table lookup rather than the level.** GEN $2388 writes it from the fourteen bytes at $2399, indexed by the cleric level: 1 2 3 5 6 7 8 9 10 10 10 10 10 12. That is the row of the AD&D turning table the cleric reads, which is why it skips 4. ROLAND's six cleric trainings moved this byte every time and never moved 0x0A3, which is what separates the two fields. docs/119-test-party.md", candidate=False), Field(offset=165, size=1, kind=<Kind.I8: 'i8'>, name='thief_pick_pockets', label='Pick pockets %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='30 at L1. The eight percentages at 0x0A5-0x0AC come from a nine-level table of eight columns in the engine, in this order, plus a racial row: HOGARTH, a dwarf thief 1, matches the base row plus the AD&D dwarf adjustments in all eight columns, including read languages -5 stored as $FB, and takes no dexterity adjustment at dexterity 17. The half-elf, gnome and halfling specimens do not match their published racial rows, so **how the C64 applies the racial modifier is UNKNOWN**; the per-level progression is not. docs/127-community-formats.md', candidate=False), Field(offset=166, size=1, kind=<Kind.I8: 'i8'>, name='thief_open_locks', label='Open locks %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='25 at L1', candidate=False), Field(offset=167, size=1, kind=<Kind.I8: 'i8'>, name='thief_find_traps', label='Find/remove traps %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='20 at L1', candidate=False), Field(offset=168, size=1, kind=<Kind.I8: 'i8'>, name='thief_move_silently', label='Move silently %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='20 at L1', candidate=False), Field(offset=169, size=1, kind=<Kind.I8: 'i8'>, name='thief_hide_in_shadows', label='Hide in shadows %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='10 at L1', candidate=False), Field(offset=170, size=1, kind=<Kind.I8: 'i8'>, name='thief_hear_noise', label='Hear noise %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='10 at L1', candidate=False), Field(offset=171, size=1, kind=<Kind.I8: 'i8'>, name='thief_climb_walls', label='Climb walls %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='85 at L1', candidate=False), Field(offset=172, size=1, kind=<Kind.I8: 'i8'>, name='thief_read_languages', label='Read languages %', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='5 at L1', candidate=False), Field(offset=173, size=10, kind=<Kind.RAW: 'raw'>, name='item_effects', label='Character Traits', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="ten trait slots -- racial abilities and readied passive items. **Not the save's active effects**, which is what this field was called until P3-EFFECTS.D64 was saved with twenty-six spells running and every character's ten slots came out exactly as they went in. Nothing here has a duration and nothing here expires; the running effects are four 64-entry arrays in SAVEDGAME0 (docs/133-active-effects.md). The two share one code namespace, which is why one table names both. **The namespace is named**: the DOS guide enumerates ids 1-127 and goldbox/traits.py carries the whole table, 44 of them CONFIRMED because a MON* record or a saved item carries the id on exactly the creature or item its meaning demands -- AHNKHEG 121 'anhkheg acid squirt', TROLL 100 and 101, WIGHT 96 'silver or magic' against WRAITH 123 'silver does half', which is the Monster Manual distinction between them. The rest are PROBABLE: the guide names them and no C64 record exercises them. Three overlays loop LDX #$09 over it, and XAVIER carrying 107 in the first slot and 89 in the tenth proves the extent. GEN $0BF3 seeds it per race from the table [1, 0, 107, 0, 124, 0, 0, 0], **indexed by the race byte itself**: race is 1-based, so elf (2) is born with 107 and half-elf (4) with 124, and the leading 1 sits at index 0 where no created character reaches it. That retires the old reading of the 1 as a dwarf's seed -- MAGNUS, a dwarf, has an empty trait block.\n**The seeding is per title, and so is the namespace (#31 (Cold-read Curse and Silver Blades for the fields the editor shows)).** The read is the same three instructions in all three measured titles -- LDX <staging>+0x072 / LDA <table>,X / STA <staging>+0x0AD -- and what differs is how many slots it fills and what it fills them with. Curse GEN $24EA seeds three from $24FF, $2506 and $250D; Silver Blades GEN $0C4B seeds two from $0C5B and $0C62, and both clear all ten first where Pool of Radiance does not. **Curse's codes are Pool of Radiance's** -- dwarf 26, 47, 97, gnome 18, 48, 97, elf 107, half-elf 124, every one landing on the race its Pool of Radiance name demands. **Silver Blades' are not**: its elf is seeded 95 and its half-elf 18, which read as 'fights on from -6 to 0 hit points' and a gnome's bonus against kobolds. Four ids agree (26, 47, the gnome's 48 and the paladin's 45) and five do not (95, 18, 7, 92 and the ranger's 105, where Curse GEN $2515 writes 134), so it is a reassignment and not an offset. The class seed is written after the racial one, by Silver Blades GEN $0FF0. #186 (The character sheet gives a Silver Blades elf a Pool of Radiance ability) gave Silver Blades its own table in goldbox/traits.py: the six codes an approved string already covered are named -- 18 and 95 borrowing the wording of Pool of Radiance's 124 and 107 -- and 7, 92 and 105 show their number, because nobody has established what they mean. tools/coldread.py traits reads the tables off any title's own disks.\nIt shares storage with item byte +14 -- SPELLE04 $ADD4 copies a readied passive item's +14 verbatim into a free slot -- and shares its meaning only for **passive** items, which item byte +15 bit 7 marks. CLOAK OF DISPLACEMENT reads +14 89 / +15 $85 and 89 is 'displaced'; TWO-HANDED SWORD +1 +3 VS UNDEAD reads 3 / $88 and 3 is 'wielding an undead-slaying weapon'. A consumable's +14 is a spell id instead: POTION OF HEALING reads 85 / $00 and 85 is a level drain only in the effect table", candidate=False), Field(offset=183, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0b7', label='unallocated @0x0b7', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=184, size=1, kind=<Kind.U8: 'u8'>, name='flags_0b8', label='Flags', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="bit 7 is the real 'this is an NPC or a monster' flag, and bit 0 records that an ability score was altered at the trainer. npc_party.d64 splits three players from five NPCs exactly on bit 7; the code counts player characters with it and enforces CMP #$06, which is the six-PC party limit in code rather than in anecdote; NPC money is zeroed by it. Bit 0 is set by GEN $155D straight after INC/DEC $6B14,X and cleared again if the change is cancelled. **Nothing anywhere reads bit 0 back**, so the forum rumour that altering a score carries a penalty in play has no code behind it on this port. **Bit 0 is the only meaning the low bits have for a player character, and that is not the whole byte.** For a character the engine drives -- bit 7 set -- the low seven bits are his morale instead, stored halved: `COMBAT $211C` masks them with AND #$7F and doubles them before comparing, and DOS keeps the same byte in the same encoding at `field_83_87`'s control byte -- #303 (The DOS record may hold the NPC flag that the conversion reports as having nowhere to go). npc_party.d64's five NPCs read $80, $B1 and $B2 twice more, none of which is bit 0 set on any reading", candidate=False), Field(offset=185, size=1, kind=<Kind.U8: 'u8'>, name='dual_class_slot', label='Dual class: old class slot', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the slot in the per-class level array at 0x0C9 that a dual-classed human left behind, with the level it was left at in 0x0BA. Curse of the Azure Bonds' GEN writes the pair, and the gate at $2387 is AD&D 1st edition's rule read off the machine: LDA race / CMP #$07 (human), a check that refuses on carry clear, LDA 0x0BA / BNE -- so a character dual-classes at most once -- and LDA level / CMP #$02, level 2 or better. Only then does $23C9 store the slot and $23D2 the level. The three consumers are the ones docs/135 names: $1470 leaves the old slot out of the experience clamp, $1321 out of eligibility, $15E7 out of the hit-die roll, and $20A3 writes the old level back into that slot and ORs its class bit into class_bits. Secret of the Silver Blades (GEN $1FB7/$1FBD) and Gateway to the Savage Frontier (GEN $23D3) carry the same stores. **Pool of Radiance does not use these two bytes at all**: no absolute-mode instruction in any of the 589 files on its disks references $6BB9 or $6BBA, against 42 references to $6BB8 beside them, and Champions of Krynn and Death Knights of Krynn are the same. That -- not a marker -- is why every Pool of Radiance specimen reads zero here; see region_0e3 and #224 (0x0B9 and 0x0BA are documented both as an NPC marker and as the dual-class slot). **CONFIRMED, no longer PROBABLE**: `WISH-SPEC-curse-dual-classed` holds PHILIPPE, a magic-user 6 who chose FIGHTER off Curse's own training-hall menu (#234 (A dual-classed Curse or Silver Blades character converted to DOS loses the class he trained out of), #291 (A Curse save disk will not load through the game's own front end in a pooled session, so no C64 Curse party can be got in)), watched being written. His record reads slot 0 (magic-user) and level 6 -- exactly the meaning above -- with level_magic_user zeroed to 0 and class_bits holding fighter's bit alone, both matching the DOS specimens on #234 (A dual-classed Curse or Silver Blades character converted to DOS loses the class he trained out of) at the same moment of change. **Still open**: what $20A3 does once the new class passes the old level -- PHILIPPE's fighter is level 1, one short of it -- so the old class's slot being frozen at zero rather than restored is CONFIRMED and the *regaining* half stays PROBABLE (#256 (The neutral record has nowhere to put a dual-classed character's former levels), M2)", candidate=False), Field(offset=186, size=1, kind=<Kind.U8: 'u8'>, name='dual_class_level', label='Dual class: old level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the level the dual-classed character left its old class at, and the sentinel for the pair: GEN $18EB reads LDY #$FF / LDA 0x0BA / BEQ / LDY 0x0B9, so **zero here means 'not dual-classed'** and 0x0B9 is a real slot number only when this byte is non-zero. That is what stops slot 0, the magic-user, being ambiguous. Written by Curse's GEN $23D2 with the whole of the gate dual_class_slot describes; zero in every Pool of Radiance specimen because that title never touches it. CONFIRMED for the reason dual_class_slot gives: PHILIPPE holds 6 here, the level he left magic-user at", candidate=False), Field(offset=187, size=2, kind=<Kind.U16LE: 'u16le'>, name='copper', label='Copper', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 100 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), Field(offset=189, size=2, kind=<Kind.U16LE: 'u16le'>, name='silver', label='Silver', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='25-26 each after looting orcs, where it was 0 before', candidate=False), Field(offset=191, size=2, kind=<Kind.U16LE: 'u16le'>, name='electrum', label='Electrum', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 100 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), Field(offset=193, size=2, kind=<Kind.U16LE: 'u16le'>, name='gold', label='Gold', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='fell for all six when they bought equipment', candidate=False), Field(offset=195, size=2, kind=<Kind.U16LE: 'u16le'>, name='platinum', label='Platinum', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='changed for three characters across a shopping trip', candidate=False), Field(offset=197, size=2, kind=<Kind.U16LE: 'u16le'>, name='gems', label='Gems', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 10 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), Field(offset=199, size=2, kind=<Kind.U16LE: 'u16le'>, name='jewelry', label='Jewelry', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='set to 10 in the edit test and shown in the game (the thirteen-field edit)', candidate=False), Field(offset=201, size=1, kind=<Kind.U8: 'u8'>, name='level_magic_user', label='Magic-user level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="1 for every magic-user, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels).\n**The array is permuted between the ports and a save converter must permute it back.** The C64 indexes 0x0C9-0x0D0 by the bit number in class_bits -- magic-user, cleric, thief, fighter, druid, monk, paladin, ranger -- and DOS indexes the same eight bytes by the class number at 0x073: cleric, druid, fighter, paladin, ranger, magic-user, thief, monk. Six specimens settle the C64 side: MALCYON (class 5, magic-user) fills slot 0, ROLAND (class 0, cleric) slot 1, BRUTUS (class 2, fighter) slot 3, and LADY KATHERINE (class 16, magic-user/thief) slots 0 and 2. A converter that copies the array straight across turns every cleric into a druid.\n**CONFIRMED**: twenty-nine trainings raised exactly the entry of the class trained and no other, and LADY KATHERINE ended magic-user 6 / thief 9 with two different non-zero entries -- which is what separates this array from 'the single class's level'. docs/119-test-party.md", candidate=False), Field(offset=202, size=1, kind=<Kind.U8: 'u8'>, name='level_cleric', label='Cleric level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every cleric, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels)', candidate=False), Field(offset=203, size=1, kind=<Kind.U8: 'u8'>, name='level_thief', label='Thief level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every thief, 0 otherwise. One entry of the per-class level array -- how dual-classing keeps an old class frozen while a new one advances (the per-class levels)', candidate=False), Field(offset=204, size=1, kind=<Kind.U8: 'u8'>, name='level_fighter', label='Fighter level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='1 for every fighter, 0 otherwise. One entry of the per-class level array (the per-class levels). Previously guessed to be an exceptional-strength flag, because the only fighters seen then were the only characters with exceptional strength', candidate=False), Field(offset=205, size=1, kind=<Kind.U8: 'u8'>, name='level_knight', label='Class-level slot 4 (druid; knight in the Krynn titles)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="slot 4 of the per-class level array, i.e. class_bits bit 4 (16). **In the Realms titles this is the druid slot.** The engine's own item-restriction bit array -- the same bit order this array is indexed by -- reads 0 magic-user, 1 cleric, 2 thief, 3 fighter, 4 druid, 5 monk, 6 paladin, 7 ranger. Bits 0-3 are CONFIRMED here from six specimens and bits 6-7 from Curse's pre-generated paladin and ranger, so six of the eight positions are checked and the two left over are druid and monk in that order. Pool of Radiance never instantiates a druid, so the byte is zero in every specimen we hold; the name of the slot is PROBABLE and the *ordering* that puts druid in it is CONFIRMED.\nThe field keeps the name `level_knight` because the Death Knights of Krynn editor calls it that -- it cycles nine class names over the eight-byte array at 0x0C9 as MAGE, CLERIC, THIEF, FIGHTER, KNIGHT, -, PALADIN, RANGER, and bounds the array with CPY #$08. Knights of Solamnia are a Krynn class in a world with no druids, so the Krynn games reuse the slot. The shipped Champions and Death Knights parties carry class_bits 0x10 with the whole array zero, so no record has yet shown a value in this byte on any title", candidate=False), Field(offset=206, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0ce', label='unallocated @0x0ce', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=207, size=1, kind=<Kind.U8: 'u8'>, name='level_paladin', label='Paladin level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="slot 6 of the per-class level array, class_bits bit 6 (64). CONFIRMED on SSI's own pre-generated Curse party, whose paladin holds 0x0CF = 5 with class_bits = 64 (docs/116 sec 2.3). Zero in every Pool of Radiance specimen -- the game names PALADIN in its class table and never instantiates one", candidate=False), Field(offset=208, size=1, kind=<Kind.U8: 'u8'>, name='level_ranger', label='Ranger level', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="slot 7 of the per-class level array, class_bits bit 7 (128). CONFIRMED on Curse's pre-generated ranger, 0x0D0 = 5 with class_bits = 128. Silver Blades and Death Knights use both this slot and the paladin one; Pool of Radiance leaves both zero", candidate=False), Field(offset=209, size=4, kind=<Kind.RAW: 'raw'>, name='gap_0d1', label='unallocated @0x0d1', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=213, size=1, kind=<Kind.U8: 'u8'>, name='infravision', label='Infravision (tens of feet)', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='6 for every dwarf/elf/half-elf, 0 for every human, across 12 specimens -- i.e. 60 feet', candidate=False), Field(offset=214, size=1, kind=<Kind.U8: 'u8'>, name='sex', label='Sex', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='0 = male, 1 = female. LADY KATHERINE is 1 and confirmed female by Donald; LARA SPELLSWORD and ZARRADA are also 1', candidate=False), Field(offset=215, size=1, kind=<Kind.RAW: 'raw'>, name='gap_0d7', label='unallocated @0x0d7', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=216, size=1, kind=<Kind.U8: 'u8'>, name='alignment', label='Alignment', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="0-based index into the game's own table at $32B3: LAWFUL GOOD=0 LAWFUL NEUTRAL=1 LAWFUL EVIL=2 NEUTRAL GOOD=3 TRUE NEUTRAL=4 NEUTRAL EVIL=5 CHAOTIC GOOD=6 CHAOTIC NEUTRAL=7 CHAOTIC EVIL=8. All six of Donald's characters decode to the alignment he chose", candidate=False), Field(offset=217, size=8, kind=<Kind.RAW: 'raw'>, name='attack_forms', label='Attack forms', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="the two attack forms, as four parallel two-entry arrays: attacks per round **doubled** at 0x0D9, damage dice at 0x0DB, die at 0x0DD, signed modifier at 0x0DF, each holding form 0 then form 1. COMBAT $0CAD rolls damage through LDA $6C13,Y / LDX $6C15,Y with a stride of 2, which is what proves there are exactly two forms, and twenty creatures match the Monster Manual: GHOUL 04 02 / 01 01 / 03 06 is two 1d3 claws and a 1d6 bite, TROLL 04 02 / 01 02 / 04 06 / 04 00 is two 1d4+4 claws and a 2d6 bite. A form with no damage dice is not an attack. Decoded in goldbox/monster.py; kept as one raw block here because the character sheet has no use for eight separate monster fields.\nThis region read UNKNOWN for a long while on a note recording the specimen as '03 02 00 01 00 02 00 00', which was said to contradict the attacks reading because BRUTUS, a one-attack level-1 fighter, appeared to hold 3. **That note was off by one**: it was a dump of 0x0D8-0x0E0, and the leading 03 is the alignment byte at 0x0D8. BRUTUS reads 02 00 01 00 02 00 00 00 -- one attack per round for 1d2 unarmed, exactly what GEN $0BBE writes and what the reading predicts. The contradiction never existed.\nIndependently corroborated: the DOS record spells the same eight bytes out in the same order at the offset this one aligns to -- attack count, dice count, die, modifier, each as a two-entry array of form 0 then form 1 -- and every C64 player character reads 02 00 01 00 02 00 00 00", candidate=False), Field(offset=225, size=1, kind=<Kind.U8: 'u8'>, name='armour_class_base', label='AC base (60 - AC)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note='base armour class, stored as 60 - AC, the same encoding used for THAC0 at 0x071 and for the current AC in the SAVEDGAME1 roster. It is 10 for every player character ever seen -- unarmoured, before dexterity -- which is why it looked like a constant. Monsters use the same record layout and put their real armour class here: kobold 7, orc 6, troll 4, zombie 8, matching the Monster Manual on all eight creatures checked. The DOS record names AC_Base at the offset this one aligns to, between the eight attack-form bytes and the per-character value that precedes experience -- corroboration, not proof, and the Monster Manual check is the stronger of the two', candidate=False), Field(offset=226, size=1, kind=<Kind.U8: 'u8'>, name='strength_index', label='Effective STR', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note='equals STR below 18; 18/80 and 18/81 give 21, 18/98 gives 22 -- the AD&D exceptional-strength bands collapsed to one number. The DOS record has a boolean STR_Bonus at the aligned offset, reading 1 in all 66 DOS specimens; this byte holds 15-22 across ours, so they are two different fields and this reading stands', candidate=False), Field(offset=227, size=1, kind=<Kind.U8: 'u8'>, name='strength_bonus_flag', label='Strength bonus gate', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="gates the to-hit and damage bonus a high strength gives: GEN $0B79 writes 1 here at creation (Curse's GEN $0C31 the same at 0x7CE3), and LIBRARY's roster recompute at $375C reads it, indexing strength_index into the AD&D strength tables at $3651/$3670 only when this is non-zero -- 18 gives +1/+2, 21 gives +2/+4, 23 gives +3/+6. Curse of the Azure Bonds makes the same gate at LIBRARY $394B on $7CE3, and its two tables at $3840/$385F are byte-identical to Pool of Radiance's over all 26 rows. CONFIRMED: every engine-made player character on the C64 disks of all three titles reads 1 here, and the running game settled what it is worth -- two converted records identical but for this byte, both 18/75 fighters, came out of the first ambush's roster recompute at THAC0 20 with no damage bonus and at THAC0 18 with +3. Nothing recomputes at load, at BEGIN ADVENTURING or on VIEW, so a record with this zero looks right on the sheet until the party's first fight. `goldbox.c64_codec.write` left it zero until #277 (A DOS character converted to the C64 loses the strength bonus to hit and damage, because 0x0E3 is written zero) and now writes 1, which is what the C64's own character creation writes. Named here by #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it)", candidate=False), Field(offset=228, size=2, kind=<Kind.RAW: 'raw'>, name='region_0e4', label='unknown @0x0E4', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note="between the strength gate and the identity pair. Referenced by nothing in 589 files and $00 $00 in every player character; $FF $FF in the five shipped MON* NPC records, which used to be read as two bytes of an eight-byte NPC marker together with 0x0B9, 0x0BA and others. **There is no marker**, only the author's $FF fill of the monster files intersected with what a player character happens to hold zero at -- see strength_bonus_flag and dual_class_slot, two of the eight that turned out to be real fields once read rather than assumed. #224 (0x0B9 and 0x0BA are documented both as an NPC marker and as the dual-class slot), #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it). Not a candidate region: unlike the identity pair it once sat beside, it shows no structure of its own -- zero in every player character we hold, BRUTUS included", candidate=False), Field(offset=230, size=2, kind=<Kind.RAW: 'raw'>, name='identity_pair', label='Identity pair', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="two bytes GEN draws from the random generator at character creation and never rewrites: $0C01 JSR $2D88 / STA $6BE6, $0C07 JSR $2D88 / STA $6BE7. CONFIRMED that nothing on the C64 reads them back: a census of 589 files finds no reference beside those two stores (plus a copy of the same code in the boot disk's POOLRE), and three load watchpoints on $6BE6-$6BE7 in the running game never moved except in step with the unreferenced $6BE4-$6BE5 beside them, while the experience control moved alone. The add screen's own duplicate test (GEN $1897) compares a candidate's name against every occupied slot's name and nothing else -- in the running game a different character under a party member's name was starred and refused, and the same character under a new name was let in carrying the identical pair, unnoticed. This is the C64 home of DOS 0x0AB, the byte the DOS engine uses for the same tie-break, and it is Pool of Radiance's alone: Curse of the Azure Bonds' and Secret of the Silver Blades' GEN never write it, and both hold 00 00 in every shipped party. #258 (The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it), docs/170-c64-identity-pair.md", candidate=False), Field(offset=232, size=3, kind=<Kind.UINT_LE: 'uint_le'>, name='experience', label='XP', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='24-bit LE. After one orc fight the party holds 17 each and LADY KATHERINE 8 -- non-zero and differing, which is what confirms it', candidate=False), Field(offset=235, size=1, kind=<Kind.U8: 'u8'>, name='class_bits', label='Class bitmask', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='magic-user=1 cleric=2 thief=4 fighter=8, OR-ed together. This is how multi-class is really represented: LADY KATHERINE is 5 (magic-user/thief, confirmed by Donald) and LARA SPELLSWORD is 9 (magic-user/fighter -- her name says so). Far more usable than the single char_class code at 0x073', candidate=False), Field(offset=236, size=1, kind=<Kind.I8: 'i8'>, name='missile_attack_adjustment', label='Missile attack adjustment', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="what dexterity is worth to hit with a ranged weapon, signed: $FF is -1. **CONFIRMED from both sides of the byte.** COM.PREP $1633 writes it -- `LDX $6B17` (dexterity) / `LDA $1682,X` / `STA $6BEC` -- out of a sixteen-entry table that is the AD&D 1st edition missile attack adjustment exactly: -3 at DEX 3, -2 at 4, -1 at 5, nothing from 6 to 15, +1 at 16, +2 at 17, +3 at 18, and on past 18 to +5 at 25 for a magically raised score. LIBRARY $36A8 reads it -- `LDA $6BEC` / `ADC $6C0E` -- and adds it to the roster's THAC0 when the readied weapon's type +14 has bit 1, ranged; the block below it adds the strength bonus instead when bit 2 is set. COMBAT $0899 adds it again in the attack roll.\n**It is a cache, and it goes stale**, because COM.PREP is the combat-preparation overlay: the byte is rewritten when a fight starts and at no other time anybody has found. Measured in the running game -- $7F poked into all six records of the PORSAVE13 party read back as 3, 1, 0, 0, 0, 0 four steps later in the Slums ambush, against dexterities 18, 16, 13, 12, 15, 14; and un-readying MALCYON's darts moved nothing. 96 of the 114 character records on the player's 19 save disks hold the table value for their own dexterity; all 18 that disagree hold a value *below* it, and every one belongs to a character whose dexterity wish edited upwards with no fight since. MALCYON is the clearest: 1 at DEX 16 in PORSAVE2, still 1 after the edit to 18 in PORSAVE4-9, and 3 in PORSAVE11 after the next fight.\nThis byte was read for a long time as spell state, on the observation that it went 0 -> 1 after combat for MALCYON and LADY KATHERINE 'and nobody else -- exactly the two spellcasters'. They are also exactly the two characters with a dexterity of 16; the other four are 12 to 15, whose table entry is zero, so there was nothing for their byte to do. #202 (Name record offset 0x0EC, which is what moves a THAC0 when darts are readied)", candidate=False), Field(offset=237, size=1, kind=<Kind.U8: 'u8'>, name='hp_rolled', label='HP rolled', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='the accumulated hit-die rolls, without the constitution bonus. hp_max at 0x076 is this plus level x the bonus, recomputed from here at every training -- so this is the stored half and hp_max the derived one, not the other way round. GEN $2037 adds one roll of the class hit die (GEN $20A7: d4, d8, d6, d10 in class-bit order) and GEN $2079 writes hp_max from it. Confirmed on twenty-nine trainings across four classes and three constitution scores; the roll itself is a die and derives from nothing', candidate=False), Field(offset=238, size=6, kind=<Kind.RAW: 'raw'>, name='spells_castable', label='Spells castable', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="how many spells of each level the character may memorise, one byte per spell level, **nibble-packed**: cleric in the high nibble, magic-user in the low. ROLAND, a level-1 cleric with wisdom 16, reads $30 -- three first-level spells, one base plus two for wisdom, which is exactly what his sheet allows. MALCYON and LADY KATHERINE, both level-1 magic-users, read $01. The three fighters read zero throughout. Found while surveying Curse of the Azure Bonds, which uses the same offsets; the docs had this down as not stored anywhere.\nPromoted from PROBABLE on three further lines (docs/127). **Multi-class specimens set both nibbles at once**, which no single-class specimen can distinguish from two separate bytes: TANARAKIS (cleric 1 / magic-user 1) and DELILIA (cleric 1 / fighter 1 / magic-user 1) both read $31. The wisdom bonus is exact at level 6, where the base row is no longer 1: DIRTEN, cleric 6 with wisdom 16, reads 5/5/2 -- AD&D's 3/3/2 plus +2/+2/+0 -- and SIMON, cleric 6 with wisdom 18, reads 5/5/3. Fifteen casters across nine parties agree; the one exception is DELILIA, wisdom 13 with three first-level slots where the rule gives two, which is what a cache not recomputed on an ability change looks like. And the DOS record carries the same six quantities as six separate bytes -- SPL_Count_CL_1..3 then SPL_Count_MU_1..3 at DOS 0x0B2 -- in the position this offset aligns to; 66 real DOS records agree. 0x0F1-0x0F3 are spell levels 4-6 and are zero in all 79 C64 records, which is what a game that stops at third-level spells should look like", candidate=False), Field(offset=244, size=10, kind=<Kind.RAW: 'raw'>, name='gap_0f4', label='unallocated @0x0f4', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=254, size=1, kind=<Kind.U8: 'u8'>, name='portrait_head', label='Portrait head', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='index into the HEAD* files on the game disks, in hex: 0x2D is HEAD2D. All eleven values across our exports name a file that exists, and the odds of that happening by chance are negligible -- the ids used include $2D, $43, $44 and $67, not just small numbers. BRUTUS carries the same pair on two unrelated disks, and the two female half-elves share a portrait', candidate=False), Field(offset=255, size=1, kind=<Kind.U8: 'u8'>, name='portrait_body', label='Portrait body', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='index into the BODY* files, the same way. Head and body are adjacent and independent', candidate=False), Field(offset=256, size=1, kind=<Kind.U8: 'u8'>, name='roster_in_use', label='Roster in use', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="record 0x100-0x11F **is** the SAVEDGAME1 roster block. An exported .chr and the roster page agree in 31 of those 32 bytes for every character, differing only at 0x10D. Two agents reached that independently -- one from LIBRARY $3189/$319A, which copies $8300 + N*$20 in and out, the other from matching exports against saves by name. So a record is four blocks the game saves separately: 256 + 32 + 256 + 36 = 580.\n**This is the character's STATUS, and the name is now wrong.** Zero means the slot is empty -- DROP CHARACTER writes it and nothing else (#104 (A converted DOS party arrives with the template save's spare characters still in it)) -- and any other value is a state the sheet puts into words. LIBRARY $38BE draws that line: `LDA $6C00 / AND #$07 / CLC / ADC #$29 / TAX / LDA #$16 / JMP $322D`, and the string table at LIBRARY $3439 (low) and $347B (high) holds ids $29-$30 as HITPOINTS, OK, GONE, DEAD, DYING, UNCONSIOUS -- the game's own spelling -- RUNNING, STONED. So the low three bits are the state and **bit 7 is a separate flag, masked off for display**: $1BF6 skips a slot whose bit 7 is set when it sums the party's strength, and the party panel draws such a name in **red**. That colour was recorded here as grey and it is not: LIBRARY $3E47 is `LDX $6E34 / CMP #$80 / BCC + / LDX #$02`, and 2 is red. **CONFIRMED, and the flag is independent of the low three bits** (#235 (Two unattributed DOS byte ranges in the combat tail are dropped converting to C64, and nobody knows what they hold)): $81 -- OK with bit 7 set -- draws OK on the sheet and red in the panel, and $05 -- unconscious with bit 7 clear -- draws UNCONSIOUS on the sheet and the panel's ordinary colour, both in one boot beside three controls at $01. That is the same effect DOS gets from its own separate byte at DOS 0x10D, where 0 draws the name red, so the two ports hold one flag at opposite polarities.\n**The panel is drawn from roster slot 7 down to 0**, so panel position 0 is the highest occupied slot: $3E21 is `LDA #$07 / STA $6DB4`, $3E30 calls $3189, which copies $8300 + $6DB4 * $20 into the staging page, and $3EAD decrements it. A run that reads the sheets back by panel position and calls the position a slot gets every staged value against the wrong character.\n1 OK, $82 GONE, $83 DEAD, $84 DYING, $85 UNCONSIOUS, $86 RUNNING, $87 STONED. Each is written by a routine that says what it means: POST.COM $216A sets $82 after comparing the age at 0x074 against a per-race maximum at $218E; COMBAT $0C4B turns $84 into $83 when the bleed counter $A6C0,X reaches 10, and DUNGEON $2626 chooses $85 over $83 by the same ten points below zero, which is AD&D's 0 to -9 dying and -10 dead; COMBAT $2161 walks slots 7 to 0 turning every $84 into $85 as the fight ends, which is the party binding its dying; COM.PREP $0F1F writes $86 to the side that is fleeing; SPELLE01 $AC38 writes $87 beside message 6, TURNS TO STONE.\n**Measured in the running game, not only read.** One character in PORSAVE13's Slums ambush was wounded to 1 hit point through the monitor and nothing else touched; an orc took him to 0 and the byte went 01 -> 84 on that turn, then 84 -> 85 when the party won, and 85 is what the game wrote to the save disk. Loading that disk, his sheet reads UNCONSIOUS. tools/statusdrive.py.\nFour values carry an upper bit that is not bit 7 and are not explained: SPELLE04 $AA11 writes $03 beside creature type 4, undead -- Animate Dead, a DEAD thing with bit 7 clear so it still acts -- and $09 where it forces hit points to 1; SPELLE01 $ACDA writes $11 beside GETS BACK UP; SPELLE02 $AA05 writes $21. All four mask to OK or DEAD, so nothing a player sees separates them.\nThe DOS enumeration is a different one at a different offset: 0 Okay, 1 Animated, 2 tempgone, 3 Running, 4 Unconscious, 5 Dying, 6 Dead, 7 Stoned, 8 Gone, at DOS 0x10C. Both are real; converting between them is a table, not an alignment", candidate=False), Field(offset=257, size=11, kind=<Kind.RAW: 'raw'>, name='gap_101', label='unallocated @0x101', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=268, size=1, kind=<Kind.U8: 'u8'>, name='combat_side', label='Combat side', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="bit 0 is the side the character fights on, 0 the party's and 1 the enemy's; bit 7 is the quickfight flag, set by QUICK and never cleared (goldbox-bugs.md bug 3). COM.PREP $0F15 reads `AND #$7F` as the side; 115 of 116 MON* records hold $81; 104 occupied roster slots across 17 save images hold $00 in 99 and $80 in 5, never $81. The DOS engine's own script-field accessor (GAME.OVR:0x7DE9, 0x8074) reads and writes this exact byte over DOS 0x10E/0x10F -- docs/169-dos-combat-side.md. Bits 1-6 are unused by every writer seen; masked (`& 0x81`) rather than asserted zero (#235 (Two unattributed DOS byte ranges in the combat tail are dropped converting to C64, and nobody knows what they hold))", candidate=False), Field(offset=269, size=1, kind=<Kind.U8: 'u8'>, name='party_order', label='Party order', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="the only byte where an export and the roster block disagree. In a roster block it is the record slot index, which is how the combat code finds a combatant's record; 8 means not in a party. It was read as marching order in an export, because across a six-character party the values form a complete 0-5 permutation -- but #160 (The automapper and the editor list the party backwards) showed the marching order is carried by the slot arrangement itself (the party lists from the highest occupied slot down) and no order table exists anywhere in the machine, and the slot indices of a packed six-character party are also a complete 0-5 permutation. So that evidence cannot separate the two readings. Treat it as the slot index until an export is taken from a party whose occupied slots are not 0..n-1", candidate=False), Field(offset=270, size=1, kind=<Kind.U8: 'u8'>, name='thac0', label='THAC0 current (60 - value)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="current THAC0 including strength and the readied weapon, stored as 60 - THAC0, sitting immediately before the current armour class at 0x10F. Matches the AD&D table on all eleven exports we hold. Like 0x10F it exists only in an export, and it agrees with the SAVEDGAME1 roster's +0x0E for the same character -- so an exported .chr does carry both combat numbers after all, which is worth knowing given the 1989 editor's author reported he could never find either", candidate=False), Field(offset=271, size=1, kind=<Kind.U8: 'u8'>, name='armour_class', label='AC current (60 - AC)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="current armour class including armour, shield and dexterity, stored as 60 - AC. Present only in an exported .chr -- it lies beyond the 256 bytes a save slot stores -- and it agrees exactly with the SAVEDGAME1 roster's +0x0F for the same character: BRUTUS 9, MALCYON 8, LADY KATHERINE 8. Base and current again, in different places", candidate=False), Field(offset=272, size=9, kind=<Kind.RAW: 'raw'>, name='roster_tail', label='Roster +0x10..+0x18', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="roster +0x10 the armour bonus, then +0x11 to +0x18 the **current attack form** -- the running copy of attack_forms at 0x0D9, in the engine's own order: two attack counts, two dice counts, two die sizes, two damage bonuses. All decoded in goldbox/savegame.py, and kept RAW here because the roster is the place to read them.\nThe die-size byte is +0x15, which this project called EQUIPMENT for a long time because it 'rises with what is readied'. It does: across thirteen of Donald's save disks it reads 3 for MALCYON's dart, 6 for LADY KATHERINE's short sword, 6 for ROLAND's mace, 8 for three long swords and 2 for every character with nothing readied -- 1d3, 1d6, 1d6+1, 1d8 and the unarmed 1d2, matched to the ITEMS table entry of the item each of them had equipped.\nThe first byte does NOT line up with DOS: DOS spends it on armour class from behind and the C64 on the armour bonus, 48 + bonus, which goldbox/savegame.py established by putting armour on (none 48, leather 50, banded 54, the AD&D bonuses exactly, and unmoved by a shield). Read the C64 byte the DOS way and those become 12, 10 and 6, two worse than each armour's real class and meaning nothing. docs/127-community-formats.md", candidate=False), Field(offset=281, size=2, kind=<Kind.U16LE: 'u16le'>, name='hp_current', label='HP now', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note='16-bit LE, and genuinely current hit points rather than a second copy of the maximum: GEN $0BD0 initialises it from hp_max, and both the trainer and the drain routine move it independently afterwards. It equals hp_max in every specimen only because no wounded character has yet been exported. Note it lies beyond the 256 bytes a save slot holds, so it exists in an export and not in a save', candidate=False), Field(offset=283, size=1, kind=<Kind.U8: 'u8'>, name='roster_movement', label='Movement (roster)', confidence=<Confidence.PROBABLE: 'PROBABLE'>, note="roster +0x1B, the movement rate as encumbered. Long recorded as '12 in every specimen', which held only because every specimen was the same six characters: PORSAVE10's exports read 9 in banded mail", candidate=False), Field(offset=284, size=4, kind=<Kind.RAW: 'raw'>, name='gap_11c', label='unallocated @0x11c', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='not yet examined', candidate=False), Field(offset=288, size=256, kind=<Kind.RAW: 'raw'>, name='inventory', label='Items carried', confidence=<Confidence.CONFIRMED: 'CONFIRMED'>, note="sixteen item slots of sixteen bytes, which is goldbox/items.py's ITEM_SIZE and ITEMS_PER_CHARACTER and the same 16 x 16 page the save file gives each character at $5900 + slot * $100. The shopping trip decoded the slot format field by field; slot +0 zero means empty, +4 is the plus and +10 the quantity. Pinning the extent here matters because it leaves 0x11C-0x11F **outside** the item area and outside the roster block, which ends at 0x11B -- four bytes still unaccounted for. Kept RAW because goldbox/items.py is where an item is read", candidate=False), Field(offset=544, size=36, kind=<Kind.RAW: 'raw'>, name='region_220', label='unknown @0x220 (record tail)', confidence=<Confidence.UNKNOWN: 'UNKNOWN'>, note='E4 A0 02 6B 04 05 06 07 08 20 A0 0B 20 0D E9 06 10 11 00 0F 08 0E 0E 08 0E 0E 0E 0E 0F 08 0E 0E 00 0E 0E 0E - densest region in the specimen; runs to the final byte of the record', candidate=True))¶
every one of the 580 bytes belongs to exactly one entry.
- Type:
The complete layout
- goldbox.layout.LOAD_ADDRESS = 27392¶
Load address seen in
brutus.chr. Informational only.
- goldbox.layout.NAME_SIZE = 20¶
Width of the character-name field.
- goldbox.layout.PRG_SIZE = 582¶
Size of the record as stored in a PRG file (record + 2-byte load address).
- goldbox.layout.RECORD_SIZE = 580¶
Size of the character record proper, in bytes.
- goldbox.layout.candidate_regions()[source]¶
Unknown regions explicitly flagged as worth investigating.
- Return type:
- goldbox.layout.field_by_name(name)[source]¶
Return the field called name.
- goldbox.layout.fields_with_confidence(level)[source]¶
All fields at exactly level.
- Parameters:
level (goldbox.layout.Confidence)
- Return type:
- goldbox.layout.format_table(include_gaps=True)[source]¶
Render the layout as a fixed-width text table (documentation source).
- goldbox.layout.named_fields()[source]¶
Fields that have a claimed meaning (CONFIRMED / PROBABLE / GUESS).
- Return type: