Skip to content

Give MARIA a bounded object list

Build reusable direct 160A headers while keeping zones, graphics placement and DMA ownership explicit.

Put a picture address, width and position in a list, and MARIA fetches the dots for you. Your CPU can get on with something else. The price is a different kind of budget: headers and picture bytes must fit into each scanline’s DMA time.

Two original A glyph objects on the Atari 7800, one at the right boundary.
Native Emu198x NTSC capture, direct 160A. Two eight-pixel-wide objects share original Code198x graphics. Logical horizontal pixels are doubled in this capture, which includes overscan.

Include, initialise, append

Copy objects.inc and include it once. Set ML_CAPACITY to 1..31. Reserve 10 private zero-page bytes at ML ($80 here), and a page-aligned RAM list of 4*capacity+2 bytes. The caller uses four slots: 18 bytes at $1900. These allocation rules are trusted preconditions; the routine does not validate arbitrary RAM pointers.

Put the list pointer at ML+0/1 and call maria_begin. It resets count at +7 and writes a two-byte terminator. It does not erase the old tail. Then supply a graphics pointer at +2/3, byte width at +4, palette at +5 and logical x at +6. Call maria_append; +8 returns 1 accepted or 0 rejected. This maintained example includes a right-edge fit and a rejected overrun:

Build the list before enabling DMA

demo.asm, excerpt
    lda #<LIST
    sta ML
    lda #>LIST
    sta ML+1
    jsr maria_begin
    lda #<graphics
    sta ML+2
    lda #>graphics
    sta ML+3
    lda #2
    sta ML+4
    lda #0
    sta ML+5
    lda #32
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT
    lda #152                  ; exactly fits eight pixels at right edge
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT+1
    inc ML+6                  ; reject visible overrun before writing
    jsr maria_append
    lda ML+8
    sta RESULT+2

Widths are 1..31 bytes; palette is 0..7; x is 0..159. A row must fit visibly: x+4*width<=160. Its source bytes must stay within one page: low-address+width<=256, including an exact end at the boundary. This API rejects rather than clips or wraps. Full capacity also rejects. Every check precedes any list write; rejection preserves the list and count.

ML+0..6 survive. The routine owns count +7, status +8 and scratch +9. Begin again when changing the list pointer. A/X/Y/flags are clobbered; clear decimal mode and reserve two stack bytes per direct call, plus caller/interrupt depth. Keep the context in valid zero-page RAM and out of 6532 RAM. Do not share a live context between interrupts and foreground code. The helpers change no interrupt, video, bank or DMA state.

Own the zone and the data

A direct header has four bytes: graphics low address, palette/negative width, graphics high address, horizontal position. Width two encodes as 30 in the low five bits. In 160A, four 2-bit pixels fit in a byte. Zero is transparent; a palette supplies three nonzero colours.

The caller supplies a 48-byte DLL at $1800, a populated list at $1900 and an empty list at $1920. Its sixteen zones each use OFFSET=15: sixteen rasters. Only zone 3 selects the populated list. MARIA adds the descending OFFSET to the graphics address’s high byte, so row zero lives at base+15*256 and row fifteen at base. Adjacent visible rows are a page apart in memory.

This simple fixture spends 16 pages to hold one eight-pixel-wide shape with 32 useful bytes. Several shapes can share the unused columns. The original A uses only colour 1 and transparent zero; the selected palette also initialises colours 2 and 3. The builder supports all eight palette numbers, but it does not choose or initialise your colours.

The complete caller disables DMA, copies the DLL into RAM, builds the lists, sets palettes and both nonadjacent display-pointer registers, waits for VBlank, then enables normal 160A DMA. The lists stay immutable afterwards. This is an original 48 KiB reset cartridge, without commercial BIOS authentication. It does not demonstrate that boot path.

For animation, build an inactive list/DLL and publish it during VBlank, coordinating both pointer writes and interrupts. That publisher is outside this component and has not been verified here. Writing a header while MARIA fetches it risks displaying an unfinished object. The DLL, zones, palettes, DMA mode, graphics lifetime and bank state need one coordinated owner.

Count work, not just objects

The builder is 121 code bytes. Context takes 10 bytes; four slots take 18 RAM bytes; the DLL takes 48 and the empty list two. Each append writes four header bytes and two terminator bytes. The populated list occupies ten bytes for two objects and its terminator.

Each populated raster fetches two four-byte headers, a two-byte terminator and four graphics bytes. That repeats for sixteen rasters. Empty zones still have DMA setup, termination and shutdown work. Four is this caller’s allocation capacity, not a hardware object limit.

