Skip to content

Kick off the project and display background image (Part 1)

phelcmanovsky edited this page May 31, 2020 · 3 revisions

Let's go!

We want to create ZX Spectrum Next game, right? But where do you start...

Luckily it doesn't matter too much, the important step is to start. At the beginning of this tutorial project, I was provided with background BMP image, already in suitable size 256x192 pixels in 8bit-indexed colour mode (256 colours palette). So I decided to start by displaying the background image. But you can just as well start by code, displaying some debug value/pattern as graphics placeholder. Just let's go!

Output format decision

One somewhat important point is to decide what kind of output you want to use. There are multiple classic ZX Spectrum file formats and there's de facto new ZX Next-specific NEX file. The NEX file format is suitable when you want to completely take over the machine and deal with almost everything yourself. It gets a bit more cumbersome when you want to partially use ROM routines, or more complex NextZXOS system services, which require preserved state of the NextBASIC and NextZXOS, then you have to design your NEX file carefully to avoid common places where system keeps its state. There are still several system services available even when NextBASIC system variables are destroyed, for example the esxdos-like API for reading/writing files, but it's important to remember the choice is limited. And if you want to create small tool usable from NextZXOS command line or file browser, returning back to OS nicely, there's esxdos DOT command file format, suitable for these kind of tasks.

The SpecBong is planned to be self-contained independent low level code, without any need of system services, so it will just mindlessly use everything what it needs, not caring about previous state of the machine (doing it the "old way" just like almost every ZX Spectrum game), so initial decision time:

  • the output will be NEX file
  • all the main code, data and stack will be stored in 16ki Bank 2 (which is by default mapped both in the sjasmplus assembler and the Next machine at address range $8000..$BFFF)
  • the background image will be stored in 16ki banks 9, 10 and 11, which will be directly used by HW "Layer2" feature to display the image, and the PC-like 24bit palette data will follow in bank 12 (this is not directly usable by Next HW and will have to be processed by the code to set up Next palette)
  • later the sprites data will probably follow in banks after the palette data and that's all what is expected to be in the final file.

It's good to keep overview where your data and code is located and how you map your memory at runtime, and think about it slightly ahead of time (although there's always an option to reorganize your project, when you get stuck with current layout). Your decision may affect how easy it will be to call ROM routines, or have interrupt handler enabled and where you can temporarily map image/palette/sprite data if you want to process them. For example forget to set up the stack pointer to designed place in memory (and let it stay for example in some top memory area), map there palette data for processing, then wonder why your subroutine does crash upon ret (because the stack data are not visible to the CPU any more, switched by the new memory mapping for palette data). So having some idea how you use the available memory space is important, stay in control of the machine all the time.

Memory layout decisions

All the code, variables data and stack area will be located in the address range $8000..$BFFF (because we decided so, because it makes initial setup easy, let me explain...). The NEX-loader of ZXNextOS will pass the control after loading the file to the defined entry point, with the memory mapped (in terms of 16kiB ZX128 banks) as: ROM, 5, 2, 0 (the last bank 0 can be actually custom-specified in the NEX file header, but SpecBong will use default Bank 0 mapping). I.e. ROM starts at address $0000, Bank 5 starts at $4000, Bank 2 starts at $8000 and Bank 0 starts at address $C000. So our initial address range for "everything" fits into "Bank 2".

Some code please, show me your code!

(here is a good spot to open the SpecBong.asm file and try to match the source code lines with this text)

(also try to assemble the part 1 version of SpecBong.asm, resolving any issues with your local setup, until you can successfully assemble the code and run the NEX file in emulator or at real HW)

Now depending which assembler and NEX file creator tools you are using, you have to enforce that decision on the source-code side. With sjasmplus we will use virtual-device directive DEVICE ZXSPECTRUMNEXT to have virtual 1.75MiB memory area for assembling machine code, and then SAVENEX directive to create NEX file as snapshot of the "current" state of the virtual device. The sjasmplus' ZXSPECTRUMNEXT device does start with default memory mapping (16ki banks): Bank 7, Bank 5, Bank 2 and Bank 0. As you can see, this perfectly overlaps with the NEX loader defaults, except the ROM area (assembling code to address $0000 will modify Bank 7 content).

Now the assembler-classic ORG $8000 directive will redirect following machine code output to the default-mapped "Bank 2", precisely where we decided to land all our code (how to target different area of the virtual memory will be explained at background image inclusion).

There's few more lines in the source at beginning using OPT directive to reconfiguring sjasmplus syntax sub-rules to my liking and enabling the CSpect emulator specific opcode break.

