goldbox.neutral

One character, in no port’s terms, and the report every codec fills in.

Three formats in two directions is six converters; three codecs around one neutral record is three readers and three writers, and a fourth format then costs two pieces rather than four. This module is the middle.

DOS file –reader–> NeutralCharacter –writer–> C64 record

A reader decodes one port’s bytes into named neutral values. A writer encodes those values into another port’s bytes. Neither knows the other exists: the DOS reader names DOS offsets, the C64 writer names C64 fields, and what passes between them is this record.

What a neutral value carries

Not just a number. Each Value carries

  • the decoded value, in the neutral convention the vocabulary below states;

  • a confidence, taken from the grade the source port’s field table gives the field it was read from. A writer asks for what it is willing to stand behind (NeutralCharacter.take()) and gets nothing rather than a guess – refusing to write is the point of the grades, not a decoration on them;

  • an origin, the reader’s one-line phrase for where the value came from, which is what the writer’s provenance report quotes.

Who says what

The split that keeps a codec honest is: a reader says where a value came from, a writer says where it went and what it could not take. So the DOS reader’s origin for the spellbook is “DOS spellbook @0x033, one byte per spell” and the C64 writer’s line is that phrase plus “packed to one bit” – its own packing rule, not the reader’s. port names the source port so a writer can say whose value it is turning away without knowing anything else about it.

Reporting

Report is the one shape every direction reports in: a provenance for every output byte, the fields left behind, and the conversions that changed something. disposition() builds the table that makes a silent drop impossible – a field the source declares and the codec names nowhere.

Module Attributes

FIELDS

every field a codec may set, and what it means.

ABILITIES

The seven abilities in the order every port stores them, which is also the order abilities_second is keyed in.

STATUS_NAMES

a codec handed a name outside this tuple has a bug, and a codec that cannot spell one of them in its own port reports the loss rather than choosing the nearest thing.

Functions

disposition(direct, transformed, dropped, into)

Every field a codec knows about and what it does with it.

undeclared(declared, table)

(unaccounted, unknown): source fields the codec never names, and names the codec claims that the source does not declare.

Classes

NeutralCharacter

One character as named neutral values, with a report line for each.

Provenance

How a value's report line reads, which is also what kind of value it is.

Report

Where each byte of the output came from, and what was left behind.

Value

One field of the neutral record: the value, where it came from, how far it is trusted, and the shape of the sentence that will report it.

Writer

The take-refuse-report protocol every writer shares.

Exceptions

NeutralError

A field name the neutral vocabulary does not declare.

goldbox.neutral.ABILITIES: tuple[str, ...] = ('strength', 'intelligence', 'wisdom', 'dexterity', 'constitution', 'charisma', 'exceptional_strength')

The seven abilities in the order every port stores them, which is also the order abilities_second is keyed in. One tuple, because three modules need the same order and a permutation between two of them would be silent.

