Skip to content

Query a Byte Map on SVI-328

Read and change map cells, look up tile properties and convert world positions with reusable Z80 code and an explicit memory contract.

A level does not need to be on screen for the game to ask what is there. Give it a byte per cell, keep those bytes in row order, and the same map can answer questions about walls, hazards, goals or scenery that has changed.

This implementation supplies four routines: checked get, checked set, property lookup and conversion from unsigned world positions to eight-unit cells. It does not draw or wait for a frame. The shared entry explains the arrangement and links the implementations across all 30 machines.

Include and initialise

Define TM as the start of a reserved 20-byte context, then include the routine. The map contains W×H consecutive bytes, with width and height each from 1 to 255. Its property table contains one byte per possible ID, through the configured highest ID. Both ranges must remain addressable without crossing an address-space end, bank window or unmapped boundary. Use writable RAM for tile_set; a ROM map needs another way to record changes.

The Z80 context can live in any reserved RAM block. Pointers and 16-bit world positions use little-endian storage.

Context offsets: 0–1 map pointer; 2/3 width/height; 4/5 column/row; 6 tile; 7 valid; 8–10 scratch; 11 property; 12–13 property pointer; 14 highest tile ID; 15 scratch; 16–17 X; 18–19 Y. Initialise the pointers, dimensions and highest ID before a call. Scratch bytes belong to the routine.

Initialise the diagnostic map and make its first checked query

demo.asm, excerpt
start:
    di
    ld sp,$fff0
    ld hl,TM
    ld b,20
    xor a
clear_context:
    ld (hl),a
    inc hl
    dec b
    jr nz,clear_context
    ld hl,initial_map
    ld de,MAP
    ld b,35
copy_map:
    ld a,(hl)
    ld (de),a
    inc hl
    inc de
    dec b
    jr nz,copy_map
    ld a,$a5
    ld (MAP+35),a
    ld a,240
    ld (TM+0),a
    ld a,192
    ld (TM+1),a
    ld a,7
    ld (TM+2),a
    ld a,5
    ld (TM+3),a
    ld a,3
    ld (TM+14),a
    ld hl,properties
    ld a,l
    ld (TM+12),a
    ld a,h
    ld (TM+13),a
    ld a,0
    ld (TM+4),a
    ld a,0
    ld (TM+5),a
    call tile_get
    ld a,(TM+7)
    cp 1
    jr z,valid_0
    ld a,255
    ld (TM+6),a

The SVI-328 fixture replaces its 32 KiB BASIC/OS ROM and uses the default NTSC configuration with no cartridge. Context $C000, results $C040, map $C0F0 and stack top $FFF0 occupy upper RAM. The SVI is not an MSX: it has its own banking and I/O arrangement, so there is no MSX $A8 slot-selection write here. A game changing memory banks must keep the routine, context and map visible together. Video RAM is a separate VDP address space and is not exercised by these queries.

Hardware reference: Spectravideo SVI-318/328 Service & Technical Manual, system description and expander memory-bank signals.

Call the routine

Set column and row, call tile_get, then check the valid byte. A successful call returns the tile ID. Failure returns valid=0 and tile=0, so tile zero alone does not mean success. This compact variant uses unsigned byte coordinates; wider maps can use a wider-coordinate variant rather than treating this as a machine limit. Translate a signed or wider world into this representation explicitly.

For a write, set column/row/tile and call tile_set. Invalid coordinates or an ID above the configured maximum leave the map unchanged. To classify a tile, call tile_property; it returns the table byte and a separate valid flag. Your game decides whether those property bits mean blocking, damage, a goal or something else.

Reusable Z80 map/query implementation

z80.inc
; Byte map, caller-owned TM..TM+19 context. AF/BC/DE/HL clobbered.
; Only instructions shared with SM83; the GB file uses its source syntax.
tile_locate:
    xor a
    ld (TM+7),a
    ld a,(TM+2)
    ld b,a
    ld a,(TM+4)
    cp b
    ret nc
    ld a,(TM+3)
    ld b,a
    ld a,(TM+5)
    cp b
    ret nc
    ld b,a
    ld a,(TM)
    ld l,a
    ld a,(TM+1)
    ld h,a
    ld a,(TM+2)
    ld e,a
    ld d,0
    ld a,b
    or a
    jr z,tile_column