The game will have entry-point right at the $8000 address, tagging it with label start: (very eccentric choice).

And the first three instructions are break, nop and nop. This will allow us to break into the debugger right at the start of the game, when you launch the CSpect emulator with -brk option. With recent versions of CSpect (after the tutorial was released) you can also use -debug option to start right inside the debugger, making this three-instructions-line useless. Why 2x nop after break? The break instruction is CSpect emulator specific, the regular HW Z80 CPU does not understand it, and it will parse it as four-byte type of slightly malformed ld bc,<16bit constant>. The two nop instructions provide the missing 16bit constant in such case, making the code compatible with both HW Next (will see ld bc,0 instruction) and CSpect emulator (will see break, nop, nop instructions).

Init the machine to desired state

Then the initial setup of machine follows, disabling interrupts (SpecBong will not use interrupts at all, to not introduce the "parallelism" complexity into thinking about how the project works, everything will operate as "single thread code"), and setting up Layer2 mode 256x192, making sprites visible and ordering layers as "SLU" (Sprites above Layer2 above classic ZX ULA graphics). The Layer2 is set to display memory content starting at 16ki "Bank 9" (using following banks 10 and 11 for the rest of the pixel data) (this is same as default for NextBASIC Layer2), and scrolling offsets are zeroed. It's good practice to set up the machine state quite explicitly. Even if the NEXLOAD does reset most of the machine to known state, if you are loading the NEX file directly by emulator without full NextZXOS running, the machine can be in less initialized state with some registers sporting unexpected values. I would add also Layer2 clip-window reset, but it somehow slipped my mind while I was programming the Part 1 version (try for fun to add clip-window initialization and make the background image visible only partially, look for next registers $1C and $18).

Setting up background image palette

After the initial machine setup, we need to set Layer2 palette from the image data. The image data will be available (trust me for the moment) in memory in 16ki bank 12 (right after the pixel data), and they will be in the form of byte triplets, containing values for Blue, Green and Red channel. The values are in 0..255 range (8 bits per channel), i.e. PC "true color" 24 bit format, allowing for 16+ millions of distinct colours.

The Next HW can't emit colours with this level of fidelity, it has only 3 bits per colour channel, i.e. values range 0..7, and that means there are 512 distinct colours in total colour-space, which Next can generate into video signal. That is further limited down by using only 256-element palettes, selecting some of the 512 available colours to be displayed simultaneously by particular layer.