goldbox.neutral.FIELDS: dict[str, str] = {'abilities_second': 'ability name -> the second of the two copies of that score the later titles keep. Curse of the Azure Bonds and every title after it store each ability twice, on both ports, and the two copies are equal in every record measured -- 406 DOS pairs and six C64 records. **Which copy the engine treats as current is not established**, so a codec carries both rather than choosing, and a codec whose port keeps one copy takes nothing from this', 'active': "false for a character the game has taken out of the party and marks as such. Both ports draw the name **red** in the party panel for it, and the C64 also leaves the character out of the party's own strength; they hold the flag at opposite polarities and each codec converts. True is the ordinary state", 'age': 'age in years', 'alignment': 'law * 3 + morality', 'armour_class': 'armour class as the game last computed it, stored 60 - value (63 is AC -3)', 'armour_class_base': 'armour class with nothing worn, stored 60 - value', 'attack_forms': 'the eight attack-form bytes', 'attack_level': 'the level the attack tables are read at', 'char_class': 'the single class code, in the shared 18-entry order', 'charisma': 'CHA', 'class_bits': 'one bit per class held, in the shared bit order', 'constitution': 'CON', 'copper': 'copper pieces', 'dexterity': 'DEX', 'electrum': 'electrum pieces', 'encumbrance': 'total weight carried, in tenths of a pound', 'exceptional_strength': 'the 18/xx percentile, 0 when there is none', 'experience': 'experience points', 'former_levels': "class name -> the level a dual-classed character left that class at. Empty for everybody else. AD&D 1st edition lets a human leave one class for another once, keeping the old class's level and using neither its hit dice nor its abilities again, and a title that allows it keeps the old level so the character can still be that class in the parts of the rules that ask", 'gems': 'gems, counted not valued', 'gold': 'gold pieces', 'granted_effects': 'whole effect records that never expire and are not innate -- what a readied ring, girdle or cloak granted. Nine bytes each, in the shared shape `goldbox/dos.py` reads: the effect id, a little-endian duration of zero, the value the effect carries, the flag the engine reads when the item comes off, and a next pointer left NULL because the engine rebuilds the chain on load', 'hostile': "true for a combatant on the enemy's side. Never true for a character the engine saved into a party: both ports write 0 for every player character, and it is here because both ports store it beside the character and a conversion copies a field rather than assuming it", 'hp_current': 'hit points now', 'hp_lost_to_drain': 'hit points lost with those levels', 'hp_max': 'maximum hit points', 'hp_rolled': 'hit points rolled, before the constitution bonus', 'infravision': 'infravision range in feet; a property of the race, which some ports store and others derive', 'innate_effects': 'effect ids that are properties of the character rather than spells running on it', 'intelligence': 'INT', 'inventory': 'the items carried, each in the shared sixteen-byte item shape `goldbox/items.py` reads', 'jewelry': 'pieces of jewelry, counted not valued', 'level': 'the character level the game itself keeps', 'levels': "class name -> that class's level, in the source's slot order", 'levels_drained': 'levels lost to undead and not yet restored', 'movement': 'movement rate unencumbered', 'movement_current': 'movement rate as the game last computed it', 'name': "the character's name, plain text", 'npc': 'true for a companion the party picked up rather than one the player made', 'npc_control_byte': "the full control byte both the C64 and DOS keep for a companion -- C64 record 0x0B8, DOS's field_83_87 second byte in Pool of Radiance and Curse of the Azure Bonds, first in Secret of the Silver Blades. Bit 7 is always set (npc is true); the low seven bits are morale, stored halved. Neither port decodes it -- a converter copies it unchanged -- and it is set only when npc is true; a player character has no morale to carry (#303)", 'party_order': 'position in the marching order', 'platinum': 'platinum pieces', 'portrait_body': 'the body half of the same pair, one of twelve', 'portrait_head': "which of the fourteen creation-menu heads the character sheet draws, as the art's own id -- the C64 record's spelling; the DOS codec converts its menu position to and from it", 'quickfight': "true once the player has chosen QUICK for the character; neither port ever clears it, so it is the state the character's next fight starts in", 'race': 'race index, in the shared Gold Box order', 'roster_tail': 'the derived combat block the roster keeps beside the record', 'save_breath': 'save vs breath weapon', 'save_paralysis': 'save vs paralysis, poison and death magic', 'save_petrification': 'save vs petrification and polymorph', 'save_spell': 'save vs spell', 'save_wands': 'save vs rod, staff and wand', 'sex': '0 male, 1 female', 'silver': 'silver pieces', 'size_small': '0 small, 1 large', 'spells_castable': "class name -> slots free per spell level, ascending. How many levels and how many classes is the source title's: Pool of Radiance keeps three levels for the cleric and the magic-user, Curse of the Azure Bonds five for those two and the druid, Secret of the Silver Blades seven", 'spells_known': 'spell ids in the spellbook, ascending', 'spells_memorised': 'spell ids memorised, highest first', 'status': 'the state the game puts into words on the character sheet, as one of the names in STATUS_NAMES. A name and not a number because the ports number these differently and DOS holds two the C64 has no value for at all', 'strength': 'STR, 3-18 (25 for a monster)', 'thac0_base': "the class-and-level THAC0, before anything carried, in the family's stored 60 - value encoding", 'thac0_current': 'THAC0 as the game last computed it, stored 60 - value', 'thief_climb_walls': 'climb walls, per cent', 'thief_find_traps': 'find and remove traps, per cent', 'thief_hear_noise': 'hear noise, per cent', 'thief_hide_in_shadows': 'hide in shadows, per cent', 'thief_move_silently': 'move silently, per cent', 'thief_open_locks': 'open locks, per cent', 'thief_pick_pockets': 'pick pockets, per cent', 'thief_read_languages': "read languages, per cent (a halfling's is negative)", 'turn_power': "the cleric's turning strength", 'unnamed_0ab': "the identity draw the engine makes at creation and uses for exactly one thing -- telling two characters of the same name apart when one is added to a party. DOS keeps one byte of it at 0x0AB; Pool of Radiance's C64 GEN draws two at 0x0E6-0x0E7 and never rewrites them; an Amiga Pool of Radiance record holds its own copy at the same offset DOS does, re-cut straight across by `goldbox.amiga.to_dos_record` (#378, An Amiga character converted to DOS loses the identity byte his own record has always held). None of the three ports ever reads it back once written, so a writer with no source for it is free to derive one instead, which is what a DOS destination with no supplied value does (#216, Every converted DOS character carries the same identity byte at 0x0AB) -- and what it still does for a pure DOS-to-DOS conversion even though DOS holds the byte too, since `goldbox.dos.IDENTITY_HELD_PORTS` trusts every port but DOS's own (#378). Curse of the Azure Bonds and Secret of the Silver Blades never draw the C64 pair at all, so a source from either has none to give (#258, The C64 side of 0x0AB is unnamed, so the conversion drops it with no issue behind it)", 'wisdom': 'WIS'}