tile_rows:
    add hl,de
    dec b
    jr nz,tile_rows
tile_column:
    ld a,(TM+4)
    ld e,a
    add hl,de
    ld a,l
    ld (TM+8),a
    ld a,h
    ld (TM+9),a
    ld a,1
    ld (TM+7),a
    ret

tile_get:
    call tile_locate
    ld a,(TM+7)
    or a
    jr z,tile_get_invalid
    ld a,(TM+8)
    ld l,a
    ld a,(TM+9)
    ld h,a
    ld a,(hl)
    ld (TM+6),a
    ret
tile_get_invalid:
    xor a
    ld (TM+6),a
    ret

tile_set:
    xor a
    ld (TM+7),a
    ld a,(TM+14)
    ld b,a
    ld a,(TM+6)
    cp b
    jr c,tile_set_id_ok
    jr z,tile_set_id_ok
    ret
tile_set_id_ok:
    call tile_locate
    ld a,(TM+7)
    or a
    ret z
    ld a,(TM+8)
    ld l,a
    ld a,(TM+9)
    ld h,a
    ld a,(TM+6)
    ld (hl),a
    ret

tile_property:
    xor a
    ld (TM+7),a
    ld (TM+11),a
    ld a,(TM+14)
    ld b,a
    ld a,(TM+6)
    cp b
    jr c,tile_property_ok
    jr z,tile_property_ok
    ret
tile_property_ok:
    ld e,a
    ld d,0
    ld a,(TM+12)
    ld l,a
    ld a,(TM+13)
    ld h,a
    add hl,de
    ld a,(hl)
    ld (TM+11),a
    ld a,1
    ld (TM+7),a
    ret

tile_pixel8:
    xor a
    ld (TM+7),a
    ld (TM+6),a
    ld a,(TM+16)
    ld l,a
    ld a,(TM+17)
    ld h,a
    srl h
    rr l
    srl h
    rr l
    srl h
    rr l
    ld a,h
    or a
    ret nz
    ld a,l
    ld (TM+4),a
    ld a,(TM+18)
    ld l,a
    ld a,(TM+19)
    ld h,a
    srl h
    rr l
    srl h
    rr l
    srl h
    rr l
    ld a,h
    or a
    ret nz
    ld a,l
    ld (TM+5),a
    jp tile_get

For tile_pixel8, set unsigned 16-bit X and Y in world coordinates. It divides by eight, rejects a cell coordinate too large for a byte, then performs the checked lookup. Read cell/tile outputs only after checking valid. X/Y inputs remain unchanged. Eight-unit square cells are this routine’s deliberate fast conversion; other sizes, wrapping or a signed origin need a corresponding variant.

State, stack and cost

AF, BC, DE and HL are clobbered. The library uses the supplied context and current stack, preserves IX/IY and does not mask or install interrupts. Get/set use 4 bytes of return addresses at their deepest point, including the direct caller’s call into the library. Pixel conversion also uses 4 because its tail jump reuses the outer return address; property lookup uses 2. Add the surrounding caller stack and any interrupt frames. An interrupt caller needs its own context and must preserve the state the interrupted program needs.

Map storage is W×H bytes, plus highest-ID+1 property bytes, context, code and stack. This readable version adds the row width repeatedly; later rows cost more work. Row-pointer tables or fixed-width shifts are alternatives when query speed matters. There is no whole-game frame-rate claim.

Make it your own

Close an exit by changing its property byte. In this caller, bit 0 (value 1) means blocking, bit 1 (2) means hazard and bit 2 (4) means exit. These meanings belong to the example, not to the library. Tile 3 starts as an open exit:

Before: tile 3 has the exit flag

properties.inc
; floor, blocking wall, hazard, open exit
    db 0,1,2,4

The maintained customisation keeps the exit flag and adds blocking, so 4 becomes 5:

After: tile 3 is a closed exit

customise-properties.inc
; Same exit, now blocking too: 4 OR 1 = 5
    db 0,1,2,5

