Lode Runner: Apple II reverse engineering

3. Apple II Graphics#

Hi-res graphics on the Apple II is odd. Graphics are memory-mapped, not exactly consecutively, and bits don't always correspond to pixels. Color especially is odd, compared to today's luxurious 32-bit per pixel RGBA.

The Apple II has two hi-res graphics pages, and maps the area from $2000-$3FFF to high-res graphics page 1 (HGR1), and $4000-$5FFF to page 2 (HGR2).

We have routines to clear these screens.

⟨routines [4]⟩=
    ORG     $7A51
CLEAR_HGR1:
    SUBROUTINE

    LDA     #$20                ; Start at $2000
    LDX     #$40                ; End at $4000 (but not including)
    BNE     CLEAR_PAGE          ; Unconditional jump

CLEAR_HGR2:
    SUBROUTINE

    LDA     #$40                ; Start at $4000
    LDX     #$60                ; End at $6000 (but not including)
    ; fallthrough

CLEAR_PAGE:
    STA     TMP_PTR+1           ; Start with the page in A.
    LDA     #$00
    STA     TMP_PTR
    TAY
    LDA     #$80                ; fill byte = 0x80

.loop:
    STA     (TMP_PTR),Y
    INY
    BNE     .loop
    INC     TMP_PTR+1
    CPX     TMP_PTR+1
    BNE     .loop               ; while TMP_PTR != X * 0x100
    RTS
Used in ⟨*⟩
Defines CLEAR_HGR1, CLEAR_HGR2
Uses TMP_PTR

3.1 Pixels and their color#

First we'll talk about pixels. Nominally, the resolution of the hi-res graphics screen is 280 pixels wide by 192 pixels tall. In the memory map, each row is represented by 40 bytes. The high bit of each byte is not used for pixel data, but is used to control color.

Here are some rules for how these bytes are turned into pixels:

Odd Even
High bit clear Green Violet
High bit set Orange Blue

The implication is that you can only select one pair of colors per byte.

An example would probably be good here. We will take one of the sprites from the game.

Bytes Bits Pixel Data
00 00 0000000 0000000 00000000000000
00 00 0000000 0000000 00000000000000
00 00 0000000 0000000 00000000000000
55 00 1010101 0000000 10101010000000
41 00 1000001 0000000 10000010000000
01 00 0000001 0000000 10000000000000
55 00 1010101 0000000 10101010000000
50 00 1010000 0000000 00001010000000
50 00 1010000 0000000 00001010000000
51 00 1010001 0000000 10001010000000
55 00 1010101 0000000 10101010000000

The game automatically sets the high bit of each byte, so we know we're going to see orange and blue. Assuming that the following bits are all zero, and we place the sprite starting at column 0, we should see this:

0
1
2
3
4
5
6
7
8
9
10

Here is a more complex sprite:

Bytes Bits Pixel Data
40 00 1000000 0000000 00000010000000
60 01 1100000 0000001 00000111000000
60 01 1100000 0000001 00000111000000
70 00 1110000 0000000 00001110000000
6C 01 1101100 0000001 00110111000000
36 06 0110110 0000110 01101100110000
30 00 0110000 0000000 00001100000000
70 00 1110000 0000000 00001110000000
5E 01 1011110 0000001 01111011000000
40 01 1000000 0000001 00000011000000
40 01 1000000 0000001 00000011000000
0
1
2
3
4
5
6
7
8
9
10

Take note of the orange and blue pixels. All the patterns noted in the rules above are used.

(a2-lode-runner): This is sprite 9, the player (SPRITE_PLAYER); the first example is sprite 87, the letter S. Both are in the sprite catalog in the next section. Their colours are not stored in the sprite: they depend on whether a pixel lands on an odd or an even screen column. Moved by an odd number of columns, the same bytes show the dot on the player's head in orange instead of blue:

The player sprite at two positions an odd number of columns apart: the dot on the head is blue in one and orange in the other

Rendered by a2-hires-lab, which shows black as grey. The small green square is the selected Excel cell: the top-left corner of the sprite's 14 by 11 area, where the right-hand player was placed. For how Apple II hi-res colour comes about, see Jeffrey Stanton, Apple Graphics & Arcade Game Design (The Book Company, 1982), and the colour model in a2-hires-lab.

3.2 The sprites#

Lode Runner defines 104 sprites, each being 11 rows, with two bytes per row. The first bytes of all 104 sprites are in the table first, then the second bytes, then the third bytes, and so on. Later we will see that only the leftmost 10 pixels out of the 14-pixel description is used.

⟨tables [6]⟩=
    ORG     $AD00
SPRITE_DATA:
    INCLUDE "sprite_data.asm"
Used in ⟨*⟩
Defines SPRITE_DATA
Sprite 0
Sprite 1
Sprite 2
Sprite 3
Sprite 4
Sprite 5
Sprite 6
Sprite 7
Sprite 8
Sprite 9
Sprite 10
Sprite 11
Sprite 12
Sprite 13
Sprite 14
Sprite 15
Sprite 16
Sprite 17
Sprite 18
Sprite 19
Sprite 20
Sprite 21
Sprite 22
Sprite 23
Sprite 24
Sprite 25
Sprite 26
Sprite 27
Sprite 28
Sprite 29
Sprite 30
Sprite 31
Sprite 32
Sprite 33
Sprite 34
Sprite 35
Sprite 36
Sprite 37
Sprite 38
Sprite 39
Sprite 40
Sprite 41
Sprite 42
Sprite 43
Sprite 44
Sprite 45
Sprite 46
Sprite 47
Sprite 48
Sprite 49
Sprite 50
Sprite 51
Sprite 52
Sprite 53
Sprite 54
Sprite 55
Sprite 56
Sprite 57
Sprite 58
Sprite 59
Sprite 60
Sprite 61
Sprite 62
Sprite 63
Sprite 64
Sprite 65
Sprite 66
Sprite 67
Sprite 68
Sprite 69
Sprite 70
Sprite 71
Sprite 72
Sprite 73
Sprite 74
Sprite 75
Sprite 76
Sprite 77
Sprite 78
Sprite 79
Sprite 80
Sprite 81
Sprite 82
Sprite 83
Sprite 84
Sprite 85
Sprite 86
Sprite 87
Sprite 88
Sprite 89
Sprite 90
Sprite 91
Sprite 92
Sprite 93
Sprite 94
Sprite 95
Sprite 96
Sprite 97
Sprite 98
Sprite 99
Sprite 100
Sprite 101
Sprite 102
Sprite 103
⟨defines [8]⟩+=
SPRITE_EMPTY        EQU     #$00
SPRITE_BRICK        EQU     #$01
SPRITE_STONE        EQU     #$02
SPRITE_LADDER       EQU     #$03
SPRITE_ROPE         EQU     #$04
SPRITE_TRAP         EQU     #$05
SPRITE_INVISIBLE_LADDER       EQU     #$06
SPRITE_GOLD         EQU     #$07
SPRITE_GUARD        EQU     #$08
SPRITE_PLAYER       EQU     #$09
SPRITE_ALLWHITE     EQU     #$0A
SPRITE_BRICK_FILL0  EQU     #$37
SPRITE_BRICK_FILL1  EQU     #$38
SPRITE_GUARD_EGG0   EQU     #$39
SPRITE_GUARD_EGG1   EQU     #$3A