Atari’s 7800 Software Guide, Appendix 3, uses 7.16 MHz cycles: eight per four-byte header and three per direct graphics byte, plus startup and shutdown. Startup ranges from 5–9; shutdown from 4–7, or 10–13 at a zone end. These clock and accounting boundaries differ from CPU instruction timing and other MARIA specifications. The retained measurement is list/data workload, not an exact elapsed-DMA benchmark. Wider rows, indirect data and more headers cost more time even when they fit in RAM.

Connect the coordinates

For local x, byte index is floor(x/4) and the shift is 6−2*(x mod 4). At object x32, local pixel x3 appears at logical x35. The native capture doubles horizontal pixels and starts the active area at capture x27: the first object’s left edge is x91. Its selected zone appears at capture y50. These observed viewport offsets belong to the capture, not the reusable game API.

Place a point connects local, object and screen coordinates. Move a glyph connects row packing with stored shapes. The page-stepped rows add another useful distinction: a picture’s visible geometry and its storage stride can be very different.

Indirect character maps, 320-pixel modes, holey DMA, arbitrary vertical movement, priority composition and split objects deserve separate contracts. A display-list builder alone is not a framebuffer or a general line/circle/text renderer. The 2600 playfield kernel shows why even closely related consoles need different reusable components.

Read the builder and complete DMA setup caller

Reusable bounded direct-header builder

objects.inc
!if ML_CAPACITY < 1 { !error "ML_CAPACITY must be 1..31" }
!if ML_CAPACITY > 31 { !error "ML_CAPACITY must be 1..31" }
; ML+0/1 -> caller-owned page-aligned list, 4*ML_CAPACITY+2 bytes.
; ML+2/3 -> graphic, +4 width bytes (1..31), +5 palette (0..7),
; +6 x (160A pixels); +7 count, +8 accepted, +9 scratch.
; Require ML_CAPACITY in 1..31; no clipping/wrapping, IRQ/DMA remain caller-owned.
maria_begin:
    lda #0
    sta ML+7
    ldy #0
    sta (ML),y
    iny
    sta (ML),y
    lda #1
    sta ML+8
    rts
maria_append:
    lda #0
    sta ML+8
    lda ML+7
    cmp #ML_CAPACITY
    bcs maria_return
    lda ML+4
    beq maria_return
    cmp #32
    bcs maria_return
    lda ML+5
    cmp #8
    bcs maria_return
    lda ML+6
    cmp #160
    bcs maria_return
    lda ML+4
    asl
    asl
    clc
    adc ML+6
    bcs maria_return
    cmp #161
    bcs maria_return
    lda ML+2
    clc
    adc ML+4
    bcc maria_page_ok
    beq maria_page_ok          ; ending exactly at page boundary is valid
    bcs maria_return
maria_page_ok:
    lda ML+7
    asl
    asl
    tay
    lda ML+2
    sta (ML),y
    iny
    lda ML+5
    asl
    asl
    asl
    asl
    asl
    sta ML+9
    lda #32
    sec
    sbc ML+4
    ora ML+9
    sta (ML),y
    iny
    lda ML+3
    sta (ML),y
    iny
    lda ML+6
    sta (ML),y
    iny
    lda #0
    sta (ML),y
    iny
    sta (ML),y
    inc ML+7
    inc ML+8
maria_return:
    rts
maria_code_end:

Complete RAM lists, DLL, palettes and original graphics

demo.asm
ML = $80
ML_CAPACITY = 4
LIST = $1900
RESULT = $1b00
DLL = $1800
EMPTY = $1920
* = $4000
start:
    sei
    cld
    ldx #$ff
    txs
    lda #7
    sta $01
    lda #0
    sta $3c
    sta $20                   ; background
    lda #14
    sta $21                   ; palette 0 colour 1
    lda #6
    sta $22
    lda #10
    sta $23
    lda #<LIST
    sta ML
    lda #>LIST
    sta ML+1
    jsr maria_begin
    lda #<graphics
    sta ML+2
    lda #>graphics
    sta ML+3
    lda #2
    sta ML+4
    lda #0
    sta ML+5
    lda #32
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT
    lda #152                  ; exactly fits eight pixels at right edge
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT+1
    inc ML+6                  ; reject visible overrun before writing
    jsr maria_append
    lda ML+8
    sta RESULT+2
    lda #$a5
    sta LIST+18
    lda #0
    sta EMPTY
    sta EMPTY+1
    ldx #47