Use the second table in place of the first in your own caller. make VARIANT=closed-exit ASM198X=/path/to/asm198x builds that variant of this diagnostic. The checked read still returns tile 3, while tile_property now returns 5 instead of 4. Your collision rules must interpret the blocking bit; this routine only supplies the classification.

Both versions ran on this machine. All other 43 result bytes match, and their complete ROM fixtures differ in exactly one data byte. The property table stays four bytes; the code and scratch-memory requirements stay the same. This edit changes data at build time. To change the table during play, put it in writable RAM and initialise its pointer accordingly.

Change dimensions, map data and property meanings together. A solid wall might have property bit 0 set, a hazard bit 1; a tile can carry both. Keep classification separate from artwork so recolouring or changing a character does not change the rules.

The offset is row × width + column. A 7×5 room exposes width/height swaps, and the fixture deliberately crosses a 256-byte boundary to exercise pointer carry. World-to-cell conversion divides by eight and rounds down: world X=23 belongs to column 2. It is a direct use of the coordinate and representation ideas in Maths for Games. Logical cells need not be eight physical screen pixels; choose and document your units.

For a busy game, row-pointer tables avoid repeated additions at the cost of two bytes per row. Packed tile IDs save map RAM but need extra work to extract and update a cell. Larger or signed worlds need a corresponding coordinate variant. These are possible extensions, not timings measured for this caller.

When it goes wrong

Symptom Cause Fix
The first row works, later rows read the wrong tiles Address arithmetic uses stride 6 for a seven-wide map Add the map width, independently of the screen layout
A rejected edit corrupts a map cell or its neighbour The write ignores the failed coordinate check Retain the early return in tile_set and check valid in the caller
Empty floor behaves like an invalid position Tile zero is used as the success flag Read the separate valid byte

Both faulty stride and faulty write variants ran to completion in this machine’s emulator. At column 0, row 1, the stride fault returns tile 2 instead of 3. Removing the invalid-write guard changes the diagnostic’s unchanged-map/guard result from 1 to 0. The third row explains the interface contract; it is not an additional executed fault. The records contain actual RAM bytes rather than a screenshot of a display this test never draws.

A wrong stride, and its correction

Deliberately broken: this executed variant advances six bytes for each seven-cell row.

Deliberately broken stride

wrong-stride-z80.inc, excerpt
    ld a,(TM+1)
    ld h,a
    ld a,6
    ld e,a

Use the configured map width, using a 16-bit addition for the address:

Correct stride

z80.inc, excerpt
    ld a,(TM+1)
    ld h,a
    ld a,(TM+2)
    ld e,a

A rejected write that still stores

Deliberately broken: this executed variant reaches the store even when tile_locate leaves valid at zero.

Deliberately broken write guard

write-without-valid-z80.inc, excerpt
tile_set_id_ok:
    call tile_locate
    ld a,(TM+7)
    or a
    ld a,(TM+8)
    ld l,a
    ld a,(TM+9)
    ld h,a
    ld a,(TM+6)
    ld (hl),a
    ret

Restore the early return. Rerun the normal caller: its unchanged-map/guard result must return to 1, and its completion byte must still reach 239.

Correct write guard

z80.inc, excerpt
tile_set_id_ok:
    call tile_locate
    ld a,(TM+7)
    or a
    ret z
    ld a,(TM+8)
    ld l,a
    ld a,(TM+9)
    ld h,a
    ld a,(TM+6)
    ld (hl),a
    ret

Run and adapt it

make ASM198X=/path/to/asm198x builds an original reset ROM fixture. The maintained verifier runs it in Emu198x, checks all 44 result bytes against explicit expected map rows, checks the closed-exit customisation, then builds and rejects the two faulty variants. The shared routine is unchanged from the other Z80 implementations.

Executed with Asm198x 0.0.71 and Emu198x 0.44.1. This qualifies the CPU code and declared RAM layout. It does not establish stock BASIC loading, display output, interrupt coexistence or a frame deadline. The diagnostic replaces firmware and takes over the machine; a game using firmware must reserve suitable memory and supply its own loading and interrupt policy.

The BASIC map entry solves the same problem with interpreter-managed arrays. Its loader, indexing and costs are qualified separately.