every field a codec may set, and what it means.

A name here is the thing, not any port’s storage of it, and a reader that invents a name outside this table is refused – a typo would otherwise be a field silently unread by every writer. Where a value needs a convention (an order, a unit, an encoding) the entry states it, and that convention is the neutral one: a port whose own encoding differs converts on the way in.

Type:

The neutral vocabulary

class goldbox.neutral.NeutralCharacter[source]

Bases: object

One character as named neutral values, with a report line for each.

port names the port a reader took this from – “DOS”, “C64”, “Amiga” – so a writer can say whose value it is turning away. dropped is what the reader itself could not carry: fields of the source with no neutral home, named on the way past rather than lost.

game is the title whose tables the port-relative indices were read in – race, char_class and class_bits are numbers into a table that is not the same in every Gold Box game, so a writer that wants a name asks goldbox/games.py with this in hand. None means Pool of Radiance’s, which is what a caller with no title in hand means.

__init__(port, source=None, game=None)[source]
Parameters:
Return type:

None

drop(what)[source]
Parameters:

what (str)

Return type:

None

dropped: list[str]

Source fields with no neutral home, said out loud by the reader.

fields: dict[str, goldbox.neutral.Value]
get(name, default=None)[source]
Parameters:
Return type:

Any

keys()[source]
Return type:

Iterable[str]

set(name, value, origin, confidence=Confidence.CONFIRMED, how=Provenance.COPIED, dropped=())[source]
Parameters:
Return type:

None

take(name, floor=Confidence.GUESS)[source]

The value, or None when the reader trusts it less than floor.

This is how a codec refuses to write what it does not understand: it asks for a field at the grade it is willing to stand behind, and a field graded below that comes back as nothing to write and something to report, never as a plausible-looking guess.

Parameters:
Return type:

goldbox.neutral.Value | None

unwritten(taken)[source]

Neutral fields a writer did not consume, in the order they were set.

The other half of disposition(): that one catches a source field no codec names, this one catches a neutral value no writer took.

Parameters:

taken (Iterable[str])

Return type:

list[str]

value(name)[source]
Parameters:

name (str)

Return type:

goldbox.neutral.Value

warnings: list[str]

Anything the read itself could not do faithfully.

exception goldbox.neutral.NeutralError[source]

Bases: KeyError

A field name the neutral vocabulary does not declare.

class goldbox.neutral.Provenance[source]

Bases: enum.Enum

How a value’s report line reads, which is also what kind of value it is.

The separator is the whole of it, and the three of them are the three honest sentence shapes a provenance line has: this byte came from somewhere, this byte is something, or this byte is the same value in the destination’s own shape.

COMPUTED = ': '

Derived by a rule; the source port does not store it at all. infravision: computed from race ...

COPIED = ' <- '

Taken from a named field of the source port. thac0 <- DOS ...

RESHAPED = ', '

The same value, re-cut to the destination’s shape. name, re-padded from ...

class goldbox.neutral.Report[source]

Bases: object

Where each byte of the output came from, and what was left behind.

docs/117-save-conversion.md makes this the test that replaces a round trip: for any offset in the output, say where that byte came from. Each port’s codec subclasses this to say how much of its output has to be explained – every byte, or only the non-zero ones.

__init__(total=0, sources=<factory>, dropped=<factory>, warnings=<factory>)
Parameters:
Return type:

None

dropped: list[str]

Fields with no home in the destination, said out loud rather than dropped.

note(offset, size, why)[source]
Parameters:
Return type:

None

sources: dict[int, str]

Offset -> a one-line provenance.

summary()[source]
Return type:

str

summary_notes()[source]

Lines a port’s own report adds between the count and the warnings.

Return type:

list[str]

total: int = 0

How many bytes the provenance covers.

warnings: list[str]

Anything the conversion could not do faithfully.

