Simple8

Resources

A game is not just code: it is also images, fonts, maps, sounds, tables of values. This chapter shows how Simple8 brings them into the program, converts them to each machine's format, and stores them in memory.

What is a resource?

A resource is program data that is not code: a font, tiles, a palette, a level map, a sound, a precomputed table. It comes from a file in the project, or it is generated at compile time.

We have been using one since chapter 2: the PC Engine font.

const int POLICE = addResource("font.bin");

addResource does three things:

  1. at compile time, it reads the file (or generates the data);
  2. it stores the data in the cartridge, along with the program;
  3. it gives an identifier, here POLICE (font), which the program uses to find this data while it runs.

To transform the data along the way, you go through a ResourceFactory, a resource “factory”: it receives a file or parameters, and produces ready-to-use bytes.

const int VAGUE = addResource(ResourceFactory("TrigoMaker",
    "function=sin&count=32&amplitude=10&offset=12"));

The first argument is the name of the factory, the second its parameters, separated by &, as in a web address.

Three kinds of resources

Resources differ in how they relate to the machine: some are the same everywhere, others adapt to each machine, and others exist for only one.

1. Generic resources

These are pure data, which depend on no hardware: every machine can use them as they are. A sine table is the typical example: 32 numbers between 2 and 22, whether for a C64 or a PC Engine.

FactoryWhat it produces
RawBinarya file, as is. This is what addResource("fichier") does without a factory.
TrigoMakera sine or cosine table, computed at compile time
TrackerSonga song written in a text file, in the style of a tracker

The parameters of TrigoMaker describe the table:

ParameterRoleHere
functionsin or cossin
countthe number of values, for a full cycle32
amplitudethe maximum distance from the center10
offsetthe center12: the values go from 2 to 22

2. Multi-architecture resources

Here, the same source is converted to each machine's format. An image is not displayed the same way on a C64 and on a PC Engine: number of colors, pixel encoding, palettes, everything differs. The factory detects the project's architecture, the one on the architecture line, and runs the matching conversion.

FactorySourceWhat it produces
SquareDota project from the SquareDot editor (.sqd)tiles, palettes and maps, in the machine's format: 4 bits per pixel on PC Engine, 2 planes on NES and Game Boy, one byte per line on C64…
BuildPaletteFromImageFilean imagethe palette of its main colors, in the machine's format
const int TUILES = addResource(ResourceFactory("SquareDot", "niveaux.sqd&part=tiles"));
const int COULEURS = addResource(ResourceFactory("SquareDot", "niveaux.sqd&part=palettes"));
const int CARTE = addResource(ResourceFactory("SquareDot", "niveaux.sqd&part=map&map=Carte 1"));

These three lines stay the same from one machine to another: only the result changes. A conversion that does not exist yet for a machine is reported:

ResourceFactory("SpriteMotionGenerator", …): not implemented for sega.megadrive yet (available: nec.pce)

3. Machine-specific resources

Some resources only make sense for one machine, because they target a chip only that machine has:

FactoryMachineWhat it produces
PSGSamplePC Enginea digitized sound for the console's sound chip
VgmMega Drivea piece of music for the Z80 player
MixerSampleMega Drivea sound effect for the Z80 sound driver
ResourceFactory("PSGSample", …): for nec.pce only, not for commodore.c64

In short. Generic: the same bytes everywhere. Multi-architecture: one source, one format per machine. Specific: a single machine.

Not every machine has addResource

addResource stores its data in the cartridge of the PC Engine and the Mega Drive, or in the Amiga disk image. On the other machines, the compiler stops and points to the solution:

addResource: the resources are placed in the cartridge of the PC Engine (nec.pce) and of the
Mega Drive (sega.megadrive), or the Amiga image (commodore.amiga), not on commodore.c64;
put the data in a const array (generated by a script, in a unit)

