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. IdentifyItems writes 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 $6C00 the 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

UNSUPPORTED

Donald's wording, approved 2026-08-24.

LEVEL_UP_FIELDS

Every record field a level-up writes.

QUICKFIGHT

roster block +0x0C, bit 7 -- see "The quickfight bit is roster +0x0C" in docs/50-experiments.md.

POOL_ADDRESSES

Pool of Radiance's row, which is where the constants below come from.

FASTTRAVEL_DISK

Which POOL disk the arriving area lives on.

FASTTRAVEL_X

The live party square inside GDRIVE00, of which $49C0-$49C2 is a lagging copy: x, y, facing.

FASTTRAVEL_TRAVEL_X

The live travel-grid square, window-local x then y.

FASTTRAVEL_FROM

$2011-$2016 sets it, and the arriving script's entry 4 compares it against its own id.

FASTTRAVEL_SLOT

The ECL slot of the loaded-files cache.

FASTTRAVEL_WALLS_SLOT

Slot 9 of the loaded-files cache -- the resident WALLS file, which loads at $ED50 under the KERNAL.

WALL_SLOT_PINNED

$49E7-$49E9, one flag per wall piece -- goldbox/memory.py calls it "wall slot pinned".

FASTTRAVEL_SCRATCH

the origin of the scratch/persistent split.

FASTTRAVEL_INDOORS

it decides whether LOADFILES asks for a GEO or a SQRDATA.

NEWECL_TAIL

The tail of NEWECL's handler, past the operand fetch.

DUNGEON

DUNGEON is the resident overlay.

KEY_WAIT

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.

KEY_FETCH

LDA $DC00 for the CIA row, then the KERNAL buffer, then RTS.

ANY_TITLE

No title given means "whatever table this build has", which is what every caller written before there was a second title meant.

PC_REGISTER

CMD_REGISTERS_AVAILABLE is served by this build and names id 3 PC, 16 bits wide, beside A, X, Y, SP and FL at 0, 1, 2, 4 and 5.

CMD_REGISTERS_AVAILABLE

CMD_REGISTERS_AVAILABLE.

Functions

actions([store, game])

Every action, in the order the bar lays them out.

area_by_id(id[, title])

One row of a title's area table, or None.

area_rows([title])

This title's area table, or nothing if there is none.

game_title([game])

What to call a title in a refusal.

in_combat(target[, game])

True only when the mode flag says combat.

jump(target, address)

Set the PC and let the machine run.

landing_square(geo)

Where to put a party arriving in an area whose square nobody harvested.

level_up_blockers([record, game])

Every reason levelling refuses, most specific first.

mode(target[, game])

Which overlay is running, or None if that cannot be established.

newecl_writes(from_area, to_area[, disk, ...])

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.

pc_register(mon[, default])

Which register id this VICE calls PC, asked rather than assumed.

place_name(geo)

What to call the map now resident, or None if we cannot say.

program_counter(target)

The CPU's PC, or None where this backend cannot say.

quickfight_flag([game])

This title's flag, or None while the bit itself is unfound.

read_party(target[, game])

The party, or None when these bytes are not one.

Classes

Action

One button's worth of behaviour.

ClearQuickfight

Clear the bit the combat menu's QUICK sets, for everyone.

FastTravel

Put the party in another area, the way the game's own exits do.

HealParty

Current hit points to maximum, for everyone standing.

IdentifyItems

Clear the hidden-name bits on every item the party carries.

LevelUp

Raise a character a level, writing what the training hall writes.

Member

One occupied party slot: the record, the roster block, and addresses.

Outcome

What an action did, or why it did nothing.

Party

Everyone in the roster, from the same two reads the live view makes.

QuickfightFlag

Where the per-character quickfight bit lives.

QuickfightWatcher

Clear the flag on the tick that combat ends, if the caller wants that.

RestoreSpells

Write the stored memorised list back, so nobody has to rest for it.

SpellStore

Memorised spell lists, kept in a file so they outlive the window.

StoreSpells

Remember what everyone has memorised, so it can be put back later.

Verdict

Whether an action may run now, and the reason when it may not.

Waypoint

Where the party was before a fasttravel, so that FastTravel Back has an answer.

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: object

One button’s worth of behaviour.

