automap.actions¶
Acting on the running game: the writes, and when each one is legal.
docs/102-live-actions.md is the plan; this is the engine half of it. No Qt in
here, the same way live.py and combat.py have none – an action takes a
Target, decides whether it is legal, and either writes or says why not. The
window wires buttons to these; the tests drive them against MemoryTarget.
Everything is gated on the mode flag the loader dispatches on, and never on
the screen: 2 is COMBAT. An action that is illegal in combat refuses at
apply time and not only in its tooltip, because a button’s enabled state is
one poll interval stale and a fight can start inside that interval.
Every address here is per title, and comes from `goldbox.games.Game` – the
slot area, the item area and the roster page all follow save_load_address,
so Curse and Silver Blades are written at $4F00, $5B00 and $6700 and not
at Pool of Radiance’s $4D00, $5900 and $8300 (#29).
The mode flag is the one address that does not follow it. It is a byte of
LINKER’s own resident page, not of the save image, so it had to be read out of
each title’s loader: $6E11 in Pool of Radiance, $7F11 in Curse and Silver
Blades, and 2 is COMBAT in all three because their overlay name tables are the
same table entry for entry (#29). The three Krynn-era titles have never been
read, so Game.mode_flag is None there and every action refuses rather than
write with no way to see a fight – an unmeasured address answers “not combat”
whatever the machine is doing, which is a gate that is open rather than one that
is missing.
Nothing here writes to a disk. These change the machine’s memory; the player saves in the game as usual, which is what keeps the losslessness promise intact.
Two facts from docs/50-experiments.md bound what is safe to write:
The item area is a copy. Poking
$5A98– Pool of Radiance’s – to 150 lb was reverted by the game, so a write there is fed from a master elsewhere and may not stick.IdentifyItemswrites anyway – it is one bit and the failure mode is “nothing happened” – but it says so, and it is the one action whose effect the caller should verify by looking at the game.`$6B00` is the resident character record and
$6C00the resident roster block, so the game works on a copy of whichever character is in hand. A write into the slot area is what the next save writes out; a write into a field the game is holding in the resident copy at that moment can be overwritten by it.
Module Attributes
Donald's wording, approved 2026-08-24. |
|
Every record field a level-up writes. |
|
roster block |
|
Pool of Radiance's row, which is where the constants below come from. |
|
Which |
|
The live party square inside |
|
The live travel-grid square, window-local x then y. |
|
|
|
The |
|
Slot 9 of the loaded-files cache -- the resident |
|
|
|
the origin of the scratch/persistent split. |
|
it decides whether |
|
The tail of |
|
DUNGEON is the resident overlay. |
|
|
|
|
|
No title given means "whatever table this build has", which is what every caller written before there was a second title meant. |
|
|
|
|
Functions
|
Every action, in the order the bar lays them out. |
|
One row of a title's area table, or None. |
|
This title's area table, or nothing if there is none. |
|
What to call a title in a refusal. |
|
True only when the mode flag says combat. |
|
Set the PC and let the machine run. |
|
Where to put a party arriving in an area whose square nobody harvested. |
|
Every reason levelling refuses, most specific first. |
|
Which overlay is running, or None if that cannot be established. |
|
The bytes a fast travel writes: |
|
Which register id this VICE calls |
|
What to call the map now resident, or None if we cannot say. |
|
The CPU's PC, or None where this backend cannot say. |
|
This title's flag, or None while the bit itself is unfound. |
|
The party, or None when these bytes are not one. |
Classes
One button's worth of behaviour. |
|
Clear the bit the combat menu's QUICK sets, for everyone. |
|
Put the party in another area, the way the game's own exits do. |
|
Current hit points to maximum, for everyone standing. |
|
Clear the hidden-name bits on every item the party carries. |
|
Raise a character a level, writing what the training hall writes. |
|
One occupied party slot: the record, the roster block, and addresses. |
|
What an action did, or why it did nothing. |
|
Everyone in the roster, from the same two reads the live view makes. |
|
Where the per-character quickfight bit lives. |
|
Clear the flag on the tick that combat ends, if the caller wants that. |
|
Write the stored memorised list back, so nobody has to rest for it. |
|
Memorised spell lists, kept in a file so they outlive the window. |
|
Remember what everyone has memorised, so it can be put back later. |
|
Whether an action may run now, and the reason when it may not. |
|
Where the party was before a fasttravel, so that |
- automap.actions.ANY_TITLE = <object object>¶
No title given means “whatever table this build has”, which is what every caller written before there was a second title meant. A caller that has a title passes it, and five of the six get nothing.
- class automap.actions.Action[source]¶
Bases:
objectOne button’s worth of behaviour.
Subclasses set
name,labelandcombat_legal, and implementrun.applyis what a caller uses: it re-checks legality, so an action is safe to call from a button whose enabled state is a poll interval old.- __init__(game=None)[source]¶
Which title this acts on. None is Pool of Radiance.
Every address an action writes is
Gamegeometry now – the slot area, the item area and the roster page all followsave_load_address– so the title is the whole of what a subclass has to be told (#29).- Parameters:
game (goldbox.games.Game | None)
- combat_legal = False¶
False means “refuse while the mode flag is 2”.
- confirm = ''¶
Non-empty means ask this question before running. There is no in-game undo for anything that carries one.
- description = ''¶
One line, for a tooltip.
- property descriptor: goldbox.games.Game¶
This action’s title, as the descriptor its addresses come from.
A property rather than the attribute itself because
LevelUpaccepts a key or aLevelTablesas well –goldbox/levels.pyis duck-typed on.keyon purpose – and the addresses need aGame.
- label = ''¶
Button text.
- name = ''¶
Stable identifier, for settings and for wiring.
- automap.actions.CMD_REGISTERS_AVAILABLE = 131¶
CMD_REGISTERS_AVAILABLE. Not inautomap/vice.py’s command table because nothing else needs it.
- class automap.actions.ClearQuickfight[source]¶
Bases:
automap.actions.ActionClear the bit the combat menu’s QUICK sets, for everyone.
The write is the roster block at
Game.roster_base + slot * $20, byte+0x0C, bit 7. The roster page is saved with the game, so this bit reaches the disk: eight of the player’s own save disks carry it set for one character and clear for the other seven.What is established and what is not. That QUICK writes this bit, for that character alone, is established – it is the only byte outside COMBAT’s own scratch that moved. That it survives a fight is established from those eight saves. What is not established is that clearing it hands a character back: setting it out of band mid-fight did not stop the game asking that character for orders, so on the evidence so far the bit marks “the computer is playing this character’s action” rather than a sticky quickfight. Clearing it is therefore safe – it restores the byte every clean save has – and its effect on the next fight is unproven. See
docs/80-fields-wanted.mdfor the experiment that would settle it.Legal anywhere. Clearing it mid-fight is the same write as clearing it afterwards.
- __init__(flag=None, game=None)[source]¶
Which title this acts on. None is Pool of Radiance.
Every address an action writes is
Gamegeometry now – the slot area, the item area and the roster page all followsave_load_address– so the title is the whole of what a subclass has to be told (#29).- Parameters:
flag (automap.actions.QuickfightFlag | None)
game (goldbox.games.Game | None)
- combat_legal = True¶
False means “refuse while the mode flag is 2”.
- description = 'Disable quickfight for all characters.'¶
One line, for a tooltip.
- label = 'Quickfight off'¶
Button text.
- name = 'clear-quickfight'¶
Stable identifier, for settings and for wiring.
- automap.actions.DUNGEON = 1¶
DUNGEON is the resident overlay.
$2034is some other overlay’s code when it is not.- Type:
$6E11
- automap.actions.FASTTRAVEL_DISK = 28178¶
Which
POOLdisk the arriving area lives on.LIBRARY $43A4reads it and prompts if that disk is not in the drive.
- automap.actions.FASTTRAVEL_FROM = 18930¶
$2011-$2016sets it, and the arriving script’s entry 4 compares it against its own id.It survives the overlay restart, and the arriving script does read it. P43 measured the opposite once and was wrong about why:
$19E1–LDX #3 / JSR $19FC / LDA $6E1B / AND #$7F / STA $49F2– rewrites it to the arriving id once the entry has run, so a snapshot taken after the area settles always shows the current area whatever was written here. Proved by fasttraveling into area 22 with$49F2= 23:ECL16’sCOMPARE [$49F2], 23 / IF= / EXITfired, the script left the square alone, and the party stood where the fasttravel had put it.- Type:
Where the party came from
- automap.actions.FASTTRAVEL_INDOORS = 18918¶
it decides whether
LOADFILESasks for aGEOor aSQRDATA.$4BE6in Curse and Silver Blades, read out ofNEWECL’s own tail call rather than relocated by hand – and in a running Curse this address isLIBRARYcode (#29).- Type:
Non-zero indoors, zero on the overland map. Read, never written
- automap.actions.FASTTRAVEL_SCRATCH = 18944¶
the origin of the scratch/persistent split.
- Type:
Zeroed by
$202A-$2032
- automap.actions.FASTTRAVEL_SLOT = 28187¶
The
ECLslot of the loaded-files cache. Bit 7 means “reload me”.
- automap.actions.FASTTRAVEL_TRAVEL_X = 18883¶
The live travel-grid square, window-local x then y. Outdoors
$C04B-$C04Dis not the party’s position –GDRIVE00is not resident – and no arriving script places an outdoor party (docs/140-loaded-files-cache.md), so the departing script’s write is the only thing that ever sets it, and a fast travel used to skip that (#178 (Fast Travel to the wilderness leaves the party on whatever overland square it last stood on)). Two bytes: the travel facing is$033D, page 3, unsaved and of unknown encoding (docs/113-world-map.mdunknown 3), and is not written here.
- automap.actions.FASTTRAVEL_WALLS_SLOT = 28188¶
Slot 9 of the loaded-files cache – the resident
WALLSfile, which loads at$ED50under the KERNAL.#156: every area but New Phlan uses aWALLDEFtriple instead:LOADPIECES(DUNGEON $276E) marks the three slots dirty,$145Creloads each into the$8C00staging buffer, and$1485unpacks them on to$ED50,$F05Cand$F368– never telling this slot its memory has been overwritten. A genuine exit empties it –ECL00 $9955/$9BDC,LOADFILES 255, 255, 127, run on the way out of New Phlan – butFastTravelentersNEWECLat its tail, past that statement, so a fast travel out of New Phlan never gives the slot back and the next arrival there finds it still sayingWALLS00and skips the reload (LIBRARY $4225: maskedAequal to the slot means no load).$FFis the value: it is the empty markerdocs/140-loaded-files-cache.mdnames for this cache, it is what every engine-written save in aWALLDEFarea carries in this slot, and it is the one valueLIBRARY $4225leaves alone – so the nextLOADFILES 0, 0, 0in New Phlan reloadsWALLS00rather than declining because the slot already says00.Pool of Radiance is the only title with this bug, because it is the only one with a `WALLS` file. A directory read of all nine Pool of Radiance sides, all six Curse sides and all six Silver Blades sides finds
WALLS00on Pool of Radiance’s alone; the other two carryWALLDEF/WALLSETpairs and nothing else. SoFastTravelAddresses.walls_slotis None there andnewecl_writesleaves the slot out rather than aiming$FFat a cache entry whose contents in those titles nobody has read.
- automap.actions.FASTTRAVEL_X = 49227¶
The live party square inside
GDRIVE00, of which$49C0-$49C2is a lagging copy: x, y, facing.$1A3C, called from$2034, copies these into the save’s own bytes, which is why the arrival square is written before the jump and not after.
- class automap.actions.FastTravel[source]¶
Bases:
automap.actions.ActionPut the party in another area, the way the game’s own exits do.
“Fast Travel” is what the user calls it.
FastTravelis the game’s own name for the mechanism –NEWECL– and stays the name in the code.The writes are proven; the arrival is measured. Entering
NEWECL’s handler at$2034from the key-wait loop has been done in the game and the party walked afterwards (P15,docs/118-debug-mode.md), and P20 fasttraveled into all fifteen areas that then had no arrival square and recorded where each landed. Fourteen still have none and get a square off the map instead.Three titles, one mechanism.
NEWECLis the same routine in Curse and Silver Blades – found by the script VM’s self-modifying dispatch rather than by relocating Pool of Radiance’s address, which is no constant at all ($2011,$21BA,$20E6) – and a Curse party has been fast-travelled four times on a running machine and then walked (#19 (Can Curse be fast-travelled at all, or is the mechanism Pool of Radiance's alone?)). The addresses areautomap/fasttravel.py, one row per title, andself.addressesis None for the three nobody has read.Two guards that are not optional, both re-checked at
applytime:`$6E11` must be 1.
$2034is some other overlay’s code otherwise, and jumping there is an immediate crash.the PC must be in `DUNGEON`’s key-wait loop. Mid-script or mid-load the stack reload at
$203Athrows away work in flight. This doubles as the check thatPC_REGISTERis the register we think it is.
What it cannot guard: the quest flags. Arriving this way is not the same as having played there, the arriving script assumes things the party never did, and the honest answer is to say so rather than to pretend otherwise. That is what
HELPis for, and the row keeps it under a help icon.- ATTRACT_TRAP = "this is the attract-mode demo, not a place: travelling there leaves the world -- no map, no status line, and the program counter never returns to DUNGEON's key-wait loop, so there is no way back out of it"¶
ECL1Eis the attract-mode demo and fasttraveling into it ends the session: P20 read$C04B-$C04Das254, 127, 16with noGEOresident, no status line and no command bar, and the PC never came back to the key-wait loop, so nothing could be fasttraveled out again.FastTravelBardoes not offer it; this refuses it for a caller that did not come through the dropdown (write-up lost,work/reports/p20-arrivals.md).
- HELP = "Fast travel puts the party in another area the way the game's own exits do. The area you arrive in assumes you got there by playing: its script can expect quest flags your party never set, people already spoken to and fights already won. In the fourteen areas where the game does not place the party itself, wish picks a square in the largest open part of the map, which need not be where a player would normally walk in. Nothing here can be undone from inside the game, so point the emulator at a copy of your save disk, never the original."¶
What travelling does not guarantee, in Donald’s own words, kept where it can be read rather than dismissed: the row hangs it off a help icon. It is deliberately not a list of what could go wrong in the machine – that half has been made in the game and the party walked afterwards (P15) – but of what the game assumes about a party that arrives somewhere it never played to.
There is no confirmation any more. It was a dialog in front of every trip until Donald tested the feature; the game itself asks for the disk it wants, so the popup asked a question the game was about to ask again.
- NO_TRAVEL_GRID = 'this area is on an overland map and {title} has no travel grid, so there is nowhere to put the party'¶
An outdoors row in a title with no travel grid. Nothing can produce it today – Pool of Radiance is the only title with a square-engine overland and the only one whose area table has an
outdoorsrow – and it is here becausenewecl_writeswould otherwise have no address to write the square at and would raise where a refusal belongs.
- OUTDOORS_TRAP = 'the party is on the overland map (${indoors:04X} is 0) and this area is indoors: travelling that way hangs the loader asking for the disk for ever. Walk off the overland map first'¶
FastTraveling out of an overland area into an indoors one hangs the loader: it asks for the target’s side and goes on asking, and re-attaching, attaching something else first and poking
$49E6afterwards all fail. The other direction is fine – area 23 to 26 worked, and the arriving script sets$49E6itself. Seedocs/50-experiments.md.Measured in Pool of Radiance and never tested elsewhere, and the refusal stands in every title on that precedent. It is not safe to assume the answer is the same: Curse’s key-wait loop has a block Pool of Radiance has nothing at,
$102E-$103A, gated on the indoors flag and callingGDRIVE00 $C003(#19). The address is the title’s own, so a Curse session does not read back Pool of Radiance’s$49E6.
- __init__(game=None)[source]¶
Which title to travel in. None is Pool of Radiance.
The addresses come with the title and are never defaulted.
automap/fasttravel.pyhas a row for each of the three titles whose overlays have been read;self.addressesis None for the other three, and every method below refuses rather than falling back to Pool of Radiance’s numbers. Falling back is the one answer that corrupts –#14fixed it for the area list, and this is the same mistake one address at a time.- Parameters:
game (goldbox.games.Game | None)
- addresses¶
This title’s
NEWECLaddresses, or None if nobody has read it.
- apply_back(target)[source]¶
FastTravel to where the last fasttravel started, on the square it started on.
- Return type:
- back: automap.actions.Waypoint | None¶
Where the last fasttravel came from.
FastTravel Backreads it; None until a fasttravel has been made, which is why the button starts disabled.
- combat_legal = False¶
False means “refuse while the mode flag is 2”.
- static current_area(target, addresses=None)[source]¶
The id of the area running now:
$6E1Bwithout the reload bit.- Return type:
int | None
- static current_indoors(target, addresses=None)[source]¶
$49E6: non-zero indoors, zero on the travel grid.Curse’s and Silver Blades’ is
$4BE6, which is not a guess and not a relocation applied by hand:NEWECL’s own tail call reads it, and the three routines are the same instructions –LDA <flag> / BEQ / LDX #$02 / LDA $C04B,X / STA <save position>,XatDUNGEON $1A3C,$1BE7and$1AF9. In a running Curse$49E6isLIBRARYcode (#29).- Return type:
int | None
- static current_overland(target, addresses=None)[source]¶
$49C3/$49C4, the live travel-grid square, or None where the title has no travel grid to have one on.
- description = 'Teleport to another area.'¶
One line, for a tooltip.
- label = 'Fast Travel'¶
Button text.
- name = 'fasttravel'¶
Stable identifier, for settings and for wiring.
- warnings(target, area, arrival, overland=None)[source]¶
Everything true about this fasttravel that the caller should know first.
Every address named here is
self.addresses’, so a Curse session reads$4BF2and$4BE6rather than quoting Pool of Radiance’s numbers at a machine that keeps something else there.
- class automap.actions.HealParty[source]¶
Bases:
automap.actions.ActionCurrent hit points to maximum, for everyone standing.
Illegal in combat. Donald: the Heal Party button, and the fast-travel dropdown alongside it, should refuse during a fight the way Store/Restore Spells and Identify already do. It used to be legal mid-fight – healing is a cheat rather than a corruption risk, and nothing the game recomputes would notice – but that is no longer what the button offers.
The write is the roster block at
Game.roster_base + slot * $20, byte+0x19. Current hit points are not in the stored 256 bytes of a record – record0x119is export-only – so this is a live-only address and the roster is the only copy a running game has.A character at zero is skipped. Zero is dead or dying, and whatever else the game marks that with is not decoded; raising the hit point byte alone would be a half-write, which is the same objection that stops levelling.
Confirmed live, while the write was still allowed mid-fight: four wounded characters healed and the game’s own party list redrew at their maxima, and SILAS went 8 to 9 at
$6E11 = 2. Seedocs/50-experiments.md.- description = 'Set current hit points to maximum for every concious character.'¶
One line, for a tooltip.
- label = 'Heal party'¶
Button text.
- name = 'heal'¶
Stable identifier, for settings and for wiring.
- class automap.actions.IdentifyItems[source]¶
Bases:
automap.actions.ActionClear the hidden-name bits on every item the party carries.
The low three bits of an item’s byte
+6hide its name words until it is identified – bit 0 the noun, bit 1 the qualifier, bit 2 the suffix – and clearing them is the whole of being identified.+6bit 7 is readied and is never touched.Illegal in combat, and it asks first: identification is part of the game’s economy and there is no in-game way to undo it.
The write may not stick. The item area is a copy fed from a master elsewhere – poking an item’s weight there was reverted by the game – so this is the one action whose effect is worth checking in the game’s own item list before believing it.
- description = 'Identify all items.'¶
One line, for a tooltip.
- label = 'Identify'¶
Button text.
- name = 'identify'¶
Stable identifier, for settings and for wiring.
- automap.actions.KEY_FETCH = (11854, 11883)¶
LDA $DC00for the CIA row, then the KERNAL buffer, thenRTS. FastTraveling from inside it is safe for the same reason as the loop – it is called from the loop, so$203A’s stack reload discards the same nothing – and P15 fasttraveled successfully from$2E4Ebefore this was written down. Nine idle samples in ten land in one window or the other, so refusing the fetcher made the button fail about half the times it was pressed.- Type:
The key fetcher the loop calls,
$2E4E-$2E6Ainclusive
- automap.actions.KEY_WAIT = (4290, 4332)¶
DUNGEON’s key-wait loop in the world, the one place it is safe to take the PC from – mid-script or mid-load the stack reset would discard work in flight.Measured, not guessed. 400 PC samples of an idle party landed on exactly
$10C2 $10C5 $10C8 $10CA $10CC $10CF $10D1 $10D3 $10D6in the loop and nothing above it, and the code agrees:$10E0is theJMP $10C2that closes it,$10E3-$10EBis its own exit tail, and$10ECstarts a different routine (LDA #$00 / STA $6DD5). So the window ends at$10EC.
- automap.actions.LEVEL_UP_FIELDS: tuple[str, ...] = ('level', 'thac0_base', 'hp_max', 'hp_rolled', 'experience', 'level_cleric', 'level_fighter', 'level_magic_user', 'level_thief', 'save_paralysis', 'save_petrification', 'save_wands', 'save_breath', 'save_spell', 'spells_castable', 'spells_known', 'turn_power', 'attack_level', 'attack_forms', 'thief_pick_pockets', 'thief_open_locks', 'thief_find_traps', 'thief_move_silently', 'thief_hide_in_shadows', 'thief_hear_noise', 'thief_climb_walls', 'thief_read_languages')¶
Every record field a level-up writes. Each one has to be CONFIRMED in
goldbox/layout.pybefore the action will write anything at all: a half-levelled character is a corrupt character, and one field written from a guess is enough to make it one.
- class automap.actions.LevelUp[source]¶
Bases:
automap.actions.ActionRaise a character a level, writing what the training hall writes.
The trainer is the specification and this is a copy of it.
GEN $1B8Cis the sequence a level-up runs; every routine it calls has been read andgoldbox/levelup.pynames each one beside the field it fills. Replaying the twenty-nine trainings measured indocs/119-test-party.mdthrough it reproduces the game’s own record byte for byte on every field, given the hit die it rolled.The die is rolled, because the game rolls one.
hp_rolledat0x0EDtakes a fresh roll of the class hit die at every training and derives from nothing;hp_maxderives from it exactly. The roll is reported in the outcome so the number is never silent.Money is untouched, and the trainer does take it: a flat 1000 gold at every level, with the rest of the character’s coin converted to platinum, measured across all twenty-nine. That is what walking into a school costs rather than what gaining a level costs, so none of the seven coin fields at
0x0BBis written. Movement is not recomputed either – the trainer does that from encumbrance, which nothing here changes.Healing is done, because the trainer does it. Current hit points end at the new maximum, after the die is rolled and
hp_maxhas risen. A character at 0 is refused rather than healed: zero is dead or dying and the record does not say which, which is the same refusalHealPartymakes.A magic-user has to choose.
GEN $215Aputs every spell it does not know, of a level it can now cast, on a menu and does not finish the level-up until one is picked – sospellis required for a magic-user with anything left to learn, and the action refuses rather than choosing.offers(record)is that list.Which class is not a question the player is asked. A multi-class character with two classes ready gets the one whose threshold after the level is largest –
levelup.best_next_class, andclass_for(record)is the answer for a caller that needs it before the write. That keeps the experience clamp’s ceiling as high as it goes, so the other class usually survives; pressing the button again then takes it. An explicitclass_namestill overrides.Which title, though, is a question, and it is asked.
gameis thegoldbox.games.Gamethe session is, and every table and every derivation is taken from it. None means Pool of Radiance, because every caller written before there was a second title meant that one. A title whose trainer nobody has measured is refused bylevel_up_blockersbefore a byte is written – seegoldbox.levels.TRAINER_MEASURED.- __init__(game=None)[source]¶
Which title this acts on. None is Pool of Radiance.
Every address an action writes is
Gamegeometry now – the slot area, the item area and the roster page all followsave_load_address– so the title is the whole of what a subclass has to be told (#29).
- static class_for(record, game=None)[source]¶
Which class the button would raise, or None if none is ready.
A caller has to know before it writes, because a magic-user needs its spell chosen and no other class does.
- Return type:
str | None
- confirm = 'Level up this character? There is no way to undo this in the game.'¶
Non-empty means ask this question before running. There is no in-game undo for anything that carries one.
- description = 'Level up a character without the trainer.'¶
One line, for a tooltip.
- property descriptor: goldbox.games.Game¶
gameas aGame, for the addressesrunwrites to.An unknown key falls back to Pool of Radiance’s geometry, which costs nothing:
level_up_blockershas already refused every title but the one whose trainer was measured.
- label = 'Level up'¶
Button text.
- name = 'level-up'¶
Stable identifier, for settings and for wiring.
- static offers(record, game=None)[source]¶
The spell ids a magic-user would be offered at its next level.
- static preview(record, class_name='', spell=None, game=None)[source]¶
The plan without writing it, or None if it cannot be made.
Only
experience_lostandclasses_disqualifiedare worth reading off it: the hit die is rolled again byrun, so every number that depends on the roll differs.- Parameters:
- Return type:
goldbox.levelup.Plan | None
- class automap.actions.Member[source]¶
Bases:
objectOne occupied party slot: the record, the roster block, and addresses.
The record is the stored 256 bytes zero-padded to a full 580, so anything the layout places past
0x0FFreads as zero and must not be written – live, the slots are$100apart and offset0x119of slot 0 is offset0x019of slot 1.Every address below comes from `game` and none of them is a constant: the three bases were Pool of Radiance’s
$4D00,$5900and$8300, which is what made these actions write into another title’s memory (#29). The offsets inside the payload are the same in all six titles; only the base moves, so a new title costs a row ingoldbox/games.pyand nothing here.- __init__(slot, record, roster, game=Game(key='pool-of-radiance', title='Pool of Radiance', save_file=b'SAVEDGAME0', save_load_address=18688, save_size=7168, roster_file=b'SAVEDGAME1', roster_load_address=33536, roster_size=2048, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='POOL*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter')), item_names_load_address=28416, live_position=49227, mode_flag=28177, travel_grid=True))¶
- Parameters:
slot (int)
record (goldbox.record.CharacterRecord)
roster (bytes)
game (goldbox.games.Game)
- Return type:
None
- field_address(name)[source]¶
Where a record field lives in the running machine.
Raises for anything past the stored 256 bytes, because that address belongs to the next character’s slot and writing it would corrupt them rather than this one.
- game: goldbox.games.Game = Game(key='pool-of-radiance', title='Pool of Radiance', save_file=b'SAVEDGAME0', save_load_address=18688, save_size=7168, roster_file=b'SAVEDGAME1', roster_load_address=33536, roster_size=2048, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='POOL*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter')), item_names_load_address=28416, live_position=49227, mode_flag=28177, travel_grid=True)¶
- record: goldbox.record.CharacterRecord¶
- automap.actions.NEWECL_TAIL = 8244¶
The tail of
NEWECL’s handler, past the operand fetch.$203Areloads the stack pointer from$03BF, so the call depth we interrupt does not matter.
- class automap.actions.Outcome[source]¶
Bases:
objectWhat an action did, or why it did nothing.
writesis every(address, bytes)that went to the machine, which is what makes these testable without one: assert on the addresses, not on a screenshot.notescarries what was deliberately left alone – a character the heal skipped, an item that was already identified – because “did nothing” and “did nothing to this one, and here is why” are different answers.- __init__(ok, message, writes=(), notes=())¶
- automap.actions.PC_REGISTER = 3¶
CMD_REGISTERS_AVAILABLEis served by this build and names id 3PC, 16 bits wide, besideA,X,Y,SPandFLat 0, 1, 2, 4 and 5.pc_registerasks anyway, because the id is a property of the emulator and not of the game.- Type:
VICE’s
e_PC. The fallback, and no longer a guess
- automap.actions.POOL_ADDRESSES = FastTravelAddresses(key='pool-of-radiance', title='Pool of Radiance', handler=8209, tail=8244, slot=28187, disk=28178, came_from=18930, scratch=18944, indoors=18918, key_wait=(4290, 4332), key_fetch=(11854, 11883), wall_slot_pinned=18919, wall_slot_pinned_len=3, walls_slot=28188, travel_square=18883, zeroed=(), live_square=49227, scratch_len=32)¶
Pool of Radiance’s row, which is where the constants below come from. Named so that a reader who follows one of them arrives at the table rather than at another copy of the number.
- class automap.actions.Party[source]¶
Bases:
objectEveryone in the roster, from the same two reads the live view makes.
- __init__(members, save0_bytes=b'', game=Game(key='pool-of-radiance', title='Pool of Radiance', save_file=b'SAVEDGAME0', save_load_address=18688, save_size=7168, roster_file=b'SAVEDGAME1', roster_load_address=33536, roster_size=2048, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='POOL*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter')), item_names_load_address=28416, live_position=49227, mode_flag=28177, travel_grid=True))¶
- Parameters:
members (tuple[automap.actions.Member, ...])
save0_bytes (bytes)
game (goldbox.games.Game)
- Return type:
None
- by_slot(slot)[source]¶
- Parameters:
slot (int)
- Return type:
automap.actions.Member | None
- game: goldbox.games.Game = Game(key='pool-of-radiance', title='Pool of Radiance', save_file=b'SAVEDGAME0', save_load_address=18688, save_size=7168, roster_file=b'SAVEDGAME1', roster_load_address=33536, roster_size=2048, roster_offset=0, slot_count=8, record_slot_count=12, disk_glob='POOL*.[dD]64', races=((1, 'dwarf'), (2, 'elf'), (3, 'gnome'), (4, 'half-elf'), (5, 'halfling'), (6, 'half-orc'), (7, 'human'), (8, 'monster')), class_bits=((1, 'magic-user'), (2, 'cleric'), (4, 'thief'), (8, 'fighter')), item_names_load_address=28416, live_position=49227, mode_flag=28177, travel_grid=True)¶
- members: tuple[automap.actions.Member, ...]¶
- automap.actions.QUICKFIGHT = QuickfightFlag(base=33548, stride=32, mask=128)¶
roster block
+0x0C, bit 7 – see “The quickfight bit is roster+0x0C” indocs/50-experiments.md. Selecting QUICK from the combat menu moved exactly this bit for exactly the character quickfought, and nothing else in 13568 bytes but two of COMBAT’s own scratch bytes. The offset and the mask arelive.ROSTER_QUICKFIGHT/live.QUICKFIGHT_BIT, so the roster card’s badge and this write cannot come to disagree.- Type:
Found
- class automap.actions.QuickfightFlag[source]¶
Bases:
objectWhere the per-character quickfight bit lives.
baseis the address of character 0’s byte,stridethe distance to the next character’s, andmaskthe bit.
- class automap.actions.QuickfightWatcher[source]¶
Bases:
objectClear the flag on the tick that combat ends, if the caller wants that.
Deliberately a plain object with one method: the window already polls the mode flag for the combat canvas, so this needs no timer of its own. Feed it the mode on every poll and it fires exactly on the 2-to-not-2 edge – not on every tick afterwards, which would fight the player who turned quickfight on deliberately in the next fight.
- __init__(action=None, enabled=False, game=None)[source]¶
- Parameters:
action (automap.actions.ClearQuickfight | None)
enabled (bool)
game (goldbox.games.Game | None)
- property game: goldbox.games.Game¶
the action’s, so the two cannot drift.
- Type:
Whose mode flag to watch
- poll(target)[source]¶
One tick. Returns the outcome only on the tick that fires.
A title with no measured mode flag never fires:
modeanswers None, which is the same “no edge” answer an unreadable machine gives, and the action underneath would refuse in any case.- Return type:
automap.actions.Outcome | None
- class automap.actions.RestoreSpells[source]¶
Bases:
automap.actions.ActionWrite the stored memorised list back, so nobody has to rest for it.
Illegal in combat. The write is the memorised list where this title keeps it – 81 bytes from record
0x020in Pool of Radiance – in the slot area atGame.slot_area_base + slot * $100, inside the stored 256 bytes, so it is also what the next save writes out.Only the memorised list moves. The capacity at
0x0EEsays how many spells of each level the character may prepare and does not change with resting, so restoring it would be writing a field the action has no business in.- __init__(store=None, game=None)[source]¶
Which title this acts on. None is Pool of Radiance.
Every address an action writes is
Gamegeometry now – the slot area, the item area and the roster page all followsave_load_address– so the title is the whole of what a subclass has to be told (#29).- Parameters:
store (automap.actions.SpellStore | None)
game (goldbox.games.Game | None)
- description = "Restore your character's memorized spells."¶
One line, for a tooltip.
- label = 'Restore spells'¶
Button text.
- name = 'restore-spells'¶
Stable identifier, for settings and for wiring.
- class automap.actions.SpellStore[source]¶
Bases:
objectMemorised spell lists, kept in a file so they outlive the window.
Keyed by save disk and character name, because a name is not unique across disks and the point of the store is to survive a session. The file is JSON under the config directory for the same reason
automap.jsonis: small, hand-editable, and a corrupt one is treated as empty rather than as an error – losing a stored spell list is not worth refusing to start over.
- class automap.actions.StoreSpells[source]¶
Bases:
automap.actions.ActionRemember what everyone has memorised, so it can be put back later.
Reads only, but it is refused in combat with the restore it pairs with: a list captured mid-fight is a list with the fight’s casting already spent, which is not what anybody means by “store my spells”.
- __init__(store=None, game=None)[source]¶
Which title this acts on. None is Pool of Radiance.
Every address an action writes is
Gamegeometry now – the slot area, the item area and the roster page all followsave_load_address– so the title is the whole of what a subclass has to be told (#29).- Parameters:
store (automap.actions.SpellStore | None)
game (goldbox.games.Game | None)
- description = 'Save the state of the memorized spell list for every character.'¶
One line, for a tooltip.
- label = 'Save spells'¶
Button text.
- name = 'store-spells'¶
Stable identifier, for settings and for wiring.
- automap.actions.UNSUPPORTED = 'ERROR: Action unsupported on {title}.'¶
Donald’s wording, approved 2026-08-24. Shown when the running title has no measured combat flag, so we cannot tell a fight from the map and will not write blind. The five actions all use it.
- class automap.actions.Verdict[source]¶
Bases:
objectWhether an action may run now, and the reason when it may not.
reasonis written to be shown as-is: it goes in a disabled button’s tooltip and in the refusal the action returns if it is called anyway.
- automap.actions.WALL_SLOT_PINNED = 18919¶
$49E7-$49E9, one flag per wall piece –goldbox/memory.pycalls it “wall slot pinned”.DUNGEON $14CBreads$49E7,Xbefore unpacking pieceXand returns at once when it is non-zero, so the piece keeps whatever screen codes the previous area’s wall set left in it. OnlyECL06(Valjevo Castle south-west),ECL07(the Inner Tower) andECL0A(Valhingen Graveyard) ever set it, and each clears it only on the way out – the part a fast travel skips (#179). Written unconditionally and zero, the same shape asFASTTRAVEL_WALLS_SLOTabove: it costs nothing in the areas that never set it, and one extra relocation pass inECL06’s Valjevo-to-Valjevo route, which is the one place the game left it set on purpose.This one does transfer, and it was checked rather than assumed. The array is at
$4BE7in Curse and Silver Blades –save_load_addressplus$0200, like the other two save-relative writes – and each title’sDUNGEONholds exactly one reference to it,LDA $4BE7,X / BNEin front of the same unpack setup Pool of Radiance guards (LDA #$0C / STA $B0 / LDA #$03 / STA $B1, the piece geometry). One hit per overlay, three overlays, so there is no second array anywhere that could be the real one. Which of their scripts pin a piece is unmeasured, and zeroing costs a relocation pass in the ones that do.
- class automap.actions.Waypoint[source]¶
Bases:
objectWhere the party was before a fasttravel, so that
FastTravel Backhas an answer.- __init__(area, disk, square, overland=None)¶
- property id: int¶
Spelled the way a row of the area table spells it, so that the same
legalityserves both buttons even when the table has no such row.
- overland: tuple[int, int] | None = None¶
$49C3/$49C4at departure, taken when the party was outdoors ($49E6read 0).squareis$C04B, which is notGDRIVE00’s square outdoors, soFastTravel Backfrom a window needs this instead (#178 (Fast Travel to the wilderness leaves the party on whatever overland square it last stood on)).
- automap.actions.actions(store=None, game=None)[source]¶
Every action, in the order the bar lays them out.
The window iterates this: one button per action,
labelon it,descriptionand the reason fromlegalityin its tooltip, andconfirmasked first where it is non-empty.This tuple is the reading order, and
actionbar.COLUMNSbreaks it into rows – so the three spell-and-healing actions fill the first row and the two that stand alone fill the second. Donald chose the grouping; moving an entry here moves the button.gameis which title they act on, and it reaches every address each one writes. None is Pool of Radiance, which is what every caller written before there was a second title meant.- Parameters:
store (automap.actions.SpellStore | None)
game (goldbox.games.Game | None)
- Return type:
- automap.actions.area_by_id(id, title=ANY_TITLE)[source]¶
One row of a title’s area table, or None.
The default is “whatever table this build has”, which for every caller written before there was a second title meant Pool of Radiance’s.
- Parameters:
id (int)
- automap.actions.area_rows(title=ANY_TITLE)[source]¶
This title’s area table, or nothing if there is none.
Two gates, and a row has to pass both.
goldbox.areas.areas_for_titleasks whether anybody has written the table down – a row’s disk number andECLid mean a different place in every title, and#14is what happens when Pool of Radiance’s are offered in a Curse session.automap.fasttravel.supportedasks the other half: whether the writes for that title have been read, because a table with no addresses behind it would put the right area id at the wrong byte.They are separate on purpose. Silver Blades has had the first since
#20 (Build an area table for Silver Blades)and got the second here, so a table can exist for a title a fast travel still cannot reach – andgoldbox.areas.areas_foris the accessor for everything that only reads.goldbox/areas.pyis imported here rather than at the top of the module so that a checkout without it still has the other five actions.- Return type:
- automap.actions.game_title(game=None)[source]¶
What to call a title in a refusal. Takes a
Game, a key, or None.- Return type:
- automap.actions.in_combat(target, game=None)[source]¶
True only when the mode flag says combat. An unreadable machine is not combat – it is unreadable, and every action refuses on that separately.
- Parameters:
game (goldbox.games.Game | None)
- Return type:
- automap.actions.jump(target, address)[source]¶
Set the PC and let the machine run. False if this backend cannot.
The last step of a fasttravel and the only irreversible one: everything before it is bytes in RAM, and this is what makes the game act on them.
- automap.actions.landing_square(geo)[source]¶
Where to put a party arriving in an area whose square nobody harvested.
goldbox.areas.landing_squaredoes the work; this is the seam, imported the same guarded way as the table for the same reason. It replaces a rule that took the first square with any passable edge, which came to(0, 0)on all twenty-nine maps and left a party walled into a pocket on four of them – P20 (write-up lost,work/reports/p20-arrivals.md; the pocket sizes are asserted intests/test_p20.py’sPOCKETS).Carrying the party’s current square over remains the one option to avoid: the maps do not line up, and (13,13) in the Slums is a wall in Sokol Keep.
- automap.actions.level_up_blockers(record=None, game=None)[source]¶
Every reason levelling refuses, most specific first.
Empty means every field the trainer touches is both derivable and CONFIRMED. It got there by measurement, not by lowering a bar: the five entries this used to carry were closed by reading the trainer’s own routines out of
GENand replaying twenty-nine measured trainings throughgoldbox/levelup.py– seedocs/135-levelling.md.It takes a record because the remaining refusals are per character: a class at its ceiling, a race at its limit, or not enough experience.
And it takes a title, because the measurement was of one title. Curse is the case that would corrupt quietly: its level tables are in
goldbox/levels.py, so selecting them looks like enough, and it is not. Every derivation around them was read at Pool of Radiance’s addresses out of Pool of Radiance’sGEN–levels.TRAINER_MEASUREDnames them – so a title nobody has measured is refused whatever tables it has.- Parameters:
record (goldbox.record.CharacterRecord | None)
- Return type:
- automap.actions.mode(target, game=None)[source]¶
Which overlay is running, or None if that cannot be established.
Two ways it comes back None and the caller has to separate them itself: the machine could not be read, and this title has no mode flag.
Game.mode_flagisLINKER’s dispatch byte, read out of the loader on Pool of Radiance, Curse and Silver Blades and on no other title, so a title with None here has no gate at all – seeAction.legality, which refuses rather than reading somebody else’s address and calling whatever it finds “not combat”.- Parameters:
game (goldbox.games.Game | None)
- Return type:
int | None
- automap.actions.newecl_writes(from_area, to_area, disk=None, arrival=None, overland=None, addresses=None)[source]¶
The bytes a fast travel writes:
NEWECL’s own, in its own order and minus the operand fetch, behind the one write a departing script would have made first.The whole write sequence lives here, in one function, because it is a guess in the sense that matters: the individual writes are read off
DUNGEON $2011-$2032, and that they can be made from outside while the game sits in its key-wait loop is not established. Correcting the sequence should mean editing this function and nothing else.addressesis the title’s row fromautomap/fasttravel.py, and defaults to Pool of Radiance’s, which is what every caller written before there was a second title meant. Three of its fields change what gets written rather than only where:`walls_slot` is None in every title but Pool of Radiance, which is the only one with a
WALLSfile at all (Curse and Silver Blades carryWALLDEF/WALLSETtriples and noWALLS00, read off their own disk directories), so the#156write is left out rather than aimed at a cache slot whose contents there nobody has read.`travel_square` is None in every title but Pool of Radiance, the one title with a square-engine overland, so
overlandcannot be honoured elsewhere and asking for it is a caller bug.`zeroed` carries Silver Blades’ sixth write. Its handler is
LDX #$1F / LDA #$00 / STA $4BFB / STA $4C00,X / DEX / BPL, and the back edge is theSTA $4C00,X– so$4BFBis written once, in front of the wipe, and that order is kept here.
arrivalis(x, y, facing), or(x, y)where the departing script sets the square but not the direction, or None to write no square at all and let the arriving script’s entry 4 place the party.overlandis(x, y)for the three overland areas’ own position,$49C3/$49C4, written in the same slotarrivaloccupies –$C04Bis notGDRIVE00’s square outdoors, and no arriving script places one there, so this is the only write that ever puts a fast-travelled party on a known overland square (#178 (Fast Travel to the wilderness leaves the party on whatever overland square it last stood on)).arrivalandoverlandare mutually exclusive – an area is one or the other, never both – and passing both is a caller bug, not a choice between writes.Two writes here are not `NEWECL`’s own.
FASTTRAVEL_WALLS_SLOTis New Phlan’s departing script,ECL00 $9955/$9BDC, run in front ofNEWECLon a genuine exit and skipped by entering the handler at its tail (#156). Written unconditionally and first, before the writes that areNEWECL’s own: every area but New Phlan leaves slot 9 alone on arrival, so setting it empty costs nothing but a reload ofWALLS00in the one area that wants it, and it is the byte the whole bug turns on.WALL_SLOT_PINNEDis the same shape: three departing scripts –ECL06,ECL07,ECL0A– clear it only on the way out, which a fast travel skips, so a piece can keep the previous area’s wall art (#179). Written unconditionally and zero.
- automap.actions.pc_register(mon, default=PC_REGISTER)[source]¶
Which register id this VICE calls
PC, asked rather than assumed.One round trip, and only ever one: the answer cannot change under a running emulator, so it is cached on the monitor object. A build that does not serve
0x83– which is what this code believed for months, wrongly – falls back toPC_REGISTER.
- automap.actions.place_name(geo)[source]¶
What to call the map now resident, or None if we cannot say.
goldbox.areas.geo_name, imported the same guarded way as the table and for the same reason. The name is the one the status line under the map already shows –AutomapState.area_labelcalls the same function – so the two cannot disagree about where the party is.
- automap.actions.program_counter(target)[source]¶
The CPU’s PC, or None where this backend cannot say.
The
Targetcontract isreadandwriteand deliberately nothing else, so the CPU is reached the two ways there are: a target that offerspc()of its own (a test’s, and whatever a second backend grows), or the VICE monitor aViceTargetis holding.The value is still sanity-checked before anything is written to it: a 6502’s other registers are eight bits wide, so an id that is not the PC cannot hold an address in
DUNGEON.
- automap.actions.quickfight_flag(game=None)[source]¶
This title’s flag, or None while the bit itself is unfound.
The offset is roster
+0x0Cin every title, because the roster block is the same block; what moves is where the roster page lives, andGame.roster_baseis that –$8300in Pool of Radiance’s second file,$6700inside the payload in Curse and Silver Blades (#29).QUICKFIGHTbeing None is the separate case, and it stays the one that means “nobody has found this bit at all”:ClearQuickfightthen refuses withWANTEDrather than writing an offset nothing established.- Parameters:
game (goldbox.games.Game | None)
- Return type:
- automap.actions.read_party(target, game=None)[source]¶
The party, or None when these bytes are not one.
Same blocks as
live.read_snapshot, read through the samelive.read_blocks, and the same validate-before-trust rule: at the title screen, mid-load or in a menu the bytes simply are not there, and that is ordinary rather than an error.live.roster_page_plausibleguards a fourth case, where the position and the record slots are both fine and only the roster page has something else’s bytes on it (#82).gamesays where to read and how to decode. One read for a title that keeps its roster in the payload’s last page, two for Pool of Radiance, which keeps it in a second file –live.read_blockshides which, so this always sees the pair.- Parameters:
game (goldbox.games.Game | None)
- Return type:
automap.actions.Party | None