Debug Tools¶
On-device debug instrumentation for NGPC homebrew: CPU profiler via raster position, ring-buffer diagnostic log, and runtime assertions with on-screen fault display.
1. CPU Profiler — ngpc_debug¶
1.1 API¶
void ngpc_debug_begin(void); /* mark start of logic */
void ngpc_debug_end(void); /* mark end of logic */
void ngpc_debug_draw_bar(plane, pal_ok, pal_warn, pal_over); /* visual CPU bar */
void ngpc_debug_print_pct(plane, pal, x, y); /* print "XX%" */
void ngpc_debug_print_fps(plane, pal, x, y); /* print "XXFPS" */
u8 ngpc_debug_get_lines(void); /* raw scanlines used */
u8 ngpc_debug_get_pct(void); /* CPU usage 0-100+ */
The profiler measures game logic duration using the hardware raster position register
(HW_RAS_V at 0x8009): it reads the current scanline at begin and end, and
computes the percentage of the 152-scanline frame consumed.
Color bar thresholds: - Green: < 80% — safe - Yellow: 80-100% — tight - Red: > 100% — frame overflow
All calls become no-ops when NGPC_DEBUG is defined as 0.
1.2 Typical Game Loop Usage¶
void main(void)
{
ngpc_init();
ngpc_load_sysfont();
/* ... setup ... */
while (1) {
ngpc_vsync();
ngpc_input_update();
ngpc_debug_begin();
game_update();
ngpc_debug_end();
ngpc_debug_draw_bar(GFX_SCR2, PAL_GREEN, PAL_YELLOW, PAL_RED);
/* or: ngpc_debug_print_pct(GFX_SCR1, 0, 18, 0); */
}
}
ngpc_debug_begin()/ngpc_debug_end()bracket only game logic, not rendering or VBlank-triggered code — to get an accurate budget reading.
2. Ring Buffer Log — ngpc_log¶
2.1 API¶
void ngpc_log_init(void);
void ngpc_log_clear(void);
void ngpc_log_hex(const char *label, u16 value); /* log "LABEL: 0x1234" */
void ngpc_log_str(const char *label, const char *str);
void ngpc_log_dump(plane, pal, x, y); /* render log to tilemap */
u8 ngpc_log_count(void); /* number of entries in buffer */
/* Convenience macros */
NGPC_LOG_HEX("PAD", ngpc_pad_held);
NGPC_LOG_STR("ST", "RUN ");
Stores short diagnostic entries in a fixed-size ring buffer (~288 bytes RAM). New entries overwrite the oldest when the buffer is full.
2.2 Usage Notes¶
- Useful on real hardware where stdout/serial is not available.
- Display with
ngpc_log_dump()on a spare tilemap plane or overlay. ngpc_log_hex()formats the value as hexadecimal.- Macros (
NGPC_LOG_HEX,NGPC_LOG_STR) compile to no-ops in release builds. - RAM cost: ~288 bytes for the ring buffer — significant on a 8 KB RAM budget. Disable in release.
3. Runtime Assert — ngpc_assert¶
In debug builds: assertion failure displays an on-screen fault page (file + line, if available) and enters a blinking loop. The game does not continue.
In release builds: NGPC_ASSERT is compiled out completely — zero cost.
Assertion failures loop indefinitely. This is intentional — it forces the developer to notice the failure on screen. The watchdog will eventually reset the console, but the fault screen should be visible long enough to read.
4. Build Flags¶
| Flag | Effect |
|---|---|
#define NGPC_DEBUG 1 |
Enable profiler (default in debug profile) |
#define NGPC_DEBUG 0 |
Disable profiler — all calls become no-ops |
#define NGP_PROFILE_RELEASE 1 |
Strip NGPC_ASSERT and NGPC_LOG_* macros |
In the build Makefile, debug vs release is typically controlled by passing
-DNGP_PROFILE_RELEASE=1 to CDEFS for release builds.
5. Faults the emulator counts for you — hw_safety¶
The three tools above run inside your ROM and cost it RAM and cycles. This one costs nothing: the NgpCraft emulator watches for a small set of conditions a real console punishes and most emulators never mention, and reports them with every run.
python ngpc_native.py run GAME.ngc --bios bios.bin --frames 600 --json
→ "hw_safety": {"counts": {"watchdog-starved": 0,
"system-stack": 0,
"flash-busy-fetch": 0}}
| finding | what it means | why a console cares |
|---|---|---|
watchdog-starved |
the watchdog counter ran out without being refreshed | the console resets, mid-anything |
system-stack |
XSP crossed above 0x6C00 into the BIOS's own page |
documented as a restart or a power-off |
flash-busy-fetch |
the CPU fetched an instruction out of a flash chip that was programming or erasing | that chip answers status, not memory: the CPU is running status bits (STORAGE §5.0c) |
Each is counted, never fatal: a real console does not stop at the offending
instruction either, it carries on into whatever follows. Each carries the PC that
committed it, and --hw-guard turns the first two into a stop when you want the run to
halt exactly there.
flash-busy-fetch is the one to know about, because it is the single unambiguous
signature of an entire family of save bug — a driver missing its di, a stub left in the
cartridge instead of copied to RAM, a chip reset sent to a chip still working. All three
kill a console and all three pass silently on an emulator whose flash has no busy
window, which is most of them. A clean run here is a claim about your save path that a
screenshot cannot make.
6. Verifying a port against the original¶
When a game is ported from another machine, an emulator of the original is the best oracle. Practices that held up over a complete port:
- One record per game update, taken by a breakpoint at the end of the update loop — not by sampling at a fixed interval: when the original chains updates back-to-back, sampling skips some.
- Compare update by update, not frame by frame: the two machines may update at different rates.
- Inject the original's randomness. If the original draws random numbers from a register (a Z80
ld a,r, for instance), log the value at each draw with a breakpoint and replay the list in the port's bench build. If that code sits in a banked area, filter by context: the same address is another routine in another bank. - Choose the reference machine at the right refresh rate. An emulated 50 Hz machine is fine for update-by-update comparisons but wrong for any duration in seconds if the original is a 60 Hz game: one port had timed a game-over screen and a door animation on the 50 Hz machine.
- Force state between two updates, not at an arbitrary time (a breakpoint at the end of an update), otherwise results shift by ±1 update at random.
- A bench that reports "identical" may be empty: check that what is compared actually happened (bullets spawned inside a wall compare equal because nothing exists).
- Benches compare state, not the picture: capture the screen after each change too — an export offset bug went unseen by fully identical benches.
- A one-update difference: log both update counters per update before touching the code; the bench may be the one out of step.
- Keep the original's oddities when they influence play, mark them in the code and list them.
Quick Reference¶
| Item | Details |
|---|---|
| CPU profiler instrument | ngpc_debug_begin() / ngpc_debug_end() around game logic only |
| Bar: green | < 80% CPU |
| Bar: yellow | 80-100% CPU |
| Bar: red | > 100% — frame overflow |
| Profiler register | HW_RAS_V (0x8009) — current scanline |
| Log buffer size | ~288 bytes RAM |
| Log entry format | Hex: "LABEL: 0x1234" — String: "LABEL: str" |
| Log overflow | Ring buffer — oldest entry overwritten |
| Assert in debug | On-screen fault page + blinking loop |
| Assert in release | Compiled out (zero cost) |
| Disable all debug | NGPC_DEBUG=0 + NGP_PROFILE_RELEASE=1 |
See Also¶
- Game Loop — VBlank sync,
ngpc_vsync(), frame budget structure - Hardware Registers —
HW_RAS_Vregister address, scanline counter - Effects and Raster —
ngpc_text_print()for displaying debug values on-screen - Build Toolchain — Makefile CDEFS, release build configuration