Subclasses set name, label and combat_legal, and implement run. apply is 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 Game geometry now – the slot area, the item area and the roster page all follow save_load_address – so the title is the whole of what a subclass has to be told (#29).

Parameters:

game (goldbox.games.Game | None)

apply(target, **kwargs)[source]
Return type:

automap.actions.Outcome

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 LevelUp accepts a key or a LevelTables as well – goldbox/levels.py is duck-typed on .key on purpose – and the addresses need a Game.

label = ''

Button text.

legality(target)[source]
Return type:

automap.actions.Verdict

name = ''

Stable identifier, for settings and for wiring.

run(target, **kwargs)[source]
Return type:

automap.actions.Outcome

automap.actions.CMD_REGISTERS_AVAILABLE = 131

CMD_REGISTERS_AVAILABLE. Not in automap/vice.py’s command table because nothing else needs it.

class automap.actions.ClearQuickfight[source]

Bases: automap.actions.Action

Clear 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.md for 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 Game geometry now – the slot area, the item area and the roster page all follow save_load_address – so the title is the whole of what a subclass has to be told (#29).

Parameters:

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.

legality(target)[source]
Return type:

automap.actions.Verdict

name = 'clear-quickfight'

Stable identifier, for settings and for wiring.

run(target, **kwargs)[source]
Return type:

automap.actions.Outcome

automap.actions.DUNGEON = 1

DUNGEON is the resident overlay. $2034 is some other overlay’s code when it is not.

Type:

$6E11

automap.actions.FASTTRAVEL_DISK = 28178

Which POOL disk the arriving area lives on. LIBRARY $43A4 reads it and prompts if that disk is not in the drive.

automap.actions.FASTTRAVEL_FROM = 18930

$2011-$2016 sets 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: $19E1LDX #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’s COMPARE [$49F2], 23 / IF= / EXIT fired, 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 LOADFILES asks for a GEO or a SQRDATA. $4BE6 in Curse and Silver Blades, read out of NEWECL’s own tail call rather than relocated by hand – and in a running Curse this address is LIBRARY code (#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 ECL slot 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- $C04D is not the party’s position – GDRIVE00 is 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.md unknown 3), and is not written here.

automap.actions.FASTTRAVEL_WALLS_SLOT = 28188

Slot 9 of the loaded-files cache – the resident WALLS file, which loads at $ED50 under the KERNAL. #156: every area but New Phlan uses a WALLDEF triple instead: LOADPIECES (DUNGEON $276E) marks the three slots dirty, $145C reloads each into the $8C00 staging buffer, and $1485 unpacks them on to $ED50, $F05C and $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 – but FastTravel enters NEWECL at 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 saying WALLS00 and skips the reload (LIBRARY $4225: masked A equal to the slot means no load). $FF is the value: it is the empty marker docs/140-loaded-files-cache.md names for this cache, it is what every engine-written save in a WALLDEF area carries in this slot, and it is the one value LIBRARY $4225 leaves alone – so the next LOADFILES 0, 0, 0 in New Phlan reloads WALLS00 rather than declining because the slot already says 00.

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 WALLS00 on Pool of Radiance’s alone; the other two carry WALLDEF/WALLSET pairs and nothing else. So FastTravelAddresses.walls_slot is None there and newecl_writes leaves the slot out rather than aiming $FF at 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-$49C2 is 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.Action

Put the party in another area, the way the game’s own exits do.

“Fast Travel” is what the user calls it. FastTravel is 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 $2034 from 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. NEWECL is 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 are automap/fasttravel.py, one row per title, and self.addresses is None for the three nobody has read.

Two guards that are not optional, both re-checked at apply time:

  • `$6E11` must be 1. $2034 is 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 $203A throws away work in flight. This doubles as the check that PC_REGISTER is 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 HELP is 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"

ECL1E is the attract-mode demo and fasttraveling into it ends the session: P20 read $C04B-$C04D as 254, 127, 16 with no GEO resident, 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. FastTravelBar does 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 outdoors row – and it is here because newecl_writes would 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 $49E6 afterwards all fail. The other direction is fine – area 23 to 26 worked, and the arriving script sets $49E6 itself. See docs/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 calling GDRIVE00 $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.py has a row for each of the three titles whose overlays have been read; self.addresses is 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 – #14 fixed 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 NEWECL addresses, or None if nobody has read it.

apply(target, area=None, arrival=None, **kwargs)[source]
Return type:

automap.actions.Outcome

apply_back(target)[source]

FastTravel to where the last fasttravel started, on the square it started on.

Return type:

automap.actions.Outcome

static arrival_of(area)[source]

The area’s own arrival square as (x, y[, facing]), or None.

back: automap.actions.Waypoint | None

Where the last fasttravel came from. FastTravel Back reads it; None until a fasttravel has been made, which is why the button starts disabled.

back_verdict(target)[source]
Return type:

automap.actions.Verdict

False means “refuse while the mode flag is 2”.

static current_area(target, addresses=None)[source]

The id of the area running now: $6E1B without the reload bit.

Return type:

int | None

static current_disk(target, addresses=None)[source]
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>,X at DUNGEON $1A3C, $1BE7 and $1AF9. In a running Curse $49E6 is LIBRARY code (#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.

Return type:

tuple[int, int] | None

static current_square(target, addresses=None)[source]
Return type:

tuple[int, int, int] | None

description = 'Teleport to another area.'

One line, for a tooltip.

label = 'Fast Travel'

Button text.

legality(target, area=None)[source]
Return type:

automap.actions.Verdict

name = 'fasttravel'

Stable identifier, for settings and for wiring.

run(target, area=None, arrival=None, **kwargs)[source]
Return type:

automap.actions.Outcome

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 $4BF2 and $4BE6 rather than quoting Pool of Radiance’s numbers at a machine that keeps something else there.

Return type:

tuple[str, …]

class automap.actions.HealParty[source]

Bases: automap.actions.Action

Current 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 – record 0x119 is 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. See docs/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.

run(target, **kwargs)[source]
Return type:

automap.actions.Outcome

class automap.actions.IdentifyItems[source]

Bases: automap.actions.Action

Clear the hidden-name bits on every item the party carries.

The low three bits of an item’s byte +6 hide 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. +6 bit 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.

run(target, **kwargs)[source]
Return type:

automap.actions.Outcome

automap.actions.KEY_FETCH = (11854, 11883)

LDA $DC00 for the CIA row, then the KERNAL buffer, then RTS. 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 $2E4E before 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-$2E6A inclusive

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 $10D6 in the loop and nothing above it, and the code agrees: $10E0 is the JMP $10C2 that closes it, $10E3-$10EB is its own exit tail, and $10EC starts 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.py before 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.Action

Raise a character a level, writing what the training hall writes.

The trainer is the specification and this is a copy of it. GEN $1B8C is the sequence a level-up runs; every routine it calls has been read and goldbox/levelup.py names each one beside the field it fills. Replaying the twenty-nine trainings measured in docs/119-test-party.md through 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_rolled at 0x0ED takes a fresh roll of the class hit die at every training and derives from nothing; hp_max derives 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 0x0BB is 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_max has 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 refusal HealParty makes.

A magic-user has to choose. GEN $215A puts 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 – so spell is 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, and class_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 explicit class_name still overrides.

Which title, though, is a question, and it is asked. game is the goldbox.games.Game the 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 by level_up_blockers before a byte is written – see goldbox.levels.TRAINER_MEASURED.

__init__(game=None)[source]

Which title this acts on. None is Pool of Radiance.

Every address an action writes is Game geometry now – the slot area, the item area and the roster page all follow save_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

game as a Game, for the addresses run writes to.

An unknown key falls back to Pool of Radiance’s geometry, which costs nothing: level_up_blockers has 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.

Return type:

list[int]

static preview(record, class_name='', spell=None, game=None)[source]

The plan without writing it, or None if it cannot be made.

Only experience_lost and classes_disqualified are worth reading off it: the hit die is rolled again by run, so every number that depends on the roll differs.

Parameters:
  • class_name (str)

  • spell (int | None)

Return type:

goldbox.levelup.Plan | None

run(target, slot=0, class_name='', spell=None, **kwargs)[source]
Parameters:
  • slot (int)

  • class_name (str)

  • spell (int | None)

Return type:

automap.actions.Outcome

class automap.actions.Member[source]

Bases: object

One 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 0x0FF reads as zero and must not be written – live, the slots are $100 apart and offset 0x119 of slot 0 is offset 0x019 of slot 1.

Every address below comes from `game` and none of them is a constant: the three bases were Pool of Radiance’s $4D00, $5900 and $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 in goldbox/games.py and 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:
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.

Parameters:

name (str)

Return type:

int

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)
property hp: int
property hp_max: int
property item_base: int
property name: str
record: goldbox.record.CharacterRecord
property record_base: int
roster: bytes
property roster_base: int
slot: int
automap.actions.NEWECL_TAIL = 8244

The tail of NEWECL’s handler, past the operand fetch. $203A reloads the stack pointer from $03BF, so the call depth we interrupt does not matter.

class automap.actions.Outcome[source]

Bases: object

What an action did, or why it did nothing.

writes is every (address, bytes) that went to the machine, which is what makes these testable without one: assert on the addresses, not on a screenshot. notes carries 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=())
Parameters:
Return type:

None

property changed: int
message: str
notes: tuple[str, ...] = ()
ok: bool
writes: tuple[tuple[int, bytes], ...] = ()
automap.actions.PC_REGISTER = 3

CMD_REGISTERS_AVAILABLE is served by this build and names id 3 PC, 16 bits wide, beside A, X, Y, SP and FL at 0, 1, 2, 4 and 5. pc_register asks 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: object

Everyone 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:
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, ...]
save0_bytes: bytes = b''
automap.actions.QUICKFIGHT = QuickfightFlag(base=33548, stride=32, mask=128)

