Storage and Saves¶
Flash save (standalone AMD stubs plus legacy BIOS/system.lib reference) and real-time clock for NGPC homebrew.
1. Memory Map — Storage Regions¶
0x004000 - 0x005FFF Main RAM (8 KB)
0x006000 - 0x006BFF Battery-backed RAM (3 KB) — lightweight saves (volatile, no erase)
0x007000 - 0x007FFF Z80 audio RAM (4 KB, shared with sound CPU)
0x006F80 - 0x006FFF BIOS system area (variables, ISR vectors)
0x200000 - 0x3FFFFF Cartridge ROM (2 MB, FAR access only)
0x3FA000 Default flash save offset (block 0x21, within 8 KB block)
0x3FC000 - 0x3FFFFF Reserved by system — do NOT use
Key constraint: the last 16 KB (0x3FC000-0x3FFFFF) is reserved by the system.
If your flash backend erases a 64 KB block, use block 0x21 (offset 0x1FA000) — not
block 0x1F, which overlaps the reserved area.
2. Flash Save — ngpc_flash¶
2.1 API¶
void ngpc_flash_init(void); /* Call at startup */
void ngpc_flash_save(const void *data); /* Write 256 bytes to flash */
void ngpc_flash_load(void *data); /* Read 256 bytes from flash */
u8 ngpc_flash_exists(void); /* Check if valid save exists (magic check) */
Flash has limited write cycles. Never save every frame.
2.2 Magic Number Requirement¶
ngpc_flash_exists() validates the first 4 bytes of the save area.
Your save struct must begin with { 0xCA, 0xFE, 0x20, 0x26 }.
typedef struct {
u8 magic[4]; /* always { 0xCA, 0xFE, 0x20, 0x26 } */
u8 version;
u8 hp;
u8 level;
/* ... up to 251 more bytes */
} SaveData;
2.3 Full Save/Load Example¶
void save_game(void)
{
SaveData s;
s.magic[0] = 0xCA; s.magic[1] = 0xFE;
s.magic[2] = 0x20; s.magic[3] = 0x26;
s.version = 1;
s.hp = player.hp;
s.level = player.level;
ngpc_flash_save(&s);
}
void load_game(void)
{
if (ngpc_flash_exists()) {
SaveData s;
ngpc_flash_load(&s);
if (s.version == 1) {
player.hp = s.hp;
player.level = s.level;
}
} else {
/* No valid save — initialize defaults in RAM only */
player.hp = 3;
player.level = 1;
}
}
3. Real-Time Clock — ngpc_rtc¶
3.1 API¶
void ngpc_rtc_get(NgpcTime *t); /* Read date/time (BCD) */
void ngpc_rtc_set_alarm(NgpcAlarm *a); /* Alarm during gameplay */
void ngpc_rtc_set_wake(NgpcAlarm *a); /* Wake-up alarm (powers on) */
void ngpc_rtc_set_alarm_handler(void (*handler)(void)); /* Install alarm ISR */
3.2 BCD Encoding¶
The NGPC RTC uses BCD encoding for all time values: 0x12 = twelve (not 18).
/* BCD helpers */
u8 bin = BCD_TO_BIN(bcd_value); /* 0x23 -> 23 */
u8 bcd = BIN_TO_BCD(23); /* 23 -> 0x23 */
/* Example: display current time */
NgpcTime t;
ngpc_rtc_get(&t);
u8 hour = BCD_TO_BIN(t.hour);
u8 min = BCD_TO_BIN(t.minute);
4. Save Design Patterns¶
4.1 Recommended Save Struct Layout¶
ngpc_flash_exists() only validates the magic number — a partial or stale save may still
pass this check. Use full application-level validation:
typedef struct {
u8 magic[4]; /* { 0xCA, 0xFE, 0x20, 0x26 } */
u8 version; /* layout version — reject mismatches */
/* game data */
u8 options;
u8 continues;
/* high scores */
u16 score_hi[10];
u16 score_lo[10];
u8 name[10][4]; /* 3 chars + null */
/* validation */
u8 checksum;
u8 _pad[...];
} SaveData;
Full validation checklist on load:
1. Magic matches { 0xCA, 0xFE, 0x20, 0x26 }
2. version matches current layout
3. Fields within valid bounds
4. Checksum passes
Put
checksumat a fixed offset, before the terminal_pad[]— never last. If the struct ends withu8 _pad[SAVE_SIZE - N]; u8 checksum;, cc900 may pad the whole struct to alignsizeof, shifting the checksum's actual offset in the flash image away fromSAVE_SIZE-1. Withchecksumahead of the terminal padding, its offset is deterministic and the trailing_pad[]absorbs any compiler padding. This layout is validated on hardware in a shipped shmup profile struct.A simple self-referential checksum (computed over every byte except itself):
Keep one save struct in RAM as the single source of truth; modify it in place, recompute the checksum, thenu8 sum = 0u; for (i = 0u; i < SAVE_SIZE - 1u; i++) sum ^= ((u8 *)&save)[i]; save.checksum = sum ^ 0x5Au;ngpc_flash_save(&save). This singleton + dirty-commit pattern avoids the scratch-buffer load/clear/rebuild cycles that invite corruption.
4.2 Append-Only Slot Pattern (Validated)¶
Validated solution — avoids erase-per-save and eliminates mid-session CLR_FLASH_RAM issues:
The validated configuration (confirmed on real hardware after extensive testing) uses
16 slots × 512 bytes (SAVE_SIZE=512, rbc3=2) inside the 8 KB block at offset 0x1FA000:
Slot 0: 0x1FA000 .. 0x1FA1FF (512 bytes)
Slot 1: 0x1FA200 .. 0x1FA3FF
...
Slot 15: 0x1FC000 .. 0x1FC1FF
Template default:
ngpc_flash.hships withSAVE_SIZE 256(32 slots × 256 bytes,rbc3=1) as a lighter starting point. Always change toSAVE_SIZE 512for production. See §5.1 for whyrbc3=1is unreliable on real hardware.
Write: find the first empty slot (first byte == 0xFF), write there.
Never erase mid-session — only erase when the entire block is full. The template handles
erase via ngpc_flash_erase_asm() (standalone AMD stub, no system.lib required).
CLR_FLASH_RAM (system.lib) is the legacy alternative; it works on the first call per
session but silently fails if called a second time.
Read at boot: scan slots 15 -> 0, return the last slot with a valid magic.
SaveData *ngpc_flash_find_valid_slot(void)
{
for (s8 i = MAX_SLOTS - 1; i >= 0; i--) {
SaveData *p = (SaveData *)(SAVE_OFFSET + i * SLOT_SIZE);
if (p->magic[0] == 0xCA && p->magic[1] == 0xFE
&& p->magic[2] == 0x20 && p->magic[3] == 0x26)
return p;
}
return NULL; /* no valid save */
}
Why this works: each save appends to a new slot — no erase required until all 16 slots
are used. The flash erase only happens at that point, and CLR_FLASH_RAM is reliable
on first call. This avoids the corruption risk of erase-on-every-save.
4.3 When to Save¶
| Event | Save? |
|---|---|
| Boot / startup | No — load defaults into RAM only |
| Every frame | Never — flash write cycles are finite |
| Button press in options | No — mark dirty in RAM only |
| Leave options screen | Yes — flush dirty RAM to flash |
| High score validated | Yes — explicit commit |
| Save point (RPG/platformer) | Yes — explicit commit |
"No crash" does not mean "valid save." To confirm a flash backend works: 1. Boot normally 2. Trigger a save 3. Power off 4. Power on 5. Verify the magic is present and data is correct
4.4 Default Initialization at Boot¶
void save_init(void)
{
if (ngpc_flash_exists()) {
ngpc_flash_load(&g_save);
if (!save_validate()) {
save_reset_defaults();
}
} else {
save_reset_defaults();
}
/* Never write to flash here — RAM-only initialization */
}
void save_reset_defaults(void)
{
g_save.magic[0] = 0xCA; g_save.magic[1] = 0xFE;
g_save.magic[2] = 0x20; g_save.magic[3] = 0x26;
g_save.version = 1;
g_save.continues = 3;
for (u8 i = 0; i < 10; i++) {
g_save.score[i] = 0;
memcpy(g_save.name[i], "---", 4);
}
}
Writing to flash at boot risks powering off the console on real hardware if the write triggers the watchdog. Always initialize RAM defaults only.
4.5 High Score Integration¶
u8 is_high_score(u32 score)
{
return score > g_save.score[9]; /* beats 10th place */
}
void insert_high_score(u32 score, const char *name_3)
{
/* Find insertion point */
u8 pos = 9;
while (pos > 0 && score > g_save.score[pos - 1])
pos--;
/* Shift down */
for (u8 i = 9; i > pos; i--) {
g_save.score[i] = g_save.score[i - 1];
memcpy(g_save.name[i], g_save.name[i - 1], 4);
}
/* Insert */
g_save.score[pos] = score;
memcpy(g_save.name[pos], name_3, 3);
g_save.name[pos][3] = 0;
/* Commit */
ngpc_flash_save(&g_save);
}
Design note: whether scores from continued runs count toward the top 10 is a game-design decision, not a technical one.
5. Flash Hardware Details¶
5.0 Save geometry is per-cartridge-size — get it wrong and you erase your own ROM¶
The 0x21 / 0x1FA000 pair below is correct for a 16 Mbit cartridge. Smaller
cartridges need a different block, and the same block number means a different address
on each size. Getting it wrong does not mean "the save fails" — it means erasing
somewhere else, and on a small ROM that somewhere else is your own code.
Manufacturer block plan (SDK FlashMem.txt, all three sizes): 64 KB blocks up to the
top, with the last 64 KB split 32 / 8 / 8 / 16. The two 8 KB blocks are where every
game's save lives; the final 16 KB block is reserved for the system program. The save
block conventionally used is the second 8 KB block from the top — exactly 0x6000
below the chip top on all three sizes:
| Cartridge | Save block | Offset | CPU address |
|---|---|---|---|
| 4 Mbit (512 KB) | 9 (0x09) |
0x07A000 |
0x27A000 |
| 8 Mbit (1 MB) | 17 (0x11) |
0x0FA000 |
0x2FA000 |
| 16 Mbit (2 MB) | 33 (0x21) |
0x1FA000 |
0x3FA000 |
Contributed by Napsterix, corroborated by two independent sources: freeplaytech forum reports, and reverse-engineering of the BIOS flash routine against the SDK block map.
Detect the cartridge size at runtime — do not hardcode it¶
The BIOS answers this question at power-on and stores the answer at 0x6C58 in its
own work RAM:
0x6C58 |
cartridge |
|---|---|
0 |
no cartridge |
1 |
4 Mbit |
2 |
8 Mbit |
3 |
16 Mbit |
The BIOS flash routine reads this same byte before touching anything (and returns
error 0xFF if it is zero), so reading it yourself is consistent by construction rather
than a second opinion. 0x6C59 is the same thing for CS1 — the development board's slot,
which is empty on a production console.
An unknown value must mean "do not save", not a guessed default: failing to save is recoverable, erasing the wrong block is not.
Alternative without the BIOS — CFI/autoselect query of the flash chip itself. After the
AA @ 5555 / 55 @ 2AAA / 90 @ 5555 unlock the chip answers 0x98 (Toshiba) then a
device ID that names the size: 0xAB = 4 Mbit, 0x2C = 8 Mbit, 0x2F = 16 Mbit.
F0 returns the chip to being memory.
⚠️ 32 Mbit Flash Masta cartridges — saves reported broken below 16 Mbit¶
On 32 Mbit Flash Masta cartridges the save function is reported broken for all games under 16 Mbit: those games write to block
0x11, which on that cartridge lies inside the game's own address space. The community fix repatches affected commercial games from block0x11to0x21(Cotton, Biomotor Unitron and others).Note that block
0x11is exactly the correct 8 Mbit block from the table above — so a correctly implemented small homebrew game is precisely the affected class.OPEN — not verified: what
0x6C58reports on a 32 Mbit Flash Masta holding a small ROM. If it reports2, a correct implementation writes to0x0FA000and hits the broken block. Until someone checks on the device, read0x6C58on your target cartridge before the first save — the first save attempt is also the test, and the stake is your own ROM image.
Reported by Napsterix; source: freeplaytech forum, Flash Masta section, tid=92. Consequence marked OPEN deliberately — do not promote it to fact without an on-device check.
5.0b Verify the write, and verify the machine survived¶
"BIOS returned SYS_SUCCESS" and "the bytes are in the cartridge" are two different
claims. Always read the record back and validate it. Anything that does not verify must
count as "no save data" — a half-written record is worse than none, because it looks like
a working one.
Check that the machine is still running afterwards. A wrong unit count made the BIOS
write routine program the requested bytes correctly and then loop forever with
interrupts masked by the swi — a dead console with a perfect-looking save record in the
cartridge. A save-comparison test passed; only a run-on test (does the game reach the next
screen?) caught it. A VBlank counter that stops incrementing is the tell.
Why
swi 1masking interrupts is not a side effect but the point: the erase takes ~1 s and the game's own ISR lives in the same cartridge. If an interrupt could fire, the CPU would try to fetch instructions from a chip that is in erase mode. Reset the watchdog before each call.
Contributed by Napsterix.
5.1 Confirmed BIOS Parameters¶
Parameters confirmed by cross-analysis of multiple working NGPC homebrews with persistent saves on real hardware:
#define SAVE_BLOCK 0x21 /* NOT 0x1F — overlaps reserved area */
#define SAVE_OFFSET 0x1FA000 /* CPU address: 0x200000 + 0x1FA000 = 0x3FA000 */
#define SAVE_SIZE 512 /* rbc3=2 — validated on real hardware */
BC=1(256 bytes) is unreliable on real hardware — writes complete without error but data may not persist after power-off. Always useBC=2(512 bytes) in production. The template ships withSAVE_SIZE 256as a configurable starting point; change it to512before shipping any game.
BIOS vector IDs used:
| Vector | ID | Operation |
|---|---|---|
VECT_FLASHERS |
rw3=8 |
Erase block |
VECT_FLASHWRITE |
rw3=6 |
Write N×256 bytes |
5.2 Raw ASM Sequence (Erase + Write)¶
The template uses standalone AMD stubs — no system.lib needed.
The stubs are position-independent byte sequences (115 bytes write, 98 bytes erase) extracted
by disassembly from a hardware-validated ROM. They are copied to RAM at 0x6E00 and executed
from there (a flash chip cannot execute code while being programmed).
Key insight — I/O register 0x6E (flash bus control):
User code CAN write this register. (0x6E) = 0x14 asserts /WE on the cartridge slot,
enabling flash write cycles. (0x6E) = 0xF0 deasserts it. This register is what
WRITE_FLASH_RAM / CLR_FLASH_RAM set internally — missing it was the root cause of all
previously failed manual flash attempts.
Standalone sequence (template default):
; --- Common prologue ---
ld (0x6E), 0x14 ; assert /WE on cart bus (CRITICAL — I/O reg, user-writable)
ld (0x6F), 0xB1 ; watchdog: extended mode for long flash operation
; --- Erase block 33 (F16_B33, 8 KB, abs 0x3FA000) ---
ld xde, 0x6E00 ; destination: RAM
ld xhl, _erase_stub ; source: 98-byte AMD erase sequence in ROM
ld bc, 98
; ldir — copy stub to RAM (stubs cannot run from the flash chip itself)
ld xix, 0x200000 ; AMD unlock base = CS0
ld xiy, 0 ; XIY = 0 (erase stub parameter)
ld a, 0 ; A = 0 (erase stub parameter)
ld xde, 0x3FA000 ; block address (absolute)
call 0x6E00 ; execute stub from RAM
ld (0x6F), 0x4E ; restore watchdog
ld (0x6E), 0xF0 ; deassert /WE
; --- Write 512 bytes ---
ld xhl, (xsp+4) ; source pointer (from C stack, bank-0 — no bank-3 promotion needed)
ld xde, (xsp+8) ; relative offset (e.g. 0x1FA000 + slot*512)
ld xix, 0x200000
add xde, xix ; make destination absolute (0x3FA000 + slot*512)
; [push xde / push xhl — preserve across ldir copy]
ld xde, 0x6E00
ld xhl, _write_stub ; source: 115-byte AMD write sequence in ROM
ld bc, 115
; ldir — copy stub to RAM
; [pop xhl / pop xde — restore src and dest]
ld bc, 0x0002 ; 2 pages × 256 = 512 bytes
call 0x6E00
ld (0x6F), 0x4E
ld (0x6E), 0xF0
Legacy sequence (system.lib path, kept as reference):
; Erase block 0x21 via CLR_FLASH_RAM (system.lib)
ld ra3, 0
ld rb3, 0x21
ld (rWDCR), 0x4E
calr CLR_FLASH_RAM ; reliable on first call only per session
; Write 512 bytes via WRITE_FLASH_RAM (system.lib)
ld ra3, 0
ld rbc3, 2 ; 2 * 256 = 512 bytes
ld xhl, (xsp+4)
ld xhl3, xhl ; bank-3 promotion required for BIOS path
ld xde, (xsp+8)
ld xde3, xde
ld (rWDCR), 0x4E
calr WRITE_FLASH_RAM
5.3 cc900 Inline ASM Pointer Rules¶
Standalone path (no bank-3 promotion needed):
; Load from C stack — stays in bank 0, no ld xhlN,xhl required
ld xhl, (xsp+4) ; source pointer (first C arg)
ld xde, (xsp+8) ; flash offset (second C arg)
ld xix, 0x200000
add xde, xix ; offset -> absolute address
Legacy system.lib path (bank-3 promotion required by BIOS convention):
/* CORRECT — load argument from stack, copy to bank 3 */
__asm("ld xhl, (xsp+4)"); /* first arg = source pointer */
__asm("ld xhl3, xhl"); /* promote to bank 3 for BIOS */
/* INCORRECT — cc900/asm900 cannot see C symbols from inline ASM */
/* __asm("ld xhl, (my_c_symbol)"); */ /* Error-221: Undefined symbol */
Keep the flash function minimal — no local variables, no C statements other than
a guard (void)data, to ensure the stack prologue stays predictable for the xsp+4 offset.
5.4 Known Flash Pitfalls¶
| Pitfall | Consequence | Fix |
|---|---|---|
| Write at boot | Power-off on real hardware | Load defaults to RAM only at boot |
BC=1 (256 bytes) |
Unreliable on real hardware — data may not persist after power-off | Use BC=2 (512 bytes, SAVE_SIZE=512) |
| Block 0x1F | Overlaps system reserved area | Use block 0x21 |
| Erase on every save | Flash wear, risks mid-session failure | Append-only slots |
| "No crash" = success | False | Validate with full power-cycle test |
(0x6E) not set to 0x14 |
Write cycles silently ignored by hardware | Set (0x6E)=0x14 before stub, (0x6F)=0xB1; restore after |
| Executing stub from flash | Undefined behavior (chip busy during program) | Copy stub to RAM at 0x6E00, execute from there |
CLR_FLASH_RAM second call |
Silently fails (BIOS internal bug) — legacy path only | Erase only when block full; standalone stubs are not affected |
| Saving while a raster/Timer0 ISR is active | The split ISR writes (0x6E)/(0x6F) — the same control bytes the flash stub drives -> corruption |
Disable the split timer (e.g. hud_raster_disable()) before ngpc_flash_save(), or save only from a state where raster is already off |
| Checksum field placed last (after terminal padding) | cc900 may pad the whole struct -> the checksum's flash offset is no longer SAVE_SIZE-1 -> validation drifts |
Put checksum at a fixed offset before the terminal _pad[] (see §4.1) |
Quick Reference¶
| Item | Value / Pattern | Notes |
|---|---|---|
| Flash save offset | 0x1FA000 |
CPU address: 0x3FA000 |
| Flash block | 0x21 |
NOT 0x1F — reserved area overlap |
| Write size | BC=2 = 512 bytes |
BC=1 (256 B) unreliable on real hardware — use 512 in production |
| VECT_FLASHERS | rw3=8 |
Erase (broken for blocks 32-34 — use standalone stub) |
| VECT_FLASHWRITE | rw3=6 |
Write (replaced by standalone write stub in template) |
| Magic bytes | { 0xCA, 0xFE, 0x20, 0x26 } |
First 4 bytes of save struct |
| Validation | magic + version + bounds + checksum | Magic alone insufficient |
| Save slots | 16 slots × 512 bytes in 8 KB block | Append-only pattern |
| Slot scan at boot | Slots 15 -> 0, last valid = current | |
| Erase policy | Only when block full | Standalone AMD stub (ngpc_flash_erase_asm); CLR_FLASH_RAM legacy only |
| Save trigger | Options exit / high score commit | Never at boot, never per-frame |
| Boot init | Load to RAM only | Flash write at boot = power-off risk |
| RTC encoding | BCD | BCD_TO_BIN() / BIN_TO_BCD() |
| Battery-backed RAM | 0x006000-0x006BFF (3 KB) |
No erase needed, volatile (no battery on cart) |
See Also¶
- Hardware Registers — Full memory map, ROM base address, BIOS area
- BIOS — BIOS SWI vectors, system.lib functions (legacy reference)
- Game Loop — Watchdog rules, VBlank ISR constraints
- Build Toolchain — ROM layout, linker sections, flash offset in 2 MB cartridge