So on C64, NES, Game Boy and Amstrad CPC, the data goes into constant arrays, in a unit. The tools of the Simple8 suite write them for you: for these machines, SquareDot exports a unit (.s8u) containing the tiles and maps, which the program adds with addunit. 3DSpriteMaker does the same for its sprites, on PC Engine and Game Boy.

// Carte.s8u, exported by SquareDot for the C64
unit Carte {
    const int W = 80;
    const int H = 25;
    const ubyte CHARS[56] = { … };      // the drawn characters
    …
}

The kind of the resource does not change: a sine table stays generic, whether it is produced by TrigoMaker or written in an array.

Reading a resource

On PC Engine, the processor only sees a small part of the cartridge at a time. So a resource cannot be read directly: you first have to make the bank that contains it visible. The sys.resource library takes care of this:

FunctionRole
resource.mapToMPR2(id)makes the resource's bank visible, in the $4000-$5FFF window
resource.getOffset(id)its position in the bank
resource.getLength(id)its size in bytes
resource.getBank(id)its bank number
resource.mapToMPR2(.VAGUE);                         // VAGUE's bank at $4000
uint debut = 0x4000 + resource.getOffset(.VAGUE);   // the address of its data
ubyte premiere = readMem(debut);                    // its first value: 12

On Mega Drive and Amiga, which are simpler, the identifier is directly the 32-bit address of the data: you store it in a ulong, and read with readMem.

Choose clear names. A resource's identifier becomes an assembler name. A name like A, X or Y is mistaken for a processor register, and assembly fails. Prefer POLICE, TUILES_NIVEAU1…

Memory layout: the HuCard

On a cartridge machine, how do you fit hundreds of kilobytes of resources into a memory the processor only sees in small pieces? Let's take the PC Engine.

8 KB banks

The HuCard is divided into banks of 8 KB. The compiler stores the resident code there, then the contexts, then the resources. One rule applies: a resource of 8 KB or less never straddles two banks, so that a single mapToMPR2 is enough to read all of it. A larger resource starts at the beginning of a bank and continues into the following ones; it is read bank by bank.

Index tables

To find each resource, the compiler writes three tables in ROM, indexed by the identifier: the bank of each resource, its position in the bank, and its size. getBank, getOffset, getLength and mapToMPR2 just read these tables.

This is what makes it possible to optimize space: since the program always finds a resource through its tables, the compiler is free to store it anywhere, without following the order of declarations. It takes advantage of this to fill the gaps:

  1. resources larger than 8 KB are stored first, each at the beginning of a bank;
  2. the others are taken from largest to smallest, and each one goes into the first bank with enough room left, including the end of a large resource;
  3. a new bank is opened only if none has room.
Five resources of 5,000, 2,000, 3,000, 1,000 and 12,000 bytes. Stored in declaration order, they take 4 banks; stored by filling the gaps, 3 banks In declaration order: 4 banks A · 5,000 B · 2,000 wasted bank 2 C · 3,000 D · 1,000 wasted bank 3 BIG · 12,000 bank 4 end of BIG wasted bank 5 Filling the gaps: 3 banks BIG · 12,000 bank 2 end of BIG C · 3,000 D · 1,000 bank 3 A · 5,000 B · 2,000 wasted bank 4
Five resources of 5,000, 2,000, 3,000, 1,000 and 12,000 bytes, declared in this order (A, B, C, D, BIG).

Here are the tables the compiler writes for this example, in the order of the identifiers A, B, C, D, BIG:

_resource_banks:            // the bank of each resource
    .db RESOURCE_FIRST_BANK+2, RESOURCE_FIRST_BANK+2, RESOURCE_FIRST_BANK+1,
        RESOURCE_FIRST_BANK+1, RESOURCE_FIRST_BANK+0
_resource_offsets:          // its position in the bank
    .dw $0000, $1388, $0EE0, $1A98, $0000

RESOURCE_FIRST_BANK is the first bank after the resident code and the contexts. For C, identifier 2, the tables give bank RESOURCE_FIRST_BANK+1 and position $0EE0 (3,808): right after the end of BIG. The program does not need to know anything about this layout: it calls mapToMPR2(.RES_C), as before.

