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 |
|---|---|
|
window’s left column |
|
one past its right column |
|
its top row – 10 while a message is printing |
|
one past its bottom row |
|
the cursor’s column |
|
the cursor’s row |
|
the message delay, |
## 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
left, one-past-right, top, one-past-bottom -- four consecutive bytes, which is why |
|
|
|
The delay between a message and the clear that follows it, in units of about a third of a second. |
|
|
|
|
|
|
Functions
|
A |
|
The region to read, and |
|
Subject, outcome and damage, where the line says them. |
|
The four window bytes, or None if they cannot be a window. |
|
The game's shouted line as ordinary prose. |
Classes
Frames of the message panel, folded into messages. |
|
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$0969hands toLIBRARY $485A.
- automap.combatlog.CURSOR = 972¶
$3ECBwrites$03CC,$488Bwrites both.- Type:
column then row, in that order
- class automap.combatlog.CombatLog[source]¶
Bases:
objectFrames of the message panel, folded into messages.
pollis one burst per tick, so the cost is oneresume– 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
MessagesPanelmakes.
- flush()[source]¶
Commit whatever is on screen. Called when the fight ends.
The last message of a fight is never painted over –
COMBATreturns toLINKERwith it still up – so without this it would be the one message the log lost.- Return type:
- messages: list[automap.combatlog.Message]¶
- note_round(initiative)[source]¶
$A380reaching all-zero ends a round; the next non-zero starts one.Optional: with nothing passed the messages carry
round=Noneand 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.
topis$03F4, which$2983sets to 10 for a fresh block and$29BAmoves 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$03F4went back up.rollis the dice as this frame read them, and is kept only where this frame starts a block – see_roll.- Parameters:
top (int | None)
roll (automap.rolls.Roll | None)
- Return type:
- 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:
- 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.CombatLogis 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
flushdoes not already clear:_pending,_last,_last_topand_headsare cleared there, and_rollgoes with_pendingin_commit, so clearing them again would be two places to keep in step. What stays ismessages, 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/$DD00on every poll anyway; and_height/_width, whichpollreads out of$03F2-$03F5before everyobserve.- Return type:
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 $09ACsets it to 2;CAMP $0CA1/$0CA6step 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:
objectOne block of the message panel, as it stood when it was painted over.
linesis exactly what the rows held.textis those rows joined – with no space where the row before it filled the window, becauseLIBRARY $2D28wraps 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,outcomeanddamageas None and is shown by itstext, which is still far better than what the game gives you.- __init__(lines, text, round=None, subject=None, outcome=None, damage=None, roll=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.
- automap.combatlog.WIDTH = 16¶
$0970gives 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 $485Acan set them with a four-byte copy.
- automap.combatlog.message(lines, round_no=None, width=WIDTH, roll=None)[source]¶
A
Messagefrom the rows of one block.- Parameters:
round_no (int | None)
width (int)
roll (automap.rolls.Roll | None)
- Return type:
- automap.combatlog.message_window(block)[source]¶
The region to read, and
$03F4only when it is ours.$03F2-$03F5describe whichever window the game printed into last, and in a fight that is often not the message panel: the command bar sets00 28 18 19– columns 0 to 39, row 24 – every time it printsGUARDING,MOVE VIEWorYOUR 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 carried00 28 18 19, and four of them were logged.The columns are not in doubt –
COMBAT $0970is17 27 01 17on all eight disk sides – so they are taken fromCOMBAT_WINDOWwhenever the live bytes describe some other window, andtopcomes back as None because$03F4then belongs to that other window: reading it would put a false row into_headsand 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.
- 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.
- automap.combatlog.plausible_window(block)[source]¶
The four window bytes, or None if they cannot be a window.
$03F2-$03F5are ordinary RAM and hold whatever the last overlay left there. Validate before trust, the same ruleshape_from_paramsfollows.
- 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
CombatLogkeeps 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,_commitandmessagemust never call it.namesis the fight’s combatants, and is the only authority on which words are names; guessing from the text would capitaliseORCinAND HITS FORas readily as inORC 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.