automap.combatlog

Keeping the combat messages the game throws away.

The game prints who hit whom for how much into a sixteen-column panel on the right of the combat screen, holds it for a delay loop, and paints over it. Nothing saves it, and the delay is a software loop, so on an emulator running faster than a 1 MHz 6510 the line is gone sooner in wall-clock time than the player can read it. Keeping those lines is a fix, not a mirror.

No Qt in here, the same way combat.py has none: this folds screen text into messages and the window paints them.

## Where the messages are

COMBAT $2983 is the message printer, and it settles every open question:

` $2983  STX $2B44        ; remember which message $2986  JSR $0969        ; window := the four bytes at $0970 $2989  LDA #$0A $298B  STA $03F4        ; ...then move its top row to 10 $298E  JSR $488B        ; cursor to the top-left of the window $2991  JSR $2E19        ; clear the window $2994  JSR $34C3        ; print the name at $6B00 $299A  INC $03CD        ; next row $299D  JSR $3ECB        ; ...column 0 of it $29A5  LDA $AF00,X      ; the message string, X + $39 into SPELLN00's table $29AB  JMP $0962        ; print it `

$0969 is LDA #$70 / LDX #$09 / JMP $485A, and LIBRARY $485A copies four bytes from the address in A/X to $03F2-$03F5. COMBAT $0970 holds 17 27 01 17, so the combat text window is columns 23-38, rows 1-22, and $2989 moves the top to row 10 for messages. Rows 1-9 of the same band are the acting combatant’s panel – name, HIT POINTS n, AC n, weapon, read off a live fight – which is why the top matters and the band alone would not do.

where

what

$03F2

window’s left column

$03F3

one past its right column

$03F4

its top row – 10 while a message is printing

$03F5

one past its bottom row

$03CC

the cursor’s column

$03CD

the cursor’s row

$49FC

the message delay, INIT sets it to 2 and CAMP steps it

## Whether it scrolls or overwrites

Both, and mostly overwrites. $2983 clears rows 10-22 and starts again at the top, so each new speaker wipes the panel. Within one speaker’s block $299A appends on the next row, and $29BA moves $03F4 down to the row below the cursor so a follow-up (“GOES DOWN”, “IS KILLED”) lands under what is already there. Only when a block runs past row 22 does LIBRARY $2D28 call $2CA5, which scrolls the window up by one line.

So the four shapes a frame-to-frame change can take are: grew (more rows, or more characters on the last row), shrank (the same rows with the bottom ones cleared, which $29B7 does when a follow-up’s delay runs out), scrolled (everything moved up one), and replaced (anything else). Each gets its own rule below.

## Whether the game waits for a key

No. COMBAT $28C3 reads $49FC and, if it is not zero, jumps to LIBRARY $2E1F – a DEX/DEY busy loop of about 325,000 cycles per unit, so roughly a third of a second each, three of them at the default setting of 2. Then $29B7 clears the window. Nothing reads the keyboard in that path.

That is the whole risk: a message lives for about a second of emulated time and then is gone. At the default 200 ms poll that is five frames, which is plenty – but it is the number to check first if messages start going missing.

## Deduplication

Only consecutive identical frames are dropped, never identical content. Two “MAGNUS MISSES.” in a row are both real, and the clear between them shows up as a frame that no longer extends the last one. Where the clear itself falls between two polls there is a second, independent edge: $03F4 going back to 10 means $2983 ran, which is a new block whatever the text says.

## The dice, beside what was printed

poll reads $2B10 and $A4F0-$A4FB on the same burst as the screen, so the dice cost bytes and not a round trip, and hands them to rolls.py. They are kept on the frame that starts a block rather than at commit time, because a block is committed when the game paints over it and by then $2B10 can belong to the next attack.

## What a live fight changed

Two rules here were wrong, and both turn on bytes only a running game writes — see docs/50-experiments.md, “The combat log’s two defects, found in a slums fight”, and message_window and _shrank below. In short: $03F2-$03F5 are the command bar’s window a fifth of the time, and a block shrinks as well as grows.

Module Attributes

WINDOW