goldbox.neutral.STATUS_NAMES: tuple[str, ...] = ('okay', 'animated', 'temporarily gone', 'running', 'unconscious', 'dying', 'dead', 'stoned', 'gone')

a codec handed a name outside this tuple has a bug, and a codec that cannot spell one of them in its own port reports the loss rather than choosing the nearest thing.

The order is DOS’s own numbering – nine length-prefixed words in START.EXE from file offset 0xD191, which is the enumeration DOS record 0x10C indexes – so the DOS reader may use the index and no other codec may. The C64 numbers seven of these differently in the low three bits of record 0x100 and has no value at all for animated or temporarily gone; goldbox/c64_codec.py’s STATUS_BITS is that port’s own table.

Type:

Every value the neutral status field may hold, and the whole of it

class goldbox.neutral.Value[source]

Bases: object

One field of the neutral record: the value, where it came from, how far it is trusted, and the shape of the sentence that will report it.

__init__(value, origin, confidence=Confidence.CONFIRMED, how=Provenance.COPIED, dropped=())
Parameters:
Return type:

None

confidence: goldbox.layout.Confidence = 'CONFIRMED'
dropped: tuple[str, ...] = ()

What the reader had to leave behind to produce this value – the running spells left over when the innate ones were picked out, say. A writer emits them where it consumes the field, so a drop is reported beside the thing it was dropped from.

how: goldbox.neutral.Provenance = ' <- '
line(destination, extra='')[source]

The provenance line for a destination field this value fed.

extra is the writer’s own half – the rule it applied on the way in, which the reader cannot know.

Parameters:
  • destination (str)

  • extra (str)

Return type:

str

origin: str
value: Any
class goldbox.neutral.Writer[source]

Bases: object

The take-refuse-report protocol every writer shares.

Hoisted from goldbox/c64_codec.write, where use and emit were closures a second writer would have copied by hand – which is exactly what goldbox/amiga.py did, against a different middle, and the mistake this class exists to end. A writer constructs one around the character and its own report and gets four things it would otherwise re-implement:

  • use() – take a field at the floor, and turn a refusal into a report line rather than silence. A refused value’s own dropped list still reaches the report: what a reader had to leave behind to produce a value is a fact about the source whether or not the value is written.

  • emit() – the provenance note for the bytes a value became.

  • get() – a plain value for a derivation, at the same floor. NeutralCharacter.get does not apply one, and a writer that computes a byte from a field it would have refused to copy is standing behind the value twice as hard, not half as hard.

  • finish() – the closing sweep: neutral fields this writer took nothing from, then the reader’s own drops and warnings.

dropped is the codec’s own (name, why) table of fields it takes nothing from, so that the sweep reports this conversion’s reason for leaving a field behind rather than a generic sentence. It reports what the character actually carries, which is why the whole-contract statement lives in the codec’s field_disposition() and is tested there instead.

__init__(char, report, into, floor=Confidence.GUESS, dropped=())[source]
Parameters:
Return type:

None

emit(v, destination, offset, size, extra='')[source]
Parameters:
Return type:

None

finish()[source]

The closing sweep every writer used to copy by hand.

Return type:

None

get(name, default=None)[source]

A bare value for a rule to compute from, floor applied.

Does not count as taking the field: a writer that derives one byte from race and also copies race reports the copy, and a writer that only derives must still use the field once if it wants the field counted as consumed.

Parameters:
Return type:

Any

taken: list[str]
use(name)[source]

The value, if the reader stands behind it at the floor.

A field graded below the floor comes back as nothing to write and something to report, never as a plausible-looking guess.

Parameters:

name (str)

Return type:

goldbox.neutral.Value | None

goldbox.neutral.disposition(direct, transformed, dropped, into, derived=(), constants=())[source]

Every field a codec knows about and what it does with it.

The tables are the codec’s whole account of itself, and the test that keeps it honest is undeclared(): a field the source declares and none of them names would be a field dropped in silence, which docs/117-save-conversion.md forbids.

derived and constants are the two a destination does not need to be given – a field it recomputes on load, and one that holds the same value in every record anybody has read. They report as derived: and constant: rather than dropped:, because a player is told about a loss and neither is one (#324 (The import pane tells a player nine fields could not be converted that the C64 recomputes for itself)). They are optional so a codec that has measured none still calls the one builder rather than growing a second.

Parameters:
Return type:

dict[str, str]

goldbox.neutral.undeclared(declared, table)[source]

(unaccounted, unknown): source fields the codec never names, and names the codec claims that the source does not declare. Both empty is the only passing state.

Parameters:
Return type:

tuple[set[str], set[str]]