copy_dll:
    lda dll_template,x
    sta DLL,x
    dex
    bpl copy_dll
    lda #>DLL
    sta $2c
    lda #<DLL
    sta $30
ready:
    bit $28
    bpl ready
    lda #$40
    sta $3c
running:
    jmp running               ; immutable lists: CPU has no raster work
irq:
    rti
!source "objects.inc"
    !align 255,0
dll_template:
    !for zone,0,15 {
        !if zone = 3 { !byte $0f,>LIST,<LIST } else { !byte $0f,>EMPTY,<EMPTY }
    }
    !align 255,0
graphics:
; Original glyph, row zero at base + 15*256; pixel zero is transparent.
    !byte $00,$00
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $15,$50
    !fill 254,0
    !byte $15,$50
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $10,$10
    !fill 254,0
    !byte $05,$40
    !fill 254,0
    !byte $05,$40
    !fill 254,0
    !byte $00,$00
    !fill 254,0
    !fill $fffa-*, $ff
    !word irq,start,irq

Make it your own

Use the same builder to place two original ships. The customisation caller supplies a wider picture, moves both objects and gives the second a blue palette. It still builds the lists once with DMA off and enables them during VBlank. The normal example above and its reusable include are unchanged.

Two twelve-pixel-wide ship shapes, one pale and one blue, placed at different positions in the MARIA display.
Verified native direct 160A customisation. Both objects share the same original graphics bytes; their headers choose different positions and palettes.

Supply a wider picture and move it

Keep customise/ beside objects.inc. Run make there and load build/ships.bin. ships.asm includes the parent builder and the original data in ship.inc. Copy those allocations and setup responsibilities when adapting the caller to your own project.

The width changes from two bytes to three: 8 to 12 logical pixels. The new positions are x24 and x100. Palette 0 draws the first ship; palette 1 draws the second. ML+4 and the graphics pointer remain set for the second call, so changing ML+5/ML+6 reuses the same shape.

Change width, position and palette through the existing inputs

ships.asm, excerpt
; BEGIN CUSTOM OBJECTS
    lda #3                    ; 12 pixels: three bytes in 160A
    sta ML+4
    lda #0
    sta ML+5
    lda #24
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT
    lda #1
    sta ML+5                 ; same shape, different palette
    lda #100
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT+1

Check the whole width when moving an object: x+4*width<=160. The original x152 fits an eight-pixel picture, but cannot fit twelve pixels. At width three, the last accepted start is x148. This API rejects an overrun; it does not clip your ship for you.

To replace the ship, pack four 2-bit pixels into each byte. Zero is transparent; 1, 2 and 3 choose palette colours. Three bytes describe a twelve-pixel row. With the existing sixteen-raster zone, store rows 256 bytes apart, bottom row first in ascending memory. Each useful three-byte row is followed by 253 padding bytes. For example, row 000001100000 packs as $00,$14,$00. Keep the source row within its page and every zone-offset address readable under the selected bank mapping.

Initialise the colours you select

Choosing palette 1 in a header does not populate it. This setup supplies its three nonzero colours before enabling DMA. Palette 0 retains $0E/$06/$0A; palette 1 uses $86/$8C/$8E. The same pixel indices now produce a blue ship in the selected NTSC emulator. Transparent pixels still show the background.

Initialise palette 1 before publishing the picture

ships.asm, excerpt
; BEGIN CUSTOM PALETTES
    lda #$86
    sta $25                   ; palette 1 colour 1
    lda #$8c
    sta $26                   ; palette 1 colour 2
    lda #$8e
    sta $27                   ; palette 1 colour 3

Choose your own colour values and check the native result in your target configuration. The capture verifies this emulator’s palette interpretation, not a calibrated physical television colour or every video standard.

Pay for width as well as object count

Resource or work Original caller Ship caller
Reusable builder + context 121 + 10 bytes Same
Four-slot list allocation 18 bytes Same
Two headers + terminator 10 bytes Same
Useful graphics across 16 rows 32 bytes 48 bytes
Page-stepped graphics span 4,096 bytes Same
Graphics bytes fetched per populated raster 4 6
Startup instruction span, start to ready 120 bytes 126 bytes

The two headers and terminator still take ten fetched bytes per populated raster. Graphics fetches increase by two, so that raster’s header/termination/graphics workload grows from 14 bytes to 16. The sixteen-raster zone repeats it. A wider picture can cost more DMA time even though the list size stays the same. These are byte/work counts; the startup span is code size, not an elapsed-time or worst-case-load measurement. Both cartridges remain 48 KiB.