left, one-past-right, top, one-past-bottom -- four consecutive bytes, which is why LIBRARY $485A can set them with a four-byte copy.

CURSOR

$3ECB writes $03CC, $488B writes both.

DELAY

The delay between a message and the clear that follows it, in units of about a third of a second.

COMBAT_WINDOW

COMBAT $0970, the block $0969 hands to LIBRARY $485A.

MESSAGE_TOP

COMBAT $2989 LDA #$0A.

WIDTH

$0970 gives 23 to 38 inclusive.

Functions

message(lines[, round_no, width, roll])

A Message from the rows of one block.

message_window(block)

The region to read, and $03F4 only when it is ours.

parse(text)

Subject, outcome and damage, where the line says them.

plausible_window(block)

The four window bytes, or None if they cannot be a window.

recase(text, names)

The game's shouted line as ordinary prose.

Classes

CombatLog

Frames of the message panel, folded into messages.

Message

One block of the message panel, as it stood when it was painted over.

automap.combatlog.COMBAT_WINDOW = (23, 39, 1, 23)

COMBAT $0970, the block $0969 hands to LIBRARY $485A.

automap.combatlog.CURSOR = 972

$3ECB writes $03CC, $488B writes both.

Type:

column then row, in that order

class automap.combatlog.CombatLog[source]

Bases: object

Frames of the message panel, folded into messages.

poll is one burst per tick, so the cost is one resume – about 14.3 ms of extra emulated time under VICE, on top of the two bursts the combat view already spends. Whether that is affordable is the one thing here that has to be measured on a live machine rather than reasoned about.

LIMIT = 1000

Kept messages. A long fight is a few hundred lines; past this the oldest go, the same bargain MessagesPanel makes.

__init__(limit=None)[source]
Parameters:

limit (int | None)

flush()[source]

Commit whatever is on screen. Called when the fight ends.

The last message of a fight is never painted over – COMBAT returns to LINKER with it still up – so without this it would be the one message the log lost.

Return type:

list[automap.combatlog.Message]

messages: list[automap.combatlog.Message]
note_round(initiative)[source]

$A380 reaching all-zero ends a round; the next non-zero starts one.

Optional: with nothing passed the messages carry round=None and are still in order, which is most of what a round tag gives you.

Parameters:

initiative (bytes)

Return type:

None

observe(rows, top=None, roll=None)[source]

One frame of the message panel. Returns whatever it completed.

top is $03F4, which $2983 sets to 10 for a fresh block and $29BA moves down for a follow-up. It is the second edge: a block that is textually identical to the one before it is still a new block if $03F4 went back up.

roll is the dice as this frame read them, and is kept only where this frame starts a block – see _roll.

Parameters:
Return type:

list[automap.combatlog.Message]

poll(target)[source]

Read one frame off a live target. One burst.

Returns the messages this frame completed – usually none, because a message is only complete once the game has painted over it.

Return type:

list[automap.combatlog.Message]

reset_fight()[source]

Forget everything that belonged to the fight that just ended.

Called after flush, so the last message of the fight keeps the round it happened in. CombatLog is built once a session and reused, so without this the round counter climbs across fights and reaches 50 in an evening.

What is reset here is what flush does not already clear: _pending, _last, _last_top and _heads are cleared there, and _roll goes with _pending in _commit, so clearing them again would be two places to keep in step. What stays is messages, which is the log’s history and is what the player is still reading; _address, which is where the screen is rather than anything about a fight, and is re-checked against $D018/$DD00 on every poll anyway; and _height/_width, which poll reads out of $03F2-$03F5 before every observe.

Return type:

None

round: int | None
automap.combatlog.DELAY = 18940

The delay between a message and the clear that follows it, in units of about a third of a second. INIT $09AC sets it to 2; CAMP $0CA1/$0CA6 step it.

automap.combatlog.MESSAGE_TOP = 10

COMBAT $2989 LDA #$0A. Rows 1-9 are the acting combatant’s panel, not messages.

class automap.combatlog.Message[source]

Bases: object

One block of the message panel, as it stood when it was painted over.