The palette conversion code starts with nextreg $57,$$BackGroundPalette instruction, which will re-map memory region at addresses $E000..$FFFF with 8ki page where the background palette data are stored (it's 8ki page number 24, i.e. beginning of "Bank 12" in 16ki banks numbering scheme, but I'm avoiding "magic numbers" in the source code by using sjasmplus $$<label> operator to get 8ki page number out of the label (the memory range $E000..$FFFF is selected by the register $57 and it was chosen as it does not interfere with our code, variables and stack area).

Then it sets the palette registers to be prepared for Layer2 palette definition, and starts the conversion.

The conversion loop code does read the PC 24bit colour definitions (as they are stored in the legacy TGA "targa" image file format, uncompressed triplets of B,G,R bytes), throws away the bottom 5 bits of each of the channels value, and concatenates the remaining top three bits into two bytes in the form "RRRGGGBB" and "0000000B" (colour channel bits positions). For example RGB colour #401020 will be converted to two bytes $40, $01 (channel values R=2, G=0, B=1) in the form, which is accepted by NextReg $44. This palette conversion thus throws away serious amount of colour information, and the original graphics should be already converted to Next-friendly palette, to get similar visual results. The two bytes of the 9-bit colour definition are then written to NextReg $44 to set up Layer2 palette.

And then infinite loop happens

Wait, what? jr $ is "jump to self", right? So the code will never escape this any more (until reset button is pressed). But when you run the resulting NEX file in the emulator or machine, the background image is displayed. Where is that "display image" part hidden?

At the beginning the machine was initialized to display Layer2 image from bank 9, 10 and 11, and that's what the machine is doing. We did also explicitly set up Layer2 palette in code. The remaining part of this mystery is related to the NEX file features. The NEXLOAD dot command loads from NEX file all banks which are specified in the file, into the correct bank of physical memory. The SpecBong.nex file contains the "Bank 2" with the code, and also banks 9 to 12, containing the pixel data and palette data. Because the pixel data are already in the correct format (8bit indices into layer2 palette), and placed in the correct banks, the HW is instantly displaying the background image, without any more code beyond the initial setup of NextRegs $12, $15, $69 and $70.

There are few more lines of code reserving stack-area in the address range $B800..$BFFF, filling it all with value $AA (for no particular reason except that the value is easy to spot in debugger in memory viewer, so you can easily check at runtime how much of stack area was used by code so far - overwriting the $AA values by actual values). Note the label initialStackTop points to the last two bytes of the area. That is because instructions using the stack like call, ret, push, pop make it grow downward, from initial address $BFFE toward $B800 (and back, when you are pop-ping the values out of the stack).

How the pixel data are included

The NEX file is constructed by SAVENEX directive, which is capable to snapshot current state of the virtual device. So to have pixel data in 16ki "Bank 9" after loading the NEX file, we will include them into the virtual-device bank 9..11 (8ki pages 18, 19, .., 23) and let the SAVENEX dump them into the file.

First the MMU directive is used to remap address region $E000..$FFFF at assembly-time (!) to 8ki page 18. The 8ki page 18 is the beginning of the 16ki bank 9. There is also special flag of MMU directive "n" telling the assembler to map consecutive pages to the same address region whenever end of it is reached, wrapping around again and again. Then ORG $E000 sets the machine code output to the beginning of this area.

And finally full 48kiB of pixel data are included from the SpecBong.tga file (the offset 0x12 + 3*256 into file is calculated based on the internal structure of TGA file - this will overflow the tiny 8ki region $E000..$FFFF multiple times, but the assembler will just wrap around back to address $E000 and map the following page (19, 20, ...), so the result is 48kiB of pixel data included in the virtual banks 9..11, ready to be snapshotted by SAVENEX directive (and ready to be displayed by Next after they are loaded by NEX file loader).

If you will check the TGA image, you will notice it is upside-down picture of the background. The upside-down version is used, because the TGA file is designed with lines of image stored in the file from bottom to top. By using upside-down image the raw pixel data in TGA are then organized exactly how the Layer2 HW is displaying them (from top to bottom), and simple INCBIN is enough to prepare them, without further processing.

After the pixel data the palette data are included (landing into "bank 12"), marking the position with label BackGroundPalette, which is used by the palette conversion code. This label lands at address $E000, and that's where the palette conversion code will map the bank 12 by using nextreg $57, matching the both by design.

You can use MMU + ORG directives to position the machine code at the place where you want it to be at runtime - but in the extended Next 1.75MiB memory pages, produce the machine code (and data), and then at runtime set up the mapping in the same way, making the machine code appear at expected destination.

Creating the final NEX file

After this the assembler virtual device contains all the data (code + stack area in "bank 2", background-image pixels in "banks 9..11" and 24bit palette data in "bank 12".

The directive SAVENEX is then used to create the NEX file with these - it is not important what is the current memory mapping, the SAVENEX will reach for the data directly into the 1.75MiB memory block, ignoring current address-space mapping.

The labels start and initialStackTop are used to tell the NEXLOAD loader where is the entry point, and where the stack pointer should be set. The SAVENEX AUTO directive is then letting the assembler scan full device memory for any non-zero value, and dump every such 16ki bank to the NEX file automatically (why 16ki banks are stored in the NEX file? That's by the design of NEX files themselves). Note if you want to include block of 16kiB zeroes in the NEX file, you must save such block explicitly by SAVENEX BANK directive, the AUTO will skip any bank which contains only zeroes.

And that's all, by assembling the source you will obtain the NEX file, and by loading it in emulator or in Next computer, you will see the background image displayed in the converted palette. If you will enter the debugger at that moment, you will see the code is in infinite loop executing the jr $ instruction, after all that setup code was quickly executed.


How the video output of Next looks right after the init code sequence, before running the palette conversion loop (note the pixel data are already being displayed, but in the default palette):

SpecBong after init code

And final image being produced by Next after the palette data conversion code is executed and the infinite spin-loop of "doing nothing" starts:

SpecBong part 1 ran


† the $57 NextReg affects runtime mapping, when the code is actually running in the machine. The assembler directives ORG, MMU, ... affect assembling-time mapping and memory position. When you switch target memory at assembly time, it does not in any way affect the runtime code and vice versa, the nextreg $57,x instruction does not affect assembly-time memory mapping (the region $C000..$FFFF remains mapped as "Bank 0" at assembly time even after this instruction was assembled, but the instruction is later executed at runtime in the machine, remapping page "x" into region $E000..$FFFF).