Resources and contexts

On a cartridge machine

On PC Engine, a resource declared in a context unit goes into the same table as the others, after the context banks. It is read the same way, from any context.

unit Niveau {
    const int VAGUE = addResource(ResourceFactory("TrigoMaker", "function=sin&count=32&amplitude=10&offset=12"));

    initContext() {
        uint debut;
        resource.mapToMPR2(.VAGUE);
        debut = 0x4000 + resource.getOffset(.VAGUE);
        // copy to video memory, read values…
    }
    …
}

This works because the two windows are separate: the context code is visible from $6000, a resource's bank at $4000. So the context can read its data without hiding itself. And nothing is copied at load time: the data stays in ROM, where it is read when needed.

The right moment to copy a resource to video memory is initContext(): interrupts are masked there, so the copy is not interrupted (chapter 8).

On a disk machine

On C64 and Amstrad CPC, a context is a file loaded from disk. Its constant arrays are part of that file: they arrive in memory with it, and disappear when another context takes its place. So a level's data is best declared in that level's context: it only takes up memory while that level is being played.

Cartridge (PC Engine)Disk (C64, CPC)
A context's datastays in ROM, always availableis loaded with the context file
To read itmake its bank visible (mapToMPR2)directly: it is in memory
The limitthe size of the cartridgethe room for the context in memory

An example: a sine wave

This program draws a wave on the screen: one star per column, at the height given by a sine table. On PC Engine, the table and the font are two generic resources.

// Resources: a sine wave (PC Engine)
architecture nec.pce;

addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;

unit {
    // two generic resources: a file as is, and a table computed at compile time
    const int POLICE = addResource("font.bin");
    const int VAGUE = addResource(ResourceFactory("TrigoMaker",
        "function=sin&count=32&amplitude=10&offset=12"));

    main() {
        int source;
        int length;
        uint debut;

        // the font: made visible at $4000, then copied to video memory
        vdc.setBATSize(vdc.BAT_32x32);
        vdc.initScreen();
        resource.mapToMPR2(.POLICE);
        source = 0x4000 + resource.getOffset(.POLICE);
        length = resource.getLength(.POLICE);
        vdc.writeToVRAM(source, 0x1000, length);
        vce.setPalette(0, 0, RGB(0, 0, 2));
        vce.setPalette(0, 1, RGB(7, 7, 7));
        vce.setPalette(1, 1, RGB(2, 6, 7));
        vdc.clearScreen();

        vdc.setFontColor(0);
        vdc.setCursor(7, 1);
        vdc.print("UNE VAGUE DE SINUS");

        // the table: made visible in turn, then read entry by entry
        resource.mapToMPR2(.VAGUE);
        debut = 0x4000 + resource.getOffset(.VAGUE);
        vdc.setFontColor(1);
        for (ubyte x = 0; x < 32; x++) {
            vdc.setCursor(x, (ubyte)(readMem(debut + x) + 3));  // the height: entry x, below the title
            vdc.putChar('*');
        }
    }
}
PC Engine screen: UNE VAGUE DE SINUS, one period of a sine wave drawn with stars
The wave on PC Engine, taken from a TrigoMaker table.
The Commodore 64 version

The C64 does not have addResource: the same 32 values are stored in a constant array. The resource stays generic; only the way it enters the program changes.

// Resources: a sine wave (Commodore 64)
architecture commodore.c64;

addlib vic.base;
addlib vic.screentext;

