automap.window

The PyQt6 map window.

Deliberately thin. All the geometry is in render.py and all the knowledge is in state.py; this paints primitives and forwards key presses. Keeping it that way is what lets the map be developed and tested without a display – see to_svg, which draws exactly the same primitives.

Module Attributes

COMBATANT_FILL

How a combatant's square is filled, by combat.Combatant.kind with any -dim taken off.

HP_INK

And what the hit points inside it are written in.

NO_DISKS

Said in the Messages panel when there are no game disks configured at all, so there is nothing for the grid to draw -- the one failure that used to go to stderr and nowhere else, then to nowhere at all once the paint that once put it on the grid was removed without a word (#214 (The automapper's empty grid never says there are no game disks, though the code and a test believe it does)).

OUTDOORS_STATUS

Said in the status line while the party is on the travel grid, where none of the loaded maps reach -- #205 (A party that walks out onto the travel grid leaves the automapper's marker behind).

WRONG_GAME

Said in the Messages panel when the map the machine is drawing is a Gold Box map and none of the configured title's -- so the disks the window is set up for are not the game that is running.

Functions

game_named(title)

The Game this title is, for the readers that need one.

Classes

AutomapBinding

The map, which opens whether or not there is a game to watch.

CombatCanvas

Paints the fight, in the area map's own language.

MapCanvas

Paints the current map.

class automap.window.AutomapBinding[source]

Bases: PyQt6.QtCore.QObject

The map, which opens whether or not there is a game to watch.

Three ordinary states, none of them an error: no emulator yet, an emulator with no save loaded, and a live party. The window sits in the first two showing an empty grid and saying what it is waiting for, and moves between them on its own. Quitting VICE and restarting it is handled the same way – the connection is simply re-established on the next tick.

COMBAT_LOG = True

Keep the combat messages the game paints over. One extra burst per tick while a fight is running and nothing at all outside one.

COMBAT_LOG_EVERY = 1

How many combat ticks per read of the message panel. One is a starting point, not an answer: a message lives about a second of emulated time (COMBAT $28C3, delay $49FC, default 2), so there is room to poll less often if the extra ~14.3 ms a tick turns out to stutter the fight. Left here rather than in Settings so it can be raised without a release; it belongs in the settings file once the measurement says what it costs.

LIVE_EVERY = 5

How many map ticks per read of the live party. See poll_live.

SIDE_SQUEEZED = 160

And the narrowest. A squeezed window still shows enough of a note or a commission to say which one it is; without a floor here the column was 270px of fixed width whatever the screen was (#41).

SIDE_WIDTH = 460

The width the notes, quest log and messages column opens at. It was a cap until #162 made the divider draggable, and a cap is the one thing a draggable column cannot have; the number itself did not move. Defined once, in panel.ColumnSplitter, which is what divides the tab.

__init__(root, mapper, interval_ms=200, connect=None, settings=None, drive=True, disks=None)[source]
Parameters:
ask(question)[source]

A yes/no the player has to answer. A method so a test can answer it, the same way ActionBar.ask is.

Parameters:

question (str)

Return type:

bool

bus_guard

One byte of $DD00 before each tick, for a target whose reads stop the processor (halts_on_read): the tick is skipped while the drive is mid-transfer, because a stop inside a load hangs the C64 Ultimate – #375 (Wish has to work around the Ultimate freezing the C64 mid-load, which hangs the game while the automapper follows along). It reduces the rate and cannot end it; see automap/busguard.py.

closeEvent(event)[source]
columns

The two dividers down the tab, and the widths the user drags them to. Built before the panels so that a column restored shut is shut on the first frame rather than after one at the default width.

delete_note(x, y, index)[source]
Parameters:
Return type:

None

edit_note(x, y, index=None)[source]

Open the popover on a square. index edits an existing note.

A popover and not a dialog: notes are made while playing, and a modal box in front of the map is an interruption for something that should cost one keystroke.

Parameters:
Return type:

None

log_combat(messages, battle=None)[source]

Combat lines into the Messages panel, each with its dice under it.

Passes `dedup=False`, and that is the whole point of the feature: MessagesPanel.say drops a line identical to the one before it, which is right for “waiting for the game” on every tick and wrong for two “MAGNUS MISSES.” in a row – and just as wrong for the identical roll line under each of them. The log has already deduplicated, on consecutive identical frames, which is the only rule that can tell the two apart.

battle is who was in the fight; it is passed explicitly because the last flush of a fight happens after self.battle has already gone to None, and without it the last message of every fight would lose its dice.

This is also where the shouting stops. The game prints in capitals because the C64’s character set is capitals; combatlog.recase turns the line into ordinary prose with the combatants’ names capitalised, and the combatant list is what says which words those are. It runs here rather than in the log because Message.text is the evidence of what the game actually printed. The tooltip keeps the rows verbatim for the same reason.

Return type:

None

no_maps

No maps at all is its own state, and not the same as no emulator: an emulator will not fill the grid either. Said in the Messages panel, alongside Waiting to connect... and the rest – the grid itself carries nothing (Donald’s ruling, 2026-09-04, on #214 (The automapper's empty grid never says there are no game disks, though the code and a test believe it does)).

note_here()[source]

N: a note on the party’s own square, if we know where that is.

Return type:

None

note_menu(x, y, at)[source]
Parameters:
  • x (int)

  • y (int)

  • at (PyQt6.QtCore.QPoint)

Return type:

None

note_menu_entries(x, y)[source]

What a right-click on a square with notes offers, as data.

Data rather than a QMenu so the offer can be tested without a display – note_menu is four lines on top of this.

No “add another”. A square holds one note. It still lists whatever is on the square rather than assuming one, because a file written by a build that allowed several has to stay editable and deletable.

Parameters:
notes_changed(x=-1, y=-1)[source]

Persist and redraw. Every edit goes through here.

Parameters:
Return type:

None

other_maps: Callable[[], dict[str, dict]] | None

Set by the host to a zero-argument callable returning every other configured title’s own maps, {title: {area: Geo}}None (the default) means “no correction available”, which is what a hosted binding with no window behind it gets, and _check_the_game then behaves exactly as it did before step 4 of #357 (The automapper reads the shared Game disks folder, so setting a title's own folder does not make it map that title).

point_at(x, y)[source]

Flash a square, because a row in the notes list was clicked.

Parameters:
Return type:

None

poll_battle()[source]

Swap to the combat canvas while a fight is running. True if one is.

Gated on `$6E11`, never on the screen. Checked once a second while the party is in the world – one more round trip on the tick that reads the party anyway – and on every tick once a fight has started, because that is when the map is worth looking at. The area map is not polled at all during a fight: the party is not moving through the world, and its explored squares sit untouched until the fight ends.

Return type:

bool

poll_combat_log()[source]

Read the message panel and keep whatever it finished saying.

Return type:

None

poll_live()[source]

Refresh the roster and the strip, every LIVE_EVERY ticks.

Not every tick, and the reason is measurable: a poll’s cost is the round trip, and under VICE each one hands the emulation ~14.3 ms of extra emulated time. The map’s own fix is one trip; the party is one more (two reads inside a single resume, see ViceTarget.read_blocks). Doing it every fifth tick is once a second at the default interval, which is faster than hit points change and a fifth of the disturbance.

Only the visible tab polls at all – the host hands the target to whichever tab is showing – so a hidden map costs nothing.

Return type:

None

set_maps(maps, title=None, disks=None)[source]

New game disks: draw their maps instead, without a restart.

These maps are also the signature the running game is checked against (#21), so changing them takes the window’s verdict on the title back to “no idea” – see Automapper.use_maps.

Parameters:
  • maps (dict)

  • title (str | None)

  • disks (str | None)

Return type:

None

show_strength(save0_bytes, roster_bytes)[source]

Recompute party strength and show it under the strip.

Live data only, and deliberately: the number is what the running game would compute, so a save file on disk would be the wrong answer the moment anybody readied anything. Same two blocks as the poll.

The slums count comes with it because it is the one scaled encounter watched end to end – (strength / 3) * 2, ECL14 $B1B0 – and a bare 13 says nothing about what it costs. See docs/114-party-strength.md.

Parameters:
Return type:

None

shutdown()[source]

Save what must survive. Idempotent, and safe with no connection.

Split out of closeEvent because a hosted window is never closed on its own – the host closes, and the notes still have to be written. The connection is only ours to close when we opened it.

Return type:

None

statusChanged

alias of str

status_text()[source]

The line this window would put in a status bar, right now.

Return type:

str

tick()[source]

Read one fix and redraw if anything moved.

Hosted (drive=False), trouble is raised rather than absorbed: the host owns the connection and is the only thing that can reattach.

Return type:

None

titleObserved

Emitted with a title’s name when _check_the_game finds the machine running a different configured title than the one this window is set up for – #357 (The automapper reads the shared Game disks folder, so setting a title's own folder does not make it map that title) step 4. The host, not this class, owns switching to that title’s own folder: it is the one holding Settings.game_folders.

alias of str

waiting(text, alarm=False)[source]

Say what is being waited for. The host sets this; alone, we do.

alarm colours it red, for the one case that is not ordinary waiting: something else is holding the emulator’s monitor, so the game is running and we cannot read it. Not a dialog – it clears on its own when the other client lets go.

Parameters:
Return type:

None

automap.window.COMBATANT_FILL = {'enemy': PyQt6.QtGui.QColor, 'helpless': PyQt6.QtGui.QColor, 'party': PyQt6.QtGui.QColor}

How a combatant’s square is filled, by combat.Combatant.kind with any -dim taken off. Unknown kinds fall back to the enemy red, which is what the old startswith("party") test did for everything that was not the party.

class automap.window.CombatCanvas[source]

Bases: PyQt6.QtWidgets.QWidget

Paints the fight, in the area map’s own language.

Same graph paper, same ink, same line art – a player should not feel they have changed program because a fight started. What is new is the colour: the party green, the enemy red and a helpless enemy gold, with current hit points written in the square, because mid-fight that is the number you look for.

The geometry is automap/combat.py, which has no Qt in it; this paints what it yields and answers the tooltip.

MIN_LABEL_PIXELS = 4

Below this pixel size nothing reads as a digit – the floor tools/combatbarsheet.py’s fit_font uses for the same reason.

__init__(parent=None, host=None)[source]
property drawn_cell: int

self.cell where there is room for it.

Derived from the widget’s size rather than remembered from a resize event, for the reason MapCanvas.cell gives.

Type:

The cell actually painted

event(e)[source]
paintEvent(_event)[source]
show_battle(battle)[source]
Return type:

None

sizeHint()[source]
tooltip_at(px, py)[source]

The record of whoever is under this point, or None.

Split out of event so the tooltip can be tested without a display.

Parameters:
Return type:

str | None

automap.window.HP_INK = {'hp-dim': PyQt6.QtGui.QColor, 'hp-ink': PyQt6.QtGui.QColor}

And what the hit points inside it are written in. Paper on the dark fills, ink on the light one, FADED on a dimmed square whatever colour it was.

class automap.window.MapCanvas[source]

Bases: PyQt6.QtWidgets.QWidget

Paints the current map.

__init__(state, parent=None, host=None)[source]
property cell: int

How big a square is drawn, for the room the widget has been given.

Derived from the size rather than remembered from a resize event, because a widget that is not on screen is not sent one until it is shown – and a click, a tooltip and a note anchor all have to agree with what was painted whether that event has arrived or not.

corner_of(x, y)[source]

The bottom-left corner of a square, for hanging the popover off.

Parameters:
Return type:

PyQt6.QtCore.QPoint

event(e)[source]
flash: tuple[int, int] | None

The square a notes-panel row asked to be pointed at, until the next click anywhere. Not a selection – nothing here is selectable.

mousePressEvent(event)[source]

Note where the press landed. Nothing opens here.

The popover used to open on the press, while the button was still down, and on Windows it closed itself the instant it appeared: the debug log showed it receiving a bare Close while still visible and still the active window, with no mouse event, no FocusOut and no WindowDeactivate before it. That is Qt dismissing a popup, and the only thing left for it to be dismissed by is the release of the very click that opened it – a popup grabs the mouse, so the release lands on a popup that was not there when the button went down.

Opening on the release instead means no button is down by the time the popup exists, and there is nothing left to straddle it.

mouseReleaseEvent(event)[source]
property origin: tuple[int, int]

Where the grid’s top-left corner sits in the widget.

The grid stays square and centred: a widget wider than it is tall spends the difference on paper, not on rectangles for squares.

paintEvent(_event)[source]
sizeHint()[source]

What it asks for: the full-size map, so nothing changes where there is room for it.

square_at(px, py)[source]
Parameters:
Return type:

tuple[int, int] | None

tooltip_at(px, py)[source]

Every note on the square under this point, one per line.

Split out of event so the tooltip can be tested without a display – the combat canvas does the same, and for the same reason.

Parameters:
Return type:

str | None

automap.window.NO_DISKS = 'No game disks found. Set the game folder in File > Preferences…'

Said in the Messages panel when there are no game disks configured at all, so there is nothing for the grid to draw – the one failure that used to go to stderr and nowhere else, then to nowhere at all once the paint that once put it on the grid was removed without a word (#214 (The automapper's empty grid never says there are no game disks, though the code and a test believe it does)). Donald’s wording, exactly.

automap.window.OUTDOORS_STATUS = 'Outdoors, no map'

Said in the status line while the party is on the travel grid, where none of the loaded maps reach – #205 (A party that walks out onto the travel grid leaves the automapper's marker behind). Donald’s ruling, 2026-09-04: the grid itself carries nothing, so this and OUTDOORS_WHERE/ OUTDOORS_AREA in automap/state.py are the whole of what tells a player the map cannot follow them. The [status]/[memory] source suffix is added the same way the ordinary status line adds it.

automap.window.WRONG_GAME = 'ERROR: Wrong game disk loaded. Disabling functionality to protect from corruption.'

Said in the Messages panel when the map the machine is drawing is a Gold Box map and none of the configured title’s – so the disks the window is set up for are not the game that is running. Donald’s wording, exactly.

It names neither game on purpose, and it could not name the second one anyway: the check that fires it validates the title we believe rather than identifying the one that is there. What was believed, and what was seen, go to the debug log – Automapper._contradicted.

automap.window.game_named(title)[source]

The Game this title is, for the readers that need one.

Parameters:

title (str | None)