Picture data, fitting positions, initialised palette choices and widths within the documented limits use the existing interface. More objects require both capacity and DMA planning. For example, raising capacity from four to eight changes its allocation from 4*4+2=18 to 4*8+2=34 bytes; that arithmetic does not verify that eight chosen objects fit a scanline. Moving smoothly between zones, switching video mode or banks, and replacing a live list need separate design and execution checks. This example does not add an animation publisher.

Read the original ship data and complete caller

Three useful bytes per page-stepped row

ship.inc
; Original 12x16 ship. Stored bottom row first for OFFSET 15..0.
    !byte $00,$00,$00  ; 000000000000
    !fill 253,0
    !byte $00,$00,$00  ; 000000000000
    !fill 253,0
    !byte $00,$00,$00  ; 000000000000
    !fill 253,0
    !byte $00,$3c,$00  ; 000003300000
    !fill 253,0
    !byte $00,$3c,$00  ; 000003300000
    !fill 253,0
    !byte $00,$ff,$00  ; 000033330000
    !fill 253,0
    !byte $00,$7d,$00  ; 000013310000
    !fill 253,0
    !byte $00,$55,$00  ; 000011110000
    !fill 253,0
    !byte $40,$55,$01  ; 100011110001
    !fill 253,0
    !byte $50,$55,$05  ; 110011110011
    !fill 253,0
    !byte $55,$aa,$55  ; 111122221111
    !fill 253,0
    !byte $15,$aa,$54  ; 011122221110
    !fill 253,0
    !byte $05,$aa,$50  ; 001122221100
    !fill 253,0
    !byte $01,$69,$40  ; 000112211000
    !fill 253,0
    !byte $00,$55,$00  ; 000011110000
    !fill 253,0
    !byte $00,$14,$00  ; 000001100000
    !fill 253,0

Complete customisation caller using the unchanged builder

ships.asm
ML = $80
ML_CAPACITY = 4
LIST = $1900
RESULT = $1b00
DLL = $1800
EMPTY = $1920
* = $4000
start:
    sei
    cld
    ldx #$ff
    txs
    lda #7
    sta $01
    lda #0
    sta $3c
    sta $20                   ; background
    lda #14
    sta $21                   ; palette 0 colour 1
    lda #6
    sta $22
    lda #10
    sta $23
; BEGIN CUSTOM PALETTES
    lda #$86
    sta $25                   ; palette 1 colour 1
    lda #$8c
    sta $26                   ; palette 1 colour 2
    lda #$8e
    sta $27                   ; palette 1 colour 3
; END CUSTOM PALETTES
    lda #<LIST
    sta ML
    lda #>LIST
    sta ML+1
    jsr maria_begin
    lda #<graphics
    sta ML+2
    lda #>graphics
    sta ML+3
; BEGIN CUSTOM OBJECTS
    lda #3                    ; 12 pixels: three bytes in 160A
    sta ML+4
    lda #0
    sta ML+5
    lda #24
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT
    lda #1
    sta ML+5                 ; same shape, different palette
    lda #100
    sta ML+6
    jsr maria_append
    lda ML+8
    sta RESULT+1
; END CUSTOM OBJECTS
    lda #$a5
    sta LIST+18
    lda #0
    sta EMPTY
    sta EMPTY+1
    ldx #47
copy_dll:
    lda dll_template,x
    sta DLL,x
    dex
    bpl copy_dll
    lda #>DLL
    sta $2c
    lda #<DLL
    sta $30
ready:
    bit $28
    bpl ready
    lda #$40
    sta $3c
running:
    jmp running               ; immutable lists: CPU has no raster work
irq:
    rti
!source "../objects.inc"
    !align 255,0
dll_template:
    !for zone,0,15 {
        !if zone = 3 { !byte $0f,>LIST,<LIST } else { !byte $0f,>EMPTY,<EMPTY }
    }
    !align 255,0
graphics:
!source "ship.inc"
    !fill $fffa-*, $ff
    !word irq,start,irq

The customisation was assembled and executed in the same released native emulator. Both headers, the terminator, every page-stepped source row, guards and the full native frame pass independent checks. The model checks the original ship shape, both placements and both palettes. The linked directory’s customise/README.md and retained capture explain reproduction and limits.

Common mistakes and their symptoms

These are deliberately broken versions of the bounded caller. Keep the working builder above. Every example reaches the normal running state; its full native image and relevant list bytes are checked against a separately predicted result. Each corrected fragment was also assembled and executed.