unit {
    // no addResource on C64: the same 32 values, in a constant array
    const ubyte VAGUE[] = {
        12, 14, 16, 18, 19, 20, 21, 22, 22, 22, 21, 20, 19, 18, 16, 14,
        12, 10,  8,  6,  5,  4,  3,  2,  2,  2,  3,  4,  5,  6,  8, 10
    };

    main() {
        vic.initScreen();
        vic.setBorderColor(vic.BLACK);
        vic.setBackgroundColor(vic.BLUE);
        vic.clearScreen();

        vic.setFontColor(vic.WHITE);
        vic.setCursor(11, 0);
        vic.print("UNE VAGUE DE SINUS");

        vic.setFontColor(vic.CYAN);
        for (ubyte x = 0; x < 32; x++) {
            vic.setCursor((ubyte)(x + 4), .VAGUE[x]);   // the height: entry x of the array
            vic.putChar('*');
        }
    }
}
Commodore 64 screen: UNE VAGUE DE SINUS, the same wave drawn with stars
The same wave on Commodore 64.

Key points

Your turn

  1. Double the amplitude of the wave. Does it still fit on the screen? If not, what is the largest possible wave, and what else must change along with the amplitude?
    Show solutionHide solution

    Let's start by trying it: amplitude=20, with nothing else changed. The values should go from 12 − 20 = −8 to 12 + 20 = 32. Two problems show up on screen:

    • the table is made of unsigned bytes, which cannot go below 0. TrigoMaker replaces negative values with 0: the top of the wave is flattened into a long plateau (you can check this in asm/resources.asm, where the table contains nine $00 in a row);
    • the bottom, down to line 32 + 3 = 35, goes off the screen, which has only 28 visible lines.
    PC Engine: the wave with amplitude 20, with a flattened top and its bottom going off the screen
    Amplitude 20: the top is flattened, the bottom is off the screen.

    So you also have to move the center (offset) so that the smallest value stays positive. But that is not enough: a wave of amplitude 20 spans 2 × 20 + 1 = 41 lines, more than the 28 lines of the PC Engine and the 25 of the C64. A wave twice as tall, at one line per unit, cannot fit on the screen. What you can do is make it as large as the screen allows. The rule: offset at least equal to amplitude (no negative values), and offset + amplitude, plus the shift below the title, at most equal to the last line.

    On PC Engine, lines 3 to 27 are free below the title, which is 25 lines: an amplitude of 12 with a center at 12 gives values from 0 to 24, drawn on lines 3 to 27.

        // amplitude 12, center 12: from 0 to 24, i.e. lines 3 to 27 once below the title
        const int VAGUE = addResource(ResourceFactory("TrigoMaker",
            "function=sin&count=32&amplitude=12&offset=12"));
    PC Engine: a wave with amplitude 12 that fills the whole screen below the title
    Amplitude 12: the largest wave that fits below the title.

    On Commodore 64, the title takes line 0 and lines 1 to 24 remain: an amplitude of 11 with a center at 12 (values from 1 to 23) fits. Without TrigoMaker, you have to recompute the 32 values yourself, 12 + 11 × sin(2π × x / 32), rounded:

        // amplitude 11, center 12: from 1 to 23, every line below the title
        const ubyte VAGUE[] = {
            12, 14, 16, 18, 20, 21, 22, 23, 23, 23, 22, 21, 20, 18, 16, 14,
            12, 10,  8,  6,  4,  3,  2,  1,  1,  1,  2,  3,  4,  6,  8, 10
        };

    This is the advantage of a table generated at compile time: on PC Engine, changing one parameter is enough; on C64, you have to redo the calculation and copy the numbers again.

  2. Draw a second wave, a cosine, in another color.
    Show solutionHide solution

    On PC Engine, you declare a second TrigoMaker resource, with function=cos and the same other parameters. A new color needs a new palette: palette 2 gets an orange in its color 1. The cosine is then drawn like the sine, after making its bank visible with mapToMPR2.

        const int VAGUE = addResource(ResourceFactory("TrigoMaker",
            "function=sin&count=32&amplitude=10&offset=12"));
        const int VAGUE2 = addResource(ResourceFactory("TrigoMaker",
            "function=cos&count=32&amplitude=10&offset=12"));     // the same, as a cosine
            vce.setPalette(1, 1, RGB(2, 6, 7));
            vce.setPalette(2, 1, RGB(7, 5, 1));             // palette 2: orange, for the cosine
            // the cosine: its bank is made visible before reading it
            resource.mapToMPR2(.VAGUE2);
            debut = 0x4000 + resource.getOffset(.VAGUE2);
            vdc.setFontColor(2);
            for (ubyte x = 0; x < 32; x++) {
                vdc.setCursor(x, (ubyte)(readMem(debut + x) + 3));
                vdc.putChar('*');
            }
    PC Engine: a blue wave (sine) and an orange wave (cosine), offset from each other
    The sine in blue, the cosine in orange: the same curve, shifted by a quarter cycle.

    Here, the two small tables are stored in the same bank (the compiler fills the gaps), so the second mapToMPR2 changes nothing. But the program does not need to know that: calling mapToMPR2 before reading a resource is the safe rule, whatever the layout.

    On Commodore 64, there is no need to write a second table: a cosine is a sine a quarter cycle ahead. With 32 values per cycle, you just read the same table 8 entries further on, wrapping back to the start after the end thanks to the modulo.

            for (ubyte x = 0; x < 32; x++) {
                vic.setCursor((ubyte)(x + 4), .VAGUE[x]);   // the height: entry x of the array
                vic.putChar('*');
            }
    
            // the cosine: the same table, read a quarter cycle (8 entries) further on
            vic.setFontColor(vic.YELLOW);
            for (ubyte x = 0; x < 32; x++) {
                vic.setCursor((ubyte)(x + 4), .VAGUE[(x + 8) % 32]);
                vic.putChar('*');
            }
        }

    The two screens look alike: the TrigoMaker cosine table contains exactly the sine values shifted by 8 entries (22, 22, 21, 20…). Where the two waves cross, the second one, drawn later, covers the first.

  3. Make the wave scroll: on each frame, shift the index read from the table by one entry ((x + decalage) % 32).
    Show solutionHide solution

    To scroll the wave, you redraw it on each frame, reading the table starting one entry further. Three ingredients:

    • a clock: the frame interrupt from chapter 8 (irq1 on PC Engine, irq on C64) increments .images (frames), and the main loop waits for it to change;
    • a decalage (offset) that increases by 1 on each frame, brought back to 0 after 31 by the modulo;
    • erasing: before drawing a column's star, you erase the one from the previous frame, by writing a space at the height read with the old offset. Without this, the stars would pile up and the screen would fill up.

    Each column has only one star, and it is only touched by its own pass through the loop: so erasing then drawing column by column cannot erase a star that was just placed.

    On PC Engine

    addlib event.irq1;
    
    unit {
        // two generic resources: a file as is, and a table computed at compile time
        const int POLICE = addResource("font.bin");
        const int VAGUE = addResource(ResourceFactory("TrigoMaker",
            "function=sin&count=32&amplitude=10&offset=12"));
        ubyte images;                                       // counted by the interrupt
        ubyte decalage;                                     // where reading starts in the table
            // the table: made visible in turn, then read entry by entry
            resource.mapToMPR2(.VAGUE);
            debut = 0x4000 + resource.getOffset(.VAGUE);
            vdc.setFontColor(1);
            while (1) {
                .images = 0;
                while (.images == 0) {                      // wait for the next frame
                }
                for (ubyte x = 0; x < 32; x++) {
                    // erase the star from the previous frame...
                    vdc.setCursor(x, (ubyte)(readMem(debut + (x + .decalage) % 32) + 3));
                    vdc.putChar(' ');
                    // ... and draw the new one, one entry further in the table
                    vdc.setCursor(x, (ubyte)(readMem(debut + (x + .decalage + 1) % 32) + 3));
                    vdc.putChar('*');
                }
                .decalage = (ubyte)((.decalage + 1) % 32);
            }
        }
    
        interrupt -> irq1 {
            .images++;
        }
    }

    The table is made visible only once, before the loop: nothing else changes the bank visible at $4000 after that.

    On Commodore 64

            vic.setFontColor(vic.CYAN);
            while (1) {
                .images = 0;
                while (.images == 0) {                      // wait for the next frame
                }
                for (ubyte x = 0; x < 32; x++) {
                    // erase the star from the previous frame...
                    vic.setCursor((ubyte)(x + 4), .VAGUE[(x + .decalage) % 32]);
                    vic.putChar(' ');
                    // ... and draw the new one, one entry further in the table
                    vic.setCursor((ubyte)(x + 4), .VAGUE[(x + .decalage + 1) % 32]);
                    vic.putChar('*');
                }
                .decalage = (ubyte)((.decalage + 1) % 32);
            }
        }
    
        interrupt -> irq {
            .images++;
        }
    }

    Measured in the emulator, decalage does advance by one entry per frame on both machines: the wave slides to the left and makes a full cycle in 32 frames, about 0.5 seconds on PC Engine (60 frames per second) and 0.64 seconds on C64 (50 frames per second). Redrawing the 32 columns easily fits within one frame. For slower scrolling, just wait 2 or 3 frames per step, as in while (.images < 3).

  4. Add three resources of 3,000, 4,000 and 5,000 bytes, and look in asm/main.asm for the banks the compiler gave them.
    Show solutionHide solution

    Create three files of 3,000, 4,000 and 5,000 bytes (their content does not matter here), and declare them in the unit of the wave program:

        // two generic resources: a file as is, and a table computed at compile time
        const int POLICE = addResource("font.bin");
        const int VAGUE = addResource(ResourceFactory("TrigoMaker",
            "function=sin&count=32&amplitude=10&offset=12"));
        // three files of 3,000, 4,000 and 5,000 bytes
        const int DONNEES3000 = addResource("donnees3000.bin");
        const int DONNEES4000 = addResource("donnees4000.bin");
        const int DONNEES5000 = addResource("donnees5000.bin");

    The program compiles without errors, and asm/main.asm contains the index tables. They are in identifier order: POLICE (0), VAGUE (1), DONNEES3000 (2), DONNEES4000 (3), DONNEES5000 (4).

    ; Resource Tables (5 resources)
    ; ========================================
    
    _resource_banks:
        .db RESOURCE_FIRST_BANK+0, RESOURCE_FIRST_BANK+1, RESOURCE_FIRST_BANK+1, RESOURCE_FIRST_BANK+2, RESOURCE_FIRST_BANK+1
    
    _resource_offsets:
        .dw $0000, $1F40, $1388, $0000, $0000
    
    _resource_lengths:
        .dw $2000, $0020, $0BB8, $0FA0, $1388
    
    _resource_count = 5

    The asm/resources.asm file, which contains the data itself, shows it more readably, bank by bank:

    BankResources (position)Used
    RESOURCE_FIRST_BANK+0POLICE ($0000)8,192 bytes: full
    RESOURCE_FIRST_BANK+1DONNEES5000 ($0000), DONNEES3000 ($1388 = 5,000), VAGUE ($1F40 = 8,000)8,032 bytes
    RESOURCE_FIRST_BANK+2DONNEES4000 ($0000)4,000 bytes

    This is exactly the rule from the chapter, from largest to smallest:

    1. the font, 8,192 bytes, fills the first bank;
    2. 5,000 bytes: the first bank is full, a second one is opened;
    3. 4,000 bytes: only 3,192 bytes are left in the second one, a third one is opened;
    4. 3,000 bytes: they fit in the remaining 3,192 bytes of the second bank, after the 5,000;
    5. the wave, 32 bytes, slips into the 192 bytes still left in the second bank.

    The compiler did not follow the order of declarations: the second bank starts with the 5,000 bytes, declared last. With these particular sizes, filling the banks in declaration order would also have needed three banks (the wave, 3,000 and 4,000 in the second, 5,000 in the third): the gain depends on the sizes, as in the chapter's example where it saves a whole bank. The program, for its part, needs to know nothing: mapToMPR2(.DONNEES3000) reads the tables and finds the right bank.