lines is exactly what the rows held. text is those rows joined – with no space where the row before it filled the window, because LIBRARY $2D28 wraps on the character and not on the word, so a full row is a word cut in half rather than a line that ended.

The parsed fields are best effort and optional. A line that will not parse keeps subject, outcome and damage as None and is shown by its text, which is still far better than what the game gives you.

__init__(lines, text, round=None, subject=None, outcome=None, damage=None, roll=None)
Parameters:
Return type:

None

damage: int | None = None
lines: tuple[str, ...]
outcome: str | None = None
roll: automap.rolls.Roll | None = None

The dice as they stood when this block was first seen, or None. Only the first message of a block carries one – a follow-up like “ORC GOES DOWN” is not an attack and has no roll of its own. See rolls.py.

round: int | None = None
subject: str | None = None
text: str
automap.combatlog.WIDTH = 16

$0970 gives 23 to 38 inclusive.

Type:

Columns in the combat message window

automap.combatlog.WINDOW = 1010

left, one-past-right, top, one-past-bottom – four consecutive bytes, which is why LIBRARY $485A can set them with a four-byte copy.

automap.combatlog.message(lines, round_no=None, width=WIDTH, roll=None)[source]

A Message from the rows of one block.

Parameters:
Return type:

automap.combatlog.Message

automap.combatlog.message_window(block)[source]

The region to read, and $03F4 only when it is ours.

$03F2-$03F5 describe whichever window the game printed into last, and in a fight that is often not the message panel: the command bar sets 00 28 18 19 – columns 0 to 39, row 24 – every time it prints GUARDING, MOVE VIEW or YOUR TEAMMATE IS DYING. Believing those four bytes then slices whole rows 10 to 24 out of the screen, which is the combat map, the border and the command bar; the map is drawn in the game’s own glyphs, so it decodes as &'( )*+ ,-. and lands in the log as a message. That is the “readable data mixed with a lot of garbage” this reader was reported for, and it is reproducible: 29 of 649 frames of one slums fight carried 00 28 18 19, and four of them were logged.

The columns are not in doubt – COMBAT $0970 is 17 27 01 17 on all eight disk sides – so they are taken from COMBAT_WINDOW whenever the live bytes describe some other window, and top comes back as None because $03F4 then belongs to that other window: reading it would put a false row into _heads and fire the restart edge on a command-bar print. The bottom is clamped either way, which keeps rows 23 and 24 – the border and the command bar – out of a message.

Parameters:

block (bytes)

Return type:

tuple[int, int, int | None, int]

automap.combatlog.parse(text)[source]

Subject, outcome and damage, where the line says them.

One block names one combatant – the attacker in “X ATTACKS AND HITS FOR 5”, the target in “Y IS HIT FOR 5”. Pairing the two is a later job and would be invention here.

Parameters:

text (str)

Return type:

tuple[str | None, str | None, int | None]

automap.combatlog.plausible_window(block)[source]

The four window bytes, or None if they cannot be a window.

$03F2-$03F5 are ordinary RAM and hold whatever the last overlay left there. Validate before trust, the same rule shape_from_params follows.

Parameters:

block (bytes)

Return type:

tuple[int, int, int, int] | None

automap.combatlog.recase(text, names)[source]

The game’s shouted line as ordinary prose. Display only.

The C64’s character set is capitals, so the game prints in capitals and CombatLog keeps exactly what it printed – that text is the evidence, and the tests assert on it. This runs on the way to the panel and nowhere else: observe, _commit and message must never call it.

names is the fight’s combatants, and is the only authority on which words are names; guessing from the text would capitalise ORC in AND HITS FOR as readily as in ORC IS KILLED. Longest first, so a name that contains another does not half-match, and on whole words only, so a name is not found inside a longer word. Everything else is lower-cased and the first letter of the line is put back afterwards, so a line that starts with a word which is not a name still reads.

A name the roster does not know – a monster that has left the fight, a line naming something that is not a combatant – is simply lower-cased. That is the honest answer: the alternative is capitalising words on a guess, and a wrong name is worse than a quiet one.

Parameters:

text (str)

Return type:

str