The right side of each letter disappears

Symptom: both A objects lose the part stored in their second graphics byte. Their positions and the surviving left sides remain correct.

Both MARIA glyphs are cropped after their first graphics byte, losing the right part of the A.
A one-byte fetch for graphics that needs two bytes. Native Emu198x NTSC direct 160A capture, using the original working glyph data.

Cause: the header stores a five-bit two’s-complement width, not a plain byte count. This wrong subtraction encodes a requested width of two as 31, which tells MARIA to fetch one byte. In 160A that fetch supplies only four logical pixels.

Deliberately wrong: use 33 instead of 32 before subtracting width

short-width.inc, excerpt
; BEGIN WRONG
    lda #33
    sec

Fix: restore 32 before the existing width subtraction and palette combination. A two-byte request then encodes as 30. Check the header and the picture: a too-wide fetch can be invisible when its extra source byte is zero, while still consuming DMA bandwidth.

Correct: encode the negative width in five bits

short-width.inc, excerpt
; BEGIN FIX
    lda #32
    sec

Two objects turn into one at the left edge

Symptom: the objects vanish from their requested x32 and x152 positions. One A appears at x0. Both headers now have the same position, so their identical pictures overlap.

One visible A sits at the far left of the MARIA active area because two identical objects overlap at x0.
Two objects still exist in the list, but both position bytes are zero. The native capture includes overscan; logical x0 does not mean capture x0.

Cause: the builder writes a constant into the header’s fourth byte instead of copying the caller’s position. Nothing is wrong with the stored glyph or its page stride. Counting visible pictures would miss that two headers are present.

Deliberately wrong: discard the requested horizontal position

wrong-position.inc, excerpt
; BEGIN WRONG
    lda #0
    sta (ML),y

Fix: load ML+6, which holds the caller’s logical 160A x coordinate, before writing the fourth header byte. Inspect both positions and the frame. Do not compensate by changing the graphics data or adding a capture-border offset to your game coordinates.

Correct: copy the caller's position into the header

wrong-position.inc, excerpt
; BEGIN FIX
    lda ML+6
    sta (ML),y

An unrequested third letter appears

Symptom: the two intended objects remain, and another A appears at logical x96. The software’s object count is still two.

MARIA displays an extra A between the two intended objects after a corrupted list terminator.
A deliberately bounded terminator error. Known bytes form the stray header, followed by a second valid terminator. No uncontrolled RAM walk is needed to reproduce the symptom.

Cause: MARIA follows list bytes, not ML+7’s software count. The second byte of the terminating pair must be zero. This diagnostic makes it 30, turning the pair into the start of another two-byte-wide object header. It also prepares that header’s address and position, and leaves a valid terminator immediately after it. Without a bounded fixture, subsequent behaviour would depend on whatever bytes followed the list.

Deliberately wrong diagnostic: replace the terminator with a stray header

bad-terminator.inc, excerpt
; BEGIN WRONG
    lda #>graphics
    sta LIST+10
    lda #96
    sta LIST+11
    lda #0
    sta LIST+12
    sta LIST+13
    lda #30
    sta LIST+9

This fragment is inserted after list construction and before the caller sets DPPH. It is a test fixture, not code to add to a game. The working builder already writes a new two-byte terminator after every append.

Fix: restore the zero at LIST+9, the second byte after this caller’s two four-byte headers. MARIA then stops before reading the planted header tail. In your own code, preserve the builder’s terminator and use its count to find the end; do not hard-code +9 for a list with a different number of objects. Rebuild or repair an inactive list with DMA off, or under a separately verified publication scheme.

Correct for the selected two-object diagnostic

bad-terminator.inc, excerpt
; BEGIN FIX
    lda #0
    sta LIST+9

What was verified

Asm198x 0.0.71 and released Emu198x 0.41.0 execute this NTSC direct 160A reset caller. Every native frame pixel and each populated header/terminator byte are compared with independent expected results. Seven rejected native calls preserve the surrounding 256-byte list page, including full capacity and page overflow. Guards and completed CPU markers are checked. Completed wrong-width and wrong-position controls fail the same destination/image checks. The website’s pinned Asm198x 0.0.65 also assembles it.

All three corrected diagnostic fragments also reproduce the working frame. The native verifier checks each broken capture against its predicted symptom, then requires the repaired path to pass the original destination and timing checks.

This does not establish stock BIOS loading, original-hardware timing, live publication, every palette/mode or a maximum-load benchmark.