roster block +0x0C, bit 7 – see “The quickfight bit is roster +0x0C” in docs/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 are live.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: object

Where the per-character quickfight bit lives.

base is the address of character 0’s byte, stride the distance to the next character’s, and mask the bit.

__init__(base, stride, mask)
Parameters:
Return type:

None

address(slot)[source]
Parameters:

slot (int)

Return type:

int

base: int
mask: int
stride: int
class automap.actions.QuickfightWatcher[source]

Bases: object

Clear 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:
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: mode answers 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

was: int | None
class automap.actions.RestoreSpells[source]

Bases: automap.actions.Action

Write 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 0x020 in Pool of Radiance – in the slot area at Game.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 0x0EE says 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 Game geometry now – the slot area, the item area and the roster page all follow save_load_address – so the title is the whole of what a subclass has to be told (#29).

Parameters:
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.

run(target, disk='', **kwargs)[source]
Parameters:

disk (str)

Return type:

automap.actions.Outcome

class automap.actions.SpellStore[source]

Bases: object

Memorised 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.json is: 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.

__init__(path=None)[source]
get(disk, name)[source]
Parameters:
Return type:

bytes | None

names(disk)[source]
Parameters:

disk (str)

Return type:

tuple[str, …]

put(disk, name, raw)[source]
Parameters:
Return type:

None

stored_at(disk, name)[source]
Parameters:
Return type:

str

class automap.actions.StoreSpells[source]

Bases: automap.actions.Action

Remember 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 Game geometry now – the slot area, the item area and the roster page all follow save_load_address – so the title is the whole of what a subclass has to be told (#29).

Parameters:
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.

run(target, disk='', **kwargs)[source]
Parameters:

disk (str)

Return type:

automap.actions.Outcome

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: object

Whether an action may run now, and the reason when it may not.

reason is 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.

__init__(ok, reason='')
Parameters:
Return type:

None

ok: bool
reason: str = ''
automap.actions.WALL_SLOT_PINNED = 18919

$49E7-$49E9, one flag per wall piece – goldbox/memory.py calls it “wall slot pinned”. DUNGEON $14CB reads $49E7,X before unpacking piece X and returns at once when it is non-zero, so the piece keeps whatever screen codes the previous area’s wall set left in it. Only ECL06 (Valjevo Castle south-west), ECL07 (the Inner Tower) and ECL0A (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 as FASTTRAVEL_WALLS_SLOT above: it costs nothing in the areas that never set it, and one extra relocation pass in ECL06’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 $4BE7 in Curse and Silver Blades – save_load_address plus $0200, like the other two save-relative writes – and each title’s DUNGEON holds exactly one reference to it, LDA $4BE7,X / BNE in 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: object

Where the party was before a fasttravel, so that FastTravel Back has an answer.

__init__(area, disk, square, overland=None)
Parameters:
Return type:

None

area: int
disk: int | None
property id: int

Spelled the way a row of the area table spells it, so that the same legality serves both buttons even when the table has no such row.

overland: tuple[int, int] | None = None

$49C3/$49C4 at departure, taken when the party was outdoors ($49E6 read 0). square is $C04B, which is not GDRIVE00’s square outdoors, so FastTravel Back from a window needs this instead (#178 (Fast Travel to the wilderness leaves the party on whatever overland square it last stood on)).

square: tuple[int, int, int] | None
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, label on it, description and the reason from legality in its tooltip, and confirm asked first where it is non-empty.

This tuple is the reading order, and actionbar.COLUMNS breaks 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.

game is 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:
Return type:

tuple[automap.actions.Action, …]

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_title asks whether anybody has written the table down – a row’s disk number and ECL id mean a different place in every title, and #14 is what happens when Pool of Radiance’s are offered in a Curse session. automap.fasttravel.supported asks 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 – and goldbox.areas.areas_for is the accessor for everything that only reads.

goldbox/areas.py is imported here rather than at the top of the module so that a checkout without it still has the other five actions.

Return type:

tuple

automap.actions.game_title(game=None)[source]

What to call a title in a refusal. Takes a Game, a key, or None.

Return type:

str

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:

bool

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.

Parameters:

address (int)

Return type:

bool

automap.actions.landing_square(geo)[source]

Where to put a party arriving in an area whose square nobody harvested.

goldbox.areas.landing_square does 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 in tests/test_p20.py’s POCKETS).

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.

Return type:

tuple[int, int, int] | None

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 GEN and replaying twenty-nine measured trainings through goldbox/levelup.py – see docs/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’s GENlevels.TRAINER_MEASURED names them – so a title nobody has measured is refused whatever tables it has.

Parameters:

record (goldbox.record.CharacterRecord | None)

Return type:

tuple[str, …]

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_flag is LINKER’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 – see Action.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.

addresses is the title’s row from automap/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 WALLS file at all (Curse and Silver Blades carry WALLDEF/WALLSET triples and no WALLS00, read off their own disk directories), so the #156 write 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 overland cannot 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 the STA $4C00,X – so $4BFB is written once, in front of the wipe, and that order is kept here.

arrival is (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.

overland is (x, y) for the three overland areas’ own position, $49C3/$49C4, written in the same slot arrival occupies – $C04B is not GDRIVE00’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)). arrival and overland are 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_SLOT is New Phlan’s departing script, ECL00 $9955/$9BDC, run in front of NEWECL on a genuine exit and skipped by entering the handler at its tail (#156). Written unconditionally and first, before the writes that are NEWECL’s own: every area but New Phlan leaves slot 9 alone on arrival, so setting it empty costs nothing but a reload of WALLS00 in the one area that wants it, and it is the byte the whole bug turns on.

WALL_SLOT_PINNED is 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.

Parameters:
Return type:

tuple[tuple[int, bytes], …]

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 to PC_REGISTER.

Parameters:

default (int)

Return type:

int

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_label calls the same function – so the two cannot disagree about where the party is.

Parameters:

geo (str)

Return type:

str | None

automap.actions.program_counter(target)[source]

The CPU’s PC, or None where this backend cannot say.

The Target contract is read and write and deliberately nothing else, so the CPU is reached the two ways there are: a target that offers pc() of its own (a test’s, and whatever a second backend grows), or the VICE monitor a ViceTarget is 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 +0x0C in every title, because the roster block is the same block; what moves is where the roster page lives, and Game.roster_base is that – $8300 in Pool of Radiance’s second file, $6700 inside the payload in Curse and Silver Blades (#29).

QUICKFIGHT being None is the separate case, and it stays the one that means “nobody has found this bit at all”: ClearQuickfight then refuses with WANTED rather than writing an offset nothing established.

Parameters:

game (goldbox.games.Game | None)

Return type:

automap.actions.QuickfightFlag | None

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 same live.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_plausible guards 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).

game says 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_blocks hides which, so this always sees the pair.

Parameters:

game (goldbox.games.Game | None)

Return type:

automap.actions.Party | None