OpenSNES
Modern Open-Source SNES Development SDK
Loading...
Searching...
No Matches
Far RAM Tutorial — FAR objects beyond the 8 KB band

This tutorial covers the FAR qualifier: how to place bulk C data in the 56 KB of WRAM above the 8 KB bank-0 band, what it costs, and when not to use it. It assumes you have read the Graphics tutorial; the DMA tutorial is useful background because most FAR buffers end up as DMA sources.

The two RAM bands

The SNES has 128 KB of WRAM, but the compiler's default addressing is bank-0-implicit: a plain C global must live in $00:0000-$1FFF, the 8 KB that also holds the stack and the direct-page registers. Every link prints how much of that band is left:

OK: C RAM band $0000-$1FFF: 1436 bytes free (top at $1A64; 5544 bytes in 38 sections)

A tilemap built in RAM (2 KB), a palette buffer (512 bytes) and a brick grid were enough to leave breakout with 1.4 KB for everything else. The second band is $7E:2000-$FFFF, 56 KB, and FAR is how a C object gets there:

#include <snes.h>
FAR u16 blockmap[0x400]; /* 2 KB tilemap, built in RAM, DMA'd to VRAM */
FAR u8 blocks[100]; /* brick state */
FAR u8 hdma_table[2][1030]; /* double-buffered HDMA table */
FAR u16 blockmap[]
BG1 tilemap RAM copy (0x400 entries = 2KB), far RAM.
FAR u8 blocks[]
Mutable brick state array (100 entries, one per grid cell).
unsigned short u16
16-bit unsigned integer (0 to 65535)
Definition types.h:53
unsigned char u8
8-bit unsigned integer (0 to 255)
Definition types.h:47
#define FAR
Place a global in bank $7E WRAM, outside the 8 KB bank-0 band.
Definition types.h:130
OpenSNES common-case master header.

The same link then prints both bands:

OK: C RAM band $0000-$1FFF: 6880 bytes free (top at $0520; 836 bytes in 37 sections)
OK: far RAM band $7E:2000-$FFFF: 52636 bytes free (top at $3264; 4708 bytes in 1 sections)

A FAR object behaves like any other global. It is zero at boot (crt0 clears the far band with one DMA, 22 ms once), it can carry an initialiser, and every access to it is compiled with bank-honouring addressing — the qualifier rides on the type, like const, so a u8 FAR *p pointer keeps the property through function calls.

Rules the compiler enforces

  • Static storage only. The stack is bank 0, so a FAR local is an error. static FAR u8 buf[256]; inside a function is fine.
  • Not const. ROM is already reachable from any bank; FAR const is refused.
  • No silent narrowing. Passing or assigning a T FAR * to a plain T * is a compile error, in an initialiser, an argument or a plain assignment. A plain pointer converts to T FAR * silently and just takes the far path.
  • const accepts far. A T FAR * converts to const T * without a cast, because a read through a const pointee is already a far read. This is why the whole DMA and asset API takes FAR buffers as they are:
dmaCopyVram((const u8 *)blockmap, 0x0000, 0x800); /* u16 -> u8 needs the cast anyway */
dmaCopyCGram((const u8 *)pal, 0, 512);
static u16 pal[256]
The 256-colour palette, built at boot.
Definition main.c:57
void dmaCopyVram(const u8 *source, u16 vramAddr, u16 size)
Copy data to VRAM (PVSnesLib compatible).
void dmaCopyCGram(const u8 *source, u16 startColor, u16 size)
Copy palette data to CGRAM (PVSnesLib compatible).
#define HDMA_MODE_2REG_2X
HDMA mode: 2 registers, 4 bytes (2 to each).
Definition hdma.h:174
#define HDMA_DEST_M7A
Destination: Mode 7 matrix A ($211B).
Definition hdma.h:245
void hdmaSetup(u8 channel, u8 mode, u8 destReg, const void *table)
Set up an HDMA channel.

Casting a FAR pointer to a plain pointer explicitly compiles and is the one way to get a silent wrong-bank access. Do it only when the object really is in bank 0.

What it costs

Measured with the b2_deref bench (cycles per access, loop overhead removed):

Access bank 0 FAR
byte load through a pointer walk p[k] 116 121
word load through a pointer walk 132 137
byte load arr[k] 107 88
byte store through a pointer walk 88 74
byte store arr[k] 79 41

Indexing a FAR array by name is cheaper than the bank-0 form: the backend folds arr + k into one absolute-long-indexed instruction (lda.l arr,x) instead of materialising the address first. A pointer walk stages the 24-bit pointer once in a direct-page pair and then reads with [tcc__r9],y, within 4 % of the bank-0 path. Struct fields through a FAR pointer stage the pointer once per basic block:

void set_rec(far_rec FAR *r, u16 w, u32 l) { r->w = w; r->l = l; }
/* -> one staging, then ldy #2 / sta [tcc__r9],y, ldy #4 / ..., ldy #6 / ... */
unsigned int u32
32-bit unsigned integer (0 to 4294967295)
Definition types.h:90

When to use it, when not to

Use FAR for bulk data that is read or written in loops and handed to DMA: tilemaps built in RAM, palette buffers for fades, HDMA tables, entity pools, level state. That is exactly what fills the 8 KB band and exactly what the far forms handle at no visible cost.

Keep in bank 0 the small hot state that lib helpers walk through a plain pointer: Rect, AnimPlayer, AudioSample, AudioVoiceState. Those helpers (rectContains, collideRect, animTick, audioGetSampleInfo, …) take plain pointers on purpose; the compiler refuses a FAR argument there, so nothing goes wrong silently. Two helpers that legitimately fill a bulk buffer accept either band: sramLoad / sramLoadOffset and dsp1Raster.

Worked example: breakout

examples/games/breakout builds its two tilemaps and its palette in RAM and re-uploads them after every brick hit. The buffers are declared in assembly and reached from C:

.RAMSECTION ".game_buffers" BANK $7E SLOT 2
blockmap dsb $800
backmap dsb $800
pal dsb $200
blocks dsb 100
.ENDS
extern FAR u16 blockmap[];
extern FAR u8 blocks[];
static void writenum(u16 num, u8 len, u16 FAR *tilemap, u16 pos, u16 offset);
static void mycopy(u8 FAR *dest, const u8 *src, u16 len);
a = blocks[b]; /* lda.l blocks,x */
blockmap[0x62 + c] = 13 + (a << 10); /* sta.l blockmap,x */
dmaCopyVram((const u8 *)blockmap, 0x0000, 0x800);
static u16 tilemap[896]
Generated 32x28 tilemap (krom's fixed pattern — see buildTilemap).
Definition main.c:55
static void mycopy(u8 FAR *dest, const u8 *src, u16 len)
Simple byte-by-byte memory copy.
Definition main.c:251
static u16 b
Definition main.c:156
static void writenum(u16 num, u8 len, u16 FAR *tilemap, u16 pos, u16 offset)
Write a right-aligned number to a tilemap buffer.
Definition main.c:227
static u16 a
Definition main.c:156
static u16 c
Definition main.c:156

The whole migration was the FAR on four extern lines, the three local helpers taking FAR pointers, and (const u8 *) on the DMA calls. The ROM renders pixel for pixel the same, and bank 0 went from 1436 to 6880 free bytes.

Copying: <snes/string.h>

A loop in C over a plain pointer reads and writes bank $00, so it cannot fill a FAR buffer from a const table: the table is in ROM, the buffer in bank $7E. The string module does it. Its seven functions — memcpy, memmove, memset, strlen, strcmp, strcpy, strncpy — follow the full 24-bit pointer on both sides, so every pairing of ROM, plain RAM and FAR RAM works, and a ROM links only the functions it calls.

#include <snes/string.h> /* and `string` in LIB_MODULES */
static const u8 level1[256] = { /* ... */ }; /* ROM, any bank */
FAR u8 level[256]; /* $7E:2000 and up */
memcpy(level, level1, sizeof(level)); /* ROM -> FAR */
memset(level, 0, sizeof(level)); /* clear it */
static u16 level
Definition main.c:163
Memory and string functions: memcpy, memmove, memset, strlen, strcmp, strcpy, strncpy.
void FAR * memset(void FAR *dst, int c, unsigned int n)
Fill n bytes with a value.
void FAR * memcpy(void FAR *dst, const void *src, unsigned int n)
Copy n bytes from src to dst.

memcpy and memset move a word at a time: 23 CPU cycles a word in the copy loop, so about 12 a byte. For VRAM, CGRAM and OAM the data goes through PPU ports: that is DMA's job (dmaCopyVram() and friends), not memcpy's.

Three things differ from a desktop C library, all following from the target: a size is an unsigned int, 16 bits, so 65535 bytes at most; a destination is typed FAR (the type every pointer converts to), and the functions that return their destination return it as a FAR pointer; strcmp returns the difference of the first two differing bytes, taken as unsigned, of which only the sign matters.

Debugging

symmap.py --check-ram-budget game.sym prints both bands. In luna, a FAR symbol resolves to its $7E:xxxx address from the .sym file, so --peek far_buf:16 reads it directly. If a value reads as zero or as garbage where you expect data, check first that no explicit cast dropped the qualifier on the way — that is the only path the compiler does not guard.

See also

  • DMA tutorial — the far buffers' usual destination.
  • HDMA tutorial — tables built in RAM are the classic case.
  • KNOWN_LIMITATIONS.md — the bank-0 band entry and the far-band escape.
  • compiler/ABI.md — the address-space section of the calling convention.