(a2-lode-runner): Why this order? The 6502 has no multiply instruction. If each sprite's 22 bytes were stored together, finding a sprite would take a multiplication by 22 on every draw. In this layout the sprite number is itself the index: the table is 22 blocks of 104 bytes, one block per byte position, so LDA (TMP_PTR),Y with Y set to the sprite number reads that sprite's byte, and the next byte of the same sprite is always 104 ($68) bytes further on. COMPUTE_SHIFTED_SPRITE in the next section works exactly like this.

To see which pixel of a sprite belongs to which bit of which byte, a2-hires-lab loads a sprite by its number into the Sprite (load) sheet, with pixels, bytes, bits and colours side by side.

3.3 Shifting sprites#

This is all very good if we're going to draw sprites exactly on 7-pixel boundaries, but what if we want to draw them starting at other columns? In general, such a shifted sprite would straddle three bytes, and Lode Runner sets aside an area of memory at the end of zero page for 11 rows of three bytes that we'll write to when we want to compute the data for a shifted sprite.

⟨defines [10]⟩+=
BLOCK_DATA      EQU     $DF     ; 33 bytes
Used in ⟨*⟩
Defines BLOCK_DATA

Lode Runner also contains tables which show how to shift any arbitrary 7-pixel pattern right by any amount from zero to six pixels.

For example, suppose we start with a pixel pattern of 0110001, and we want to shift that right by three bits. The 14-bit result would be 0000110 0010000. However, we have to break that up into bytes, reverse the bits (remember that each byte's bits are output as pixels least significant bit first), and set their high bits, so we end up with 10110000 10000100.

Now, given a shift amount and a pixel pattern, we should be able to find the two-byte shifted pattern. Lode Runner accomplishes this with table lookups as follows:

Diagram: the shift amount selects a page of the pixel shift table, the pixel pattern selects an offset and a page in it, and together they point into the pixel pattern table

The pixel pattern table is a table of every possible pattern of 7 consecutive pixels spread out over two bytes. This table is 512 entries, each entry being two bytes. A naive table would have redundancy. For example the pattern 0000100 starting at column 0 is exactly the same as the pattern 0001000 starting at column 1. This table eliminates that redundancy.

⟨tables [12]⟩+=
    ORG     $A900
PIXEL_PATTERN_TABLE:
    INCLUDE "pixel_pattern_table.asm"
Used in ⟨*⟩
Defines PIXEL_PATTERN_TABLE

Now we just need tables which index into PIXEL_PATTERN_TABLE for every 7-pixel pattern and shift value. This table works by having the page number for the shifted pixel pattern at index shift * 0x100 + 0x80 + pattern and the offset at index shift * 0x100 + pattern.

⟨tables [14]⟩+=
    ORG     $A200
PIXEL_SHIFT_TABLE:
    INCLUDE "pixel_shift_table.asm"
Used in ⟨*⟩
Defines PIXEL_SHIFT_TABLE

Rather than multiplying the shift value by 0x100, we instead define another table which holds the page numbers for the shift tables for each shift value.

⟨tables [16]⟩+=
    ORG     $84C1
PIXEL_SHIFT_PAGES:
    HEX     A2 A3 A4 A5 A6 A7 A8
Used in ⟨*⟩
Defines PIXEL_SHIFT_PAGES

So we can get shifted pixels by indexing into all these tables.

Now we can define a routine that will take a sprite number and a pixel shift amount, and write the shifted pixel data into the BLOCK_DATA area. The routine first shifts the first byte of the sprite into a two-byte area. Then it shifts the second byte of the sprite, and combines that two-byte result with the first. Thus, we shift two bytes of sprite data into a three-byte result.

Diagram: both bytes of a sprite row are shifted into two-byte results, which are ORed together into the three bytes of block data

Rather than load addresses from the tables and store them, the routine modifies its own instructions with those addresses.

⟨defines [18]⟩+=
ROW_COUNT       EQU     $1D
SPRITE_NUM      EQU     $1E
Used in ⟨*⟩
Defines ROW_COUNT, SPRITE_NUM
⟨routines [20]⟩+=
    ORG     $8438
COMPUTE_SHIFTED_SPRITE:
    SUBROUTINE
    ; Enter routine with X set to pixel shift amount and
    ; SPRITE_NUM containing the sprite number to read.

.offset_table       EQU $A000               ; Target addresses in read
.page_table         EQU $A080               ; instructions. The only truly
.shift_ptr_byte0    EQU $A000               ; necessary value here is the
.shift_ptr_byte1    EQU $A000               ; 0x80 in .shift_ptr_byte0.

    LDA     #$0B                            ; 11 rows
    STA     ROW_COUNT
    LDA     #<SPRITE_DATA
    STA     TMP_PTR
    LDA     #>SPRITE_DATA
    STA     TMP_PTR+1                       ; TMP_PTR = SPRITE_DATA
    LDA     PIXEL_SHIFT_PAGES,X 
    STA     .rd_offset_table + 2
    STA     .rd_page_table + 2
    STA     .rd_offset_table2 + 2
    STA     .rd_page_table2 + 2             ; Fix up pages in lookup instructions
                                            ; based on shift amount (X).

    LDX     #$00                            ; X is the offset into BLOCK_DATA.

.loop:                                      ; === LOOP === (over all 11 rows)
    LDY     SPRITE_NUM
    LDA     (TMP_PTR),Y 
    TAY                                     ; Get sprite pixel data.

.rd_offset_table:
    LDA     .offset_table,Y                 ; Load offset for shift amount.
    STA     .rd_shift_ptr_byte0 + 1
    CLC
    ADC     #$01
    STA     .rd_shift_ptr_byte1 + 1         ; Fix up instruction offsets with it.
.rd_page_table:
    LDA     .page_table,Y                   ; Load page for shift amount.
    STA     .rd_shift_ptr_byte0 + 2
    STA     .rd_shift_ptr_byte1 + 2         ; Fix up instruction page with it.

.rd_shift_ptr_byte0:
    LDA     .shift_ptr_byte0                ; Read shifted pixel data byte 0
    STA     BLOCK_DATA,X                    ; and store in block data byte 0.
.rd_shift_ptr_byte1:
    LDA     .shift_ptr_byte1                ; Read shifted pixel data byte 1
    STA     BLOCK_DATA+1,X                  ; and store in block data byte 1.

    LDA     TMP_PTR
    CLC
    ADC     #$68
    STA     TMP_PTR
    LDA     TMP_PTR+1
    ADC     #$00
    STA     TMP_PTR+1                       ; TMP_PTR++

    ; Now basically do the same thing with the second sprite byte

    LDY     SPRITE_NUM
    LDA     (TMP_PTR),Y 
    TAY                                     ; Get sprite pixel data.

.rd_offset_table2:
    LDA     .offset_table,Y                 ; Load offset for shift amount.
    STA     .rd_shift_ptr2_byte0 + 1
    CLC
    ADC     #$01
    STA     .rd_shift_ptr2_byte1 + 1        ; Fix up instruction offsets with it.
.rd_page_table2:
    LDA     .page_table,Y                   ; Load page for shift amount.
    STA     .rd_shift_ptr2_byte0 + 2
    STA     .rd_shift_ptr2_byte1 + 2        ; Fix up instruction page with it.

.rd_shift_ptr2_byte0:
    LDA     .shift_ptr_byte0                ; Read shifted pixel data byte 0
    ORA     BLOCK_DATA+1,X                  ; OR with previous block data byte 1
    STA     BLOCK_DATA+1,X                  ; and store in block data byte 1.
.rd_shift_ptr2_byte1:
    LDA     .shift_ptr_byte1                ; Read shifted pixel data byte 1
    STA     BLOCK_DATA+2,X                  ; and store in block data byte 2.

    LDA     TMP_PTR
    CLC
    ADC     #$68
    STA     TMP_PTR
    LDA     TMP_PTR+1
    ADC     #$00
    STA     TMP_PTR+1                       ; TMP_PTR++

    INX
    INX
    INX                                     ; X += 3
    DEC     ROW_COUNT                       ; ROW_COUNT--
    BNE     .loop                           ; loop while ROW_COUNT > 0
    RTS

(a2-lode-runner): A smaller and faster table. As built, each sprite byte goes through two tables. Its 7-bit pattern Y indexes the shift table page for the shift amount, which yields the address of a two-byte entry in PIXEL_PATTERN_TABLE; the routine writes that address into its own LDA instructions and then reads the two result bytes. For pattern $16 (%0010110) shifted by 3:

flowchart LR S["shift 3"] -->|"PIXEL_SHIFT_PAGES"| P["page $A5"] Y["pattern $16"] --> LO Y --> HI P -->|"$A500 + $16"| LO["offset $5A"] P -->|"$A580 + $16"| HI["page $A9"] LO --> A["address $A95A<br/>in PIXEL_PATTERN_TABLE"] HI --> A A --> B["bytes $B0 $81"]

There are only 128 patterns times 7 shift amounts, 896 cases of two bytes each. A table holding those result bytes directly needs 896 x 2 = 1,792 bytes: exactly the size of PIXEL_SHIFT_TABLE alone, with byte 0 in the first half of each page and byte 1 in the second half, where the offsets and pages sit now. The lookup then becomes two plain reads, and the per-byte patching disappears:

    LDA     $A200,Y         ; page patched to $A2 + shift once per sprite -> byte 0
    STA     BLOCK_DATA,X
    LDA     $A280,Y         ; same page, second half -> byte 1
    STA     BLOCK_DATA+1,X

This would save the 1,024 bytes of PIXEL_PATTERN_TABLE and 56 of the 162 cycles per sprite row: 1,208 instead of 1,824 cycles for the whole routine, a third less. (Counted with the standard 6502 timings, including the RTS; the occasional extra cycle when (TMP_PTR),Y crosses a page is the same in both versions.) The two tables were checked against direct shifting for all 896 cases, and all 512 pattern entries are used. The check runs as a test in a2-hires-lab, whose Pixel Shifter and Sprite Shifter sheets follow both lookups step by step.

Reducing 896 cases to 512 unique patterns looks like a saving, but the two-byte pointers to them cost as much as the result bytes themselves. Why the game uses two tables anyway is not known.

Why main.pdf says "never used". In main.pdf, the notes under the definitions of PIXEL_SHIFT_TABLE and PIXEL_PATTERN_TABLE say "never used", although this routine reads both on every call. No instruction names them: the shift table is reached through the page numbers in PIXEL_SHIFT_PAGES, and the pattern table through addresses the routine writes into its own instructions. A cross-reference built from names cannot see accesses like these.

3.4 Memory mapped graphics#

Within a screen row, consecutive bytes map to consecutive pixels. However, rows themselves are not consecutive in memory.

To make it easy to convert a row number from 0 to 191 to a base address, Lode Runner has a table and a routine to use that table.

⟨tables [22]⟩+=
    ORG     $1A85
ROW_TO_OFFSET_LO:
    INCLUDE "row_to_offset_lo_table.asm"
ROW_TO_OFFSET_HI:
    INCLUDE "row_to_offset_hi_table.asm"
Used in ⟨*⟩
Defines ROW_TO_OFFSET_HI, ROW_TO_OFFSET_LO
⟨defines [24]⟩+=
ROW_ADDR        EQU     $0C     ; 2 bytes
ROW_ADDR2       EQU     $0E     ; 2 bytes
HGR_PAGE        EQU     $1F     ; 0x20 for HGR1, 0x40 for HGR2
Used in ⟨*⟩
Defines HGR_PAGE, ROW_ADDR, ROW_ADDR2
⟨routines [26]⟩+=
    ORG     $7A31
ROW_TO_ADDR:
    SUBROUTINE
    ; Enter routine with Y set to row. Base address
    ; (for column 0) will be placed in ROW_ADDR.

    LDA     ROW_TO_OFFSET_LO,Y 
    STA     ROW_ADDR
    LDA     ROW_TO_OFFSET_HI,Y 
    ORA     HGR_PAGE
    STA     ROW_ADDR+1
    RTS

There's also a routine to load the address for both page 1 and page 2.

⟨routines [28]⟩+=
    ORG     $7A3E
ROW_TO_ADDR_FOR_BOTH_PAGES:
    SUBROUTINE
    ; Enter routine with Y set to row. Base address
    ; (for column 0) will be placed in ROW_ADDR (for page 1)
    ; and ROW_ADDR2 (for page 2).

    LDA     ROW_TO_OFFSET_LO,Y 
    STA     ROW_ADDR
    STA     ROW_ADDR2
    LDA     ROW_TO_OFFSET_HI,Y 
    ORA     #$20
    STA     ROW_ADDR+1
    EOR     #$60
    STA     ROW_ADDR2+1
    RTS
Used in ⟨*⟩
Defines ROW_TO_ADDR_FOR_BOTH_PAGES

Lode Runner's screens are organized into 28 sprites across by 17 sprites down. To convert between sprite coordinates and screen coordinates and vice-versa, we use tables and lookup routines. Each sprite is 10 pixels across by 11 pixels down.

Note that the last row is used for the status, so actually the game screen is 16 sprites vertically.

⟨defines [30]⟩+=
MAX_GAME_COL        EQU     #27     ; 0x1B
MAX_GAME_ROW        EQU     #15     ; 0x0F
⟨tables [32]⟩+=
    ORG     $1C35
HALF_SCREEN_COL_TABLE:
    ; 28 cols of 5 double-pixels each
    HEX     00 05 0a 0f 14 19 1e 23 28 2d 32 37 3c 41 46 4b
    HEX     50 55 5a 5f 64 69 6e 73 78 7d 82 87
SCREEN_ROW_TABLE:
    ; 17 rows of 11 pixels each
    HEX     00 0B 16 21 2C 37 42 4D 58 63 6E 79 84 8F 9A A5
    HEX     B5
COL_BYTE_TABLE:
    ; Byte number
    HEX     00 01 02 04 05 07 08 0A 0B 0C 0E 0F 11 12 14 15
    HEX     16 18 19 1B 1C 1E 1F 20 22 23 25 26
COL_SHIFT_TABLE:
    ; Right shift amount
    HEX     00 03 06 02 05 01 04 00 03 06 02 05 01 04 00 03
    HEX     06 02 05 01 04 00 03 06 02 05 01 04
HALF_SCREEN_COL_BYTE_TABLE:
    HEX     00 00 00 00 01 01 01 02 02 02 02 03 03 03 04 04
    HEX     04 04 05 05 05 06 06 06 06 07 07 07 08 08 08 08
    HEX     09 09 09 0A 0A 0A 0A 0B 0B 0B 0C 0C 0C 0C 0D 0D
    HEX     0D 0E 0E 0E 0E 0F 0F 0F 10 10 10 10 11 11 11 12
    HEX     12 12 12 13 13 13 14 14 14 14 15 15 15 16 16 16
    HEX     16 17 17 17 18 18 18 18 19 19 19 1A 1A 1A 1A 1B
    HEX     1B 1B 1C 1C 1C 1C 1D 1D 1D 1E 1E 1E 1E 1F 1F 1F
    HEX     20 20 20 20 21 21 21 22 22 22 22 23 23 23 24 24
    HEX     24 24 25 25 25 26 26 26 26 27 27 27
HALF_SCREEN_COL_SHIFT_TABLE:
    HEX     00 02 04 06 01 03 05 00 02 04 06 01 03 05 00 02
    HEX     04 06 01 03 05 00 02 04 06 01 03 05 00 02 04 06
    HEX     01 03 05 00 02 04 06 01 03 05 00 02 04 06 01 03
    HEX     05 00 02 04 06 01 03 05 00 02 04 06 01 03 05 00
    HEX     02 04 06 01 03 05 00 02 04 06 01 03 05 00 02 04
    HEX     06 01 03 05 00 02 04 06 01 03 05 00 02 04 06 01
    HEX     03 05 00 02 04 06 01 03 05 00 02 04 06 01 03 05
    HEX     00 02 04 06 01 03 05 00 02 04 06 01 03 05 00 02
    HEX     04 06 01 03 05 00 02 04 06 01 03 05
Used in ⟨*⟩
Defines COL_BYTE_TABLE, COL_SHIFT_TABLE, HALF_SCREEN_COL_BYTE_TABLE, HALF_SCREEN_COL_SHIFT_TABLE, HALF_SCREEN_COL_TABLE, SCREEN_ROW_TABLE

Here is the routine to return the screen coordinates for the given sprite coordinates. The reason that GET_SCREEN_COORDS_FOR returns half the screen column coordinate is that otherwise the screen column coordinate wouldn't fit in a register.

⟨routines [34]⟩+=
    ORG     $885D
GET_SCREEN_COORDS_FOR:
    SUBROUTINE
    ; Enter routine with Y set to sprite row (0-16) and
    ; X set to sprite column (0-27). On return, Y will be set to
    ; screen row, and X is set to half screen column.

    LDA     SCREEN_ROW_TABLE,Y 
    PHA
    LDA     HALF_SCREEN_COL_TABLE,X 
    TAX                         ; X = HALF_SCREEN_COL_TABLE[X]
    PLA
    TAY                         ; Y = SCREEN_ROW_TABLE[Y]
    RTS
Used in ⟨*⟩
Defines GET_SCREEN_COORDS_FOR

This routine takes a sprite column and converts it to the memory-mapped byte offset and right-shift amount.

⟨routines [36]⟩+=
    ORG     $8868
GET_BYTE_AND_SHIFT_FOR_COL:
    SUBROUTINE
    ; Enter routine with X set to sprite column. On
    ; return, A will be set to screen column byte number
    ; and X will be set to an additional right shift amount.

    LDA     COL_BYTE_TABLE,X 
    PHA                         ; A = COL_BYTE_TABLE[X]
    LDA     COL_SHIFT_TABLE,X 
    TAX                         ; X = COL_SHIFT_TABLE[X]
    PLA
    RTS
Used in ⟨*⟩
Defines GET_BYTE_AND_SHIFT_FOR_COL

This routine takes half the screen column coordinate and converts it to the memory-mapped byte offset and right-shift amount.

⟨routines [38]⟩+=
    ORG     $8872
GET_BYTE_AND_SHIFT_FOR_HALF_SCREEN_COL:
    SUBROUTINE
    ; Enter routine with X set to half screen column. On
    ; return, A will be set to screen column byte number
    ; and X will be set to an additional right shift amount.

    LDA     HALF_SCREEN_COL_BYTE_TABLE,X 
    PHA                         ; A = HALF_SCREEN_COL_BYTE_TABLE[X]
    LDA     HALF_SCREEN_COL_SHIFT_TABLE,X 
    TAX                         ; X = HALF_SCREEN_COL_SHIFT_TABLE[X]
    PLA
    RTS
Used in ⟨*⟩
Defines GET_BYTE_AND_SHIFT_FOR_HALF_SCREEN_COL

We also have some utility routines that let us take a sprite row or column and get its screen row or half column, but offset in either row or column by anywhere from -2 to +2.

⟨tables [40]⟩+=
    ORG     $888A
ROW_OFFSET_TABLE:
    HEX     FB FD 00 02 04
Used in ⟨*⟩
Defines ROW_OFFSET_TABLE
⟨routines [42]⟩+=
    ORG     $887C
GET_SCREEN_ROW_OFFSET_IN_X_FOR:
    SUBROUTINE
    ; Enter routine with X set to offset+2 (in double-pixels) and
    ; Y set to sprite row. On return, X will retain its value and
    ; Y will be set to the screen row.

    TXA
    PHA
    JSR     GET_SCREEN_COORDS_FOR
    PLA
    TAX                                 ; Restore X
    TYA
    CLC
    ADC     ROW_OFFSET_TABLE,X
    TAY
    RTS
Used in ⟨*⟩
Defines GET_SCREEN_ROW_OFFSET_IN_X_FOR
⟨tables [44]⟩+=
    ORG     $889D
COL_OFFSET_TABLE:
    HEX     FE FF 00 01 02
Used in ⟨*⟩
Defines COL_OFFSET_TABLE
⟨routines [46]⟩+=
    ORG     $888F
GET_HALF_SCREEN_COL_OFFSET_IN_Y_FOR:
    SUBROUTINE
    ; Enter routine with Y set to offset+2 (in double-pixels) and
    ; X set to sprite column. On return, Y will retain its value and
    ; X will be set to the half screen column.

    TYA
    PHA
    JSR     GET_SCREEN_COORDS_FOR
    PLA
    TAY                                 ; Restore Y
    TXA
    CLC
    ADC     COL_OFFSET_TABLE,Y
    TAX
    RTS
Used in ⟨*⟩
Defines GET_HALF_SCREEN_COL_OFFSET_IN_Y_FOR

Now we can finally write the routines that draw a sprite on the screen. We have one routine that draws a sprite at a given game row and game column. There are two entry points, one to draw on HGR1, and one for HGR2.

⟨defines [48]⟩+=
ROWNUM          EQU     $1B
COLNUM          EQU     $1C
MASK0           EQU     $50
MASK1           EQU     $51
COL_SHIFT_AMT   EQU     $71
GAME_COLNUM     EQU     $85
GAME_ROWNUM     EQU     $86
Used in ⟨*⟩
Defines COLNUM, COL_SHIFT_AMT, GAME_COLNUM, GAME_ROWNUM, MASK0, MASK1, ROWNUM
⟨tables [50]⟩+=
    ORG     $8328
PIXEL_MASK0:
    BYTE    %00000000
    BYTE    %00000001
    BYTE    %00000011
    BYTE    %00000111
    BYTE    %00001111
    BYTE    %00011111
    BYTE    %00111111
PIXEL_MASK1:
    BYTE    %11111000
    BYTE    %11110000
    BYTE    %11100000
    BYTE    %11000000
    BYTE    %10000000
    BYTE    %11111110
    BYTE    %11111100
Used in ⟨*⟩
Defines PIXEL_MASK0, PIXEL_MASK1
⟨routines [52]⟩+=
    ORG     $82AA
DRAW_SPRITE_PAGE1:
    SUBROUTINE
    ; Enter routine with A set to sprite number to draw,
    ; GAME_ROWNUM set to the row to draw it at, and GAME_COLNUM
    ; set to the column to draw it at.

    STA     SPRITE_NUM
    LDA     #$20                ; Page number for HGR1
    BNE     DRAW_SPRITE         ; Actually unconditional jump

DRAW_SPRITE_PAGE2:
    SUBROUTINE
    ; Enter routine with A set to sprite number to draw,
    ; GAME_ROWNUM set to the row to draw it at, and GAME_COLNUM
    ; set to the column to draw it at.

    STA     SPRITE_NUM
    LDA     #$40                ; Page number for HGR2
    ; fallthrough

DRAW_SPRITE:
    STA     HGR_PAGE
    LDY     GAME_ROWNUM
    JSR     GET_SCREEN_COORDS_FOR
    STY     ROWNUM              ; ROWNUM = SCREEN_ROW_TABLE[GAME_ROWNUM]

    LDX     GAME_COLNUM
    JSR     GET_BYTE_AND_SHIFT_FOR_COL
    STA     COLNUM              ; COLNUM = COL_BYTE_TABLE[GAME_COLNUM]
    STX     COL_SHIFT_AMT       ; COL_SHIFT_AMT = COL_SHIFT_TABLE[GAME_COLNUM]

    LDA     PIXEL_MASK0,X 
    STA     MASK0               ; MASK0 = PIXEL_MASK0[COL_SHIFT_AMT]
    LDA     PIXEL_MASK1,X 
    STA     MASK1               ; MASK1 = PIXEL_MASK1[COL_SHIFT_AMT]

    JSR     COMPUTE_SHIFTED_SPRITE

    LDA     #$0B
    STA     ROW_COUNT
    LDX     #$00
    LDA     COL_SHIFT_AMT
    CMP     #$05
    BCS     .need_3_bytes       ; If COL_SHIFT_AMT >= 5, we need to alter three
                                ; screen bytes, otherwise just two bytes.

.loop1:
    LDY     ROWNUM
    JSR     ROW_TO_ADDR
    LDY     COLNUM
    LDA     (ROW_ADDR),Y 
    AND     MASK0
    ORA     BLOCK_DATA,X 
    STA     (ROW_ADDR),Y        ; screen[COLNUM] =
                                ;   screen[COLNUM] & MASK0 | BLOCK_DATA[i]

    INX                         ; X++
    INY                         ; Y++
    LDA     (ROW_ADDR),Y 
    AND     MASK1
    ORA     BLOCK_DATA,X 
    STA     (ROW_ADDR),Y        ; screen[COLNUM+1] =
                                ;   screen[COLNUM+1] & MASK1 | BLOCK_DATA[i+1]

    INX
    INX                         ; X += 2
    INC     ROWNUM              ; ROWNUM++
    DEC     ROW_COUNT           ; ROW_COUNT--
    BNE     .loop1              ; loop while ROW_COUNT > 0
    RTS

.need_3_bytes
    LDY     ROWNUM
    JSR     ROW_TO_ADDR
    LDY     COLNUM
    LDA     (ROW_ADDR),Y 
    AND     MASK0
    ORA     BLOCK_DATA,X 
    STA     (ROW_ADDR),Y        ; screen[COLNUM] =
                                ;   screen[COLNUM] & MASK0 | BLOCK_DATA[i]

    INX                         ; X++
    INY                         ; Y++
    LDA     BLOCK_DATA,X 
    STA     (ROW_ADDR),Y        ; screen[COLNUM+1] = BLOCK_DATA[i+1]

    INX                         ; X++
    INY                         ; Y++
    LDA     (ROW_ADDR),Y 
    AND     MASK1
    ORA     BLOCK_DATA,X 
    STA     (ROW_ADDR),Y        ; screen[COLNUM+2] =
                                ;   screen[COLNUM+2] & MASK1 | BLOCK_DATA[i+2]

    INX                         ; X++
    INC     ROWNUM              ; ROWNUM++
    DEC     ROW_COUNT           ; ROW_COUNT--
    BNE     .need_3_bytes       ; loop while ROW_COUNT > 0
    RTS

There is a different routine which erases a sprite at a given screen coordinate. It does this by drawing the inverse of the sprite on page 1, then drawing the sprite data from page 2 (the background page) onto page 1.

Upon entry, the Y register needs to be set to the screen row coordinate (0-191). However, the X register needs to be set to half the screen column coordinate (0-139) because otherwise the maximum coordinate (279) wouldn't fit in a register.

⟨erase sprite at screen coordinate [54]⟩=
    ORG     $8336
ERASE_SPRITE_AT_PIXEL_COORDS:
    SUBROUTINE
    ; Enter routine with A set to sprite number to draw,
    ; Y set to the screen row to erase it at, and X
    ; set to *half* the screen column to erase it at.

    STY     ROWNUM
    STA     SPRITE_NUM
    JSR     GET_BYTE_AND_SHIFT_FOR_HALF_SCREEN_COL
    STA     COLNUM
    STX     COL_SHIFT_AMT
    JSR     COMPUTE_SHIFTED_SPRITE

    LDX     #$0B
    STX     ROW_COUNT
    LDX     #$00
    LDA     COL_SHIFT_AMT
    CMP     #$05
    BCS     .need_3_bytes       ; If COL_SHIFT_AMT >= 5, we need to alter three
                                ; screen bytes, otherwise just two bytes.

.loop1:
    LDY     ROWNUM
    JSR     ROW_TO_ADDR_FOR_BOTH_PAGES
    LDY     COLNUM
    LDA     BLOCK_DATA,X
    EOR     #$7F
    AND     (ROW_ADDR),Y
    ORA     (ROW_ADDR2),Y
    STA     (ROW_ADDR),Y            ; screen[COLNUM] =
                                    ;   (screen[COLNUM] & (BLOCK_DATA[i] ^ 0x7F)) |
                                    ;   screen2[COLNUM]

    INX                             ; X++
    INY                             ; Y++
    LDA     BLOCK_DATA,X
    EOR     #$7F
    AND     (ROW_ADDR),Y
    ORA     (ROW_ADDR2),Y
    STA     (ROW_ADDR),Y            ; screen[COLNUM+1] =
                                    ;   (screen[COLNUM+1] & (BLOCK_DATA[i+1] ^ 0x7F)) |
                                    ;   screen2[COLNUM+1]

    INX                             ; X++
    INX                             ; X++
    INC     ROWNUM
    DEC     ROW_COUNT
    BNE     .loop1
    RTS

.need_3_bytes:
    LDY     ROWNUM
    JSR     ROW_TO_ADDR_FOR_BOTH_PAGES
    LDY     COLNUM
    LDA     BLOCK_DATA,X
    EOR     #$7F
    AND     (ROW_ADDR),Y
    ORA     (ROW_ADDR2),Y
    STA     (ROW_ADDR),Y            ; screen[COLNUM] =
                                    ;   (screen[COLNUM] & (BLOCK_DATA[i] ^ 0x7F)) |
                                    ;   screen2[COLNUM]

    INX                             ; X++
    INY                             ; Y++
    LDA     BLOCK_DATA,X
    EOR     #$7F
    AND     (ROW_ADDR),Y
    ORA     (ROW_ADDR2),Y
    STA     (ROW_ADDR),Y            ; screen[COLNUM+1] =
                                    ;   (screen[COLNUM+1] & (BLOCK_DATA[i+1] ^ 0x7F)) |
                                    ;   screen2[COLNUM+1]

    INX                             ; X++
    INY                             ; Y++
    LDA     BLOCK_DATA,X
    EOR     #$7F
    AND     (ROW_ADDR),Y
    ORA     (ROW_ADDR2),Y
    STA     (ROW_ADDR),Y            ; screen[COLNUM+2] =
                                    ;   (screen[COLNUM+2] & (BLOCK_DATA[i+2] ^ 0x7F)) |
                                    ;   screen2[COLNUM+2]

    INX                             ; X++
    INC     ROWNUM
    DEC     ROW_COUNT
    BNE     .need_3_bytes
    RTS

And then there's the corresponding routine to draw a sprite at the given coordinates. The routine also sets whether the active and the background screens differ in SCREENS_DIFFER.

⟨defines [56]⟩+=
Used in ⟨*⟩
Defines SCREENS_DIFFER
⟨draw sprite at screen coordinate [58]⟩=
    ORG     $83A7
DRAW_SPRITE_AT_PIXEL_COORDS:
    SUBROUTINE
    ; Enter routine with A set to sprite number to draw,
    ; Y set to the screen row to draw it at, and X
    ; set to *half* the screen column to draw it at.

    STY     ROWNUM
    STA     SPRITE_NUM
    JSR     GET_BYTE_AND_SHIFT_FOR_HALF_SCREEN_COL
    STA     COLNUM
    STX     COL_SHIFT_AMT
    JSR     COMPUTE_SHIFTED_SPRITE

    LDA     #$0B
    STA     ROW_COUNT
    LDX     #$00
    STX     SCREENS_DIFFER      ; SCREENS_DIFFER = 0
    LDA     COL_SHIFT_AMT
    CMP     #$05
    BCS     .need_3_bytes       ; If COL_SHIFT_AMT >= 5, we need to alter three
                                ; screen bytes, otherwise just two bytes.

.loop1:
    LDY     ROWNUM
    JSR     ROW_TO_ADDR_FOR_BOTH_PAGES
    LDY     COLNUM
    LDA     (ROW_ADDR),Y
    EOR     (ROW_ADDR2),Y
    AND     BLOCK_DATA,X
    ORA     SCREENS_DIFFER
    STA     SCREENS_DIFFER          ; SCREENS_DIFFER |= 
                                    ;   ( (screen[COLNUM] ^ screen2[COLNUM]) &
                                    ;     BLOCK_DATA[i])
    LDA     BLOCK_DATA,X            
    ORA     (ROW_ADDR),Y            
    STA     (ROW_ADDR),Y            ; screen[COLNUM] |= BLOCK_DATA[i]

    INX                             ; X++
    INY                             ; Y++
    LDA     (ROW_ADDR),Y
    EOR     (ROW_ADDR2),Y
    AND     BLOCK_DATA,X
    ORA     SCREENS_DIFFER
    STA     SCREENS_DIFFER          ; SCREENS_DIFFER |= 
                                    ;   ( (screen[COLNUM+1] ^ screen2[COLNUM+1]) &
                                    ;     BLOCK_DATA[i+1])
    LDA     BLOCK_DATA,X            
    ORA     (ROW_ADDR),Y            
    STA     (ROW_ADDR),Y            ; screen[COLNUM+1] |= BLOCK_DATA[i+1]

    INX                             ; X++
    INX                             ; X++
    INC     ROWNUM
    DEC     ROW_COUNT
    BNE     .loop1
    RTS

.need_3_bytes:
    LDY     ROWNUM
    JSR     ROW_TO_ADDR_FOR_BOTH_PAGES
    LDY     COLNUM
    LDA     (ROW_ADDR),Y
    EOR     (ROW_ADDR2),Y
    AND     BLOCK_DATA,X
    ORA     SCREENS_DIFFER
    STA     SCREENS_DIFFER          ; SCREENS_DIFFER |= 
                                    ;   ( (screen[COLNUM] ^ screen2[COLNUM]) &
                                    ;     BLOCK_DATA[i])
    LDA     BLOCK_DATA,X            
    ORA     (ROW_ADDR),Y            
    STA     (ROW_ADDR),Y            ; screen[COLNUM] |= BLOCK_DATA[i]

    INX                             ; X++
    INY                             ; Y++
    LDA     (ROW_ADDR),Y
    EOR     (ROW_ADDR2),Y
    AND     BLOCK_DATA,X
    ORA     SCREENS_DIFFER
    STA     SCREENS_DIFFER          ; SCREENS_DIFFER |= 
                                    ;   ( (screen[COLNUM+1] ^ screen2[COLNUM+1]) &
                                    ;     BLOCK_DATA[i+1])
    LDA     BLOCK_DATA,X            
    ORA     (ROW_ADDR),Y            
    STA     (ROW_ADDR),Y            ; screen[COLNUM+1] |= BLOCK_DATA[i+1]

    INX                             ; X++
    INY                             ; Y++
    LDA     (ROW_ADDR),Y
    EOR     (ROW_ADDR2),Y
    AND     BLOCK_DATA,X
    ORA     SCREENS_DIFFER
    STA     SCREENS_DIFFER          ; SCREENS_DIFFER |= 
                                    ;   ( (screen[COLNUM+2] ^ screen2[COLNUM+2]) &
                                    ;     BLOCK_DATA[i+2])
    LDA     BLOCK_DATA,X            
    ORA     (ROW_ADDR),Y            
    STA     (ROW_ADDR),Y            ; screen[COLNUM+2] |= BLOCK_DATA[i+2]

    INX                             ; X++
    INC     ROWNUM
    DEC     ROW_COUNT
    BNE     .need_3_bytes
    RTS

There is a special routine to draw the player sprite at the player's location. If the two pages at the player's location are different and the player didn't pick up gold (which would explain the difference), then the player is killed.

⟨draw player [60]⟩=
    ORG     $6C02
DRAW_PLAYER:
    SUBROUTINE

    JSR     GET_SPRITE_AND_SCREEN_COORD_AT_PLAYER
    JSR     DRAW_SPRITE_AT_PIXEL_COORDS
    LDA     SCREENS_DIFFER
    BEQ     .end
    LDA     DIDNT_PICK_UP_GOLD
    BEQ     .end
    LSR     ALIVE       ; Set player as dead
.end
    RTS

3.5 Printing strings#

Now that we can put sprites onto the screen at any game coordinate, we can also have some routines that print strings. We saw above that we have letter and number sprites, plus some punctuation. Letters and punctuation are always blue, while numbers are always orange.

There is a basic routine to put a character at the current GAME_COLNUM and GAME_ROWNUM, incrementing this "cursor", and putting it at the beginning of the next line if we "print" a newline character.

We first define a routine to convert the ASCII code of a character to its sprite number. Lode Runner sets the high bit of the code to make it be treated as ASCII.

⟨char to sprite num [62]⟩=
    ORG     $7B2A
CHAR_TO_SPRITE_NUM:
    SUBROUTINE
    ; Enter routine with A set to the ASCII code of the
    ; character to convert to sprite number, with the high bit set.
    ; The sprite number is returned in A.

    CMP     #$C1                    ; 'A' -> sprite 69
    BCC     .not_letter
    CMP     #$DB                    ; 'Z' -> sprite 94
    BCC     .letter

.not_letter:
    ; On return, we will subtract 0x7C from X to
    ; get the actual sprite. This is to make A-Z
    ; easier to handle.
    LDX     #$7C
    CMP     #$A0                    ; ' ' -> sprite 0
    BEQ     .end
    LDX     #$DB
    CMP     #$BE                    ; '>' -> sprite 95
    BEQ     .end
    INX
    CMP     #$AE                    ; '.' -> sprite 96
    BEQ     .end
    INX
    CMP     #$A8                    ; '(' -> sprite 97
    BEQ     .end
    INX
    CMP     #$A9                    ; ')' -> sprite 98
    BEQ     .end
    INX
    CMP     #$AF                    ; '/' -> sprite 99
    BEQ     .end
    INX
    CMP     #$AD                    ; '-' -> sprite 100
    BEQ     .end
    INX
    CMP     #$BC                    ; '<' -> sprite 101
    BEQ     .end
    LDA     #$10                    ; sprite 16: just one of the man sprites
    RTS

.end:
    TXA

.letter:
    SEC
    SBC     #$7C                    
    RTS
Defines CHAR_TO_SPRITE_NUM

Now we can define the routine to put a character on the screen at the current position.

⟨defines [64]⟩+=
DRAW_PAGE   EQU     $87     ; 0x20 for page 1, 0x40 for page 2
Used in ⟨*⟩
Defines DRAW_PAGE
⟨put char [66]⟩=
    ORG     $7B64
PUT_CHAR:
    SUBROUTINE
    ; Enter routine with A set to the ASCII code of the
    ; character to put on the screen, with the high bit set.

    CMP     #$8D
    BEQ     NEWLINE                 ; If newline, do NEWLINE instead.
    JSR     CHAR_TO_SPRITE_NUM
    LDX     DRAW_PAGE
    CPX     #$40
    BEQ     .draw_to_page2

    JSR     DRAW_SPRITE_PAGE1
    INC     GAME_COLNUM
    RTS

.draw_to_page2
    JSR     DRAW_SPRITE_PAGE2
    INC     GAME_COLNUM
    RTS

NEWLINE:
    SUBROUTINE
    INC     GAME_ROWNUM
    LDA     #$00
    STA     GAME_COLNUM
    RTS

The PUT_STRING routine uses PUT_CHAR to put a string on the screen. Rather than take an address pointing to a string, instead it uses the return address as the source for data. It then has to fix up the actual return address at the end to be just after the zero-terminating byte of the string.

⟨defines [68]⟩+=
SAVED_RET_ADDR      EQU     $10     ; 2 bytes
Used in ⟨*⟩
Defines SAVED_RET_ADDR
⟨put string [70]⟩=
    ORG     $86E0
PUT_STRING:
    SUBROUTINE

    PLA
    STA     SAVED_RET_ADDR
    PLA
    STA     SAVED_RET_ADDR+1
    BNE     .next

.loop:
    LDY     #$00
    LDA     (SAVED_RET_ADDR),Y
    BEQ     .end
    JSR     PUT_CHAR

.next:
    INC     SAVED_RET_ADDR
    BNE     .loop
    INC     SAVED_RET_ADDR+1
    BNE     .loop

.end:
    LDA     SAVED_RET_ADDR+1
    PHA
    LDA     SAVED_RET_ADDR
    PHA
    RTS
Defines PUT_STRING

Like PUT_CHAR, we also have PUT_DIGIT which draws the sprite corresponding to digits 0 to 9 at the current position, incrementing the cursor.

⟨put digit [72]⟩=
    ORG     $7B15
PUT_DIGIT:
    SUBROUTINE
    ; Enter routine with A set to the digit to put on the screen.

    CLC
    ADC     #$3B                    ; '0' -> sprite 59, '9' -> sprite 68.
    LDX     DRAW_PAGE
    CPX     #$40
    BEQ     .draw_to_page2
    JSR     DRAW_SPRITE_PAGE1
    INC     GAME_COLNUM
    RTS

.draw_to_page2:
    JSR     DRAW_SPRITE_PAGE2
    INC     GAME_COLNUM
    RTS

3.6 Numbers#

We also need a way to put numbers on the screen.

First, a routine to convert a one-byte decimal number into hundreds, tens, and units.

⟨defines [74]⟩+=
HUNDREDS        EQU     $89
TENS            EQU     $8A
UNITS           EQU     $8B
Used in ⟨*⟩
Defines HUNDREDS, TENS, UNITS
⟨to decimal3 [76]⟩=
    ORG     $7AF8
TO_DECIMAL3:
    SUBROUTINE
    ; Enter routine with A set to the number to convert.

    LDX     #$00
    STX     TENS
    STX     HUNDREDS

.loop1:
    CMP     #100
    BCC     .loop2
    INC     HUNDREDS
    SBC     #100
    BNE     .loop1

.loop2:
    CMP     #10
    BCC     .end
    INC     TENS
    SBC     #10
    BNE     .loop2

.end:
    STA     UNITS
    RTS
Defines TO_DECIMAL3

There's also a routine to convert a BCD byte to tens and units.

⟨bcd to decimal2 [78]⟩=
    ORG     $7AE9
BCD_TO_DECIMAL2:
    SUBROUTINE
    ; Enter routine with A set to the BCD number to convert.

    STA     TENS
    AND     #$0F
    STA     UNITS
    LDA     TENS
    LSR
    LSR
    LSR
    LSR
    STA     TENS
    RTS
Defines BCD_TO_DECIMAL2
Uses TENS, UNITS

3.7 Score and status#

Lode Runner stores your score as an 8-digit BCD number.

⟨defines [80]⟩+=
SCORE       EQU     $8E     ; 4 bytes, BCD format, tens/units in first byte.
Used in ⟨*⟩
Defines SCORE

The score is always put on the screen at row 16 column 5, but only the last 7 digits. Row 16 is the status line, as can be seen at the bottom of this screenshot.

A Lode Runner level in play, with the status line at the bottom

There's a routine to add a 4-digit BCD number to the score and then update it on the screen.

⟨add and update score [82]⟩=
    ORG     $7A92
ADD_AND_UPDATE_SCORE:
    SUBROUTINE
    ; Enter routine with A set to BCD tens/units and
    ; Y set to BCD thousands/hundreds.

    CLC
    SED                         ; Turn on BCD addition mode.
    ADC     SCORE
    STA     SCORE
    TYA
    ADC     SCORE+1
    STA     SCORE+1
    LDA     #$00
    ADC     SCORE+2
    STA     SCORE+2
    LDA     #$00
    ADC     SCORE+3
    STA     SCORE+3             ; SCORE += param
    CLD                         ; Turn off BCD addition mode.

    LDA     #5
    STA     GAME_COLNUM
    LDA     #16
    STA     GAME_ROWNUM

    LDA     SCORE+3
    JSR     BCD_TO_DECIMAL2
    LDA     UNITS               ; Note we skipped TENS.
    JSR     PUT_DIGIT

    LDA     SCORE+2
    JSR     BCD_TO_DECIMAL2
    LDA     TENS
    JSR     PUT_DIGIT
    LDA     UNITS
    JSR     PUT_DIGIT

    LDA     SCORE+1
    JSR     BCD_TO_DECIMAL2
    LDA     TENS
    JSR     PUT_DIGIT
    LDA     UNITS
    JSR     PUT_DIGIT

    LDA     SCORE
    JSR     BCD_TO_DECIMAL2
    LDA     TENS
    JSR     PUT_DIGIT
    LDA     UNITS
    JMP     PUT_DIGIT           ; tail call
Defines ADD_AND_UPDATE_SCORE

The other elements in the status line are the number of men (i.e. lives) and the current level.

⟨defines [84]⟩+=
LEVELNUM    EQU     $A6
LIVES       EQU     $98
Used in ⟨*⟩
Defines LEVELNUM, LIVES

Here are the routines to put the lives and level number on the status line. Lives starts at column 16, and level number starts at column 25.

⟨put status [86]⟩=
    ORG     $7A70
PUT_STATUS_LIVES:
    SUBROUTINE

    LDA     LIVES
    LDX     #16
    ; fallthrough

PUT_STATUS_BYTE:
    SUBROUTINE
    ; Puts the number in A as a three-digit decimal on the screen
    ; at row 16, column X.

    STX     GAME_COLNUM
    JSR     TO_DECIMAL3
    LDA     #16
    STA     GAME_ROWNUM
    LDA     HUNDREDS
    JSR     PUT_DIGIT
    LDA     TENS
    JSR     PUT_DIGIT
    LDA     UNITS
    JMP     PUT_DIGIT           ; tail call

PUT_STATUS_LEVEL:
    SUBROUTINE

    LDA     LEVELNUM
    LDX     #25
    BNE     PUT_STATUS_BYTE     ; Unconditional jump

    ORG     $79AD
PUT_STATUS:
    SUBROUTINE

    JSR     CLEAR_HGR1
    JSR     CLEAR_HGR2

PUT_STATUS_DRAW:
    LDY     #$27
    LDA     DRAW_PAGE
    CMP     #$40
    BEQ     .draw_line_on_page_2

.draw_line_on_page_1:
    LDA     #$AA
    STA     $2350,Y
    STA     $2750,Y
    STA     $2B50,Y
    STA     $2F50,Y
    DEY
    LDA     #$D5
    STA     $2350,Y
    STA     $2750,Y
    STA     $2B50,Y
    STA     $2F50,Y
    DEY
    BPL     .draw_line_on_page_1
    BMI     .end        ; Unconditional

.draw_line_on_page_2:
    LDA     #$AA
    STA     $4350,Y
    STA     $4750,Y
    STA     $4B50,Y
    STA     $4F50,Y
    DEY
    LDA     #$D5
    STA     $4350,Y
    STA     $4750,Y
    STA     $4B50,Y
    STA     $4F50,Y
    DEY
    BPL     .draw_line_on_page_2

.end:
    LDA     #$10
    STA     GAME_ROWNUM
    LDA     #$00
    STA     GAME_COLNUM

    ; "SCORE        MEN    LEVEL   "
    JSR     PUT_STRING
    HEX     D3 C3 CF D2 C5 A0 A0 A0 A0 A0 A0 A0 A0 CD C5 CE
    HEX     A0 A0 A0 A0 CC C5 D6 C5 CC A0 A0 A0 00

    JSR     PUT_STATUS_LIVES
    JSR     PUT_STATUS_LEVEL
    LDA     #$00
    TAY
    JMP     ADD_AND_UPDATE_SCORE        ; tailcall