08-03-2026, 05:43 AM
@sodthor sure. The method is short; here is the whole of it.
1. The emulator models wait states, but does not turn them on by itself.
This is the big one, and it cost us weeks before we noticed. NgpCraft's core defaults to cart_wait=0, so instruction fetch is free — while on real silicon every instruction byte fetched from cartridge flash costs 3 ticks. Same scene, same build:
without wait states : 99,638 cycles/frame
with wait states : 286,958 cycles/frame factor 2.88
The calibrated values (silicon measurements by the emulator author; we re-measured vram_wait against hardware ourselves and got the same):
m.set_cart_wait(3) # instruction fetch from cart
m.set_cart_data_wait(0) # data reads from cart - measured; an earlier guess of 5 was wrong
m.set_ldir_cost(14) # datasheet says 7, but that is a floor
m.set_vram_wait(3) # single-byte VRAM access
That single default is why plausible optimisations kept measuring "exactly zero" for us: the saving sat in instruction fetch, which was not being billed. It also means instruction count matters as much as cycle count — shorter code is directly faster. Same reason padding a struct to a power of two can backfire: the field offsets fall out of the 8-bit displacement, and every extra instruction byte costs another three ticks.
2. Count game frames through the scroll register, not through time.
No symbol table needed, nothing to instrument. SCR1_Y sits at 0x8033 and is readable from outside the machine:
cycles per game frame = total_cycles / (pixels_scrolled × game_frames_per_pixel)
Run both ROMs until the same number of pixels has scrolled, and you are comparing equal amounts of game, not equal amounts of wall clock.
3. Never measure at the frame cap.
If the main loop waits for VBlank, every build reports the same number — you are measuring the wait, not the work. We build the measurement ROMs with the VBlank wait removed entirely.
The same trap applies to any on-screen average: ours is VBlanks per 30 frames, so at 20 fps it can never read below 090, no matter how much you save. I once handed out test ROMs with predictions of 075 and 085 that were arithmetically unreachable, and spent a while wondering why hardware "disagreed".
4. The scene is part of the measurement.
The single most expensive lesson here. One optimisation pass measured −18 % in the tower band, −8 % at level start and −3 % in an enemy-heavy scene. All three numbers are correct; they just answer different questions. On the user's actual hardware, in his actual scene, that −18 % showed up as 1 %.
So: decide first which scene the game really drops frames in, and measure there. A block breakdown without naming the scene is worthless. And for A/B comparisons on hardware you need a frozen scene — fixed enemies, scroll held, no spawns — because free play is not repeatable and an 8 % change disappears in the noise.
Two smaller traps, each of which cost us a day:
make does not always rebuild. Change only a #define and the object file may be reused. We once measured a "new" build and got a byte-identical result — same cycles and same instruction count. That exact equality is the tell: a real code change never reproduces it. Delete all objects, not just the one for the file you edited.
A failed build leaves the previous ROM sitting there. Our link step aborted on a RAM overflow, the old .ngp got picked up, and the comparison read "+0.0 %" — which looks exactly like "this change does nothing". Now the build helper hashes the ROM before and after and refuses if it did not move.
And one that is not about tooling at all: you cannot measure what a block costs by switching it off, if that block creates work. We disabled the enemy dispatch loop and it reported 11.9 %. The real cost was 1.3 % — the rest was the spawning, drawing and collision work that no longer happened because no enemies existed. For that kind of block, replace only the loop mechanics and keep the effect.
The whole thing is about 200 lines of Python around the emulator's native core: one script that patches #define switches, builds, and saves the ROM aside; one that runs two ROMs and prints cycles per game frame with the delta. Happy to clean them up and post them if that is useful to you — they currently have our paths baked in.
1. The emulator models wait states, but does not turn them on by itself.
This is the big one, and it cost us weeks before we noticed. NgpCraft's core defaults to cart_wait=0, so instruction fetch is free — while on real silicon every instruction byte fetched from cartridge flash costs 3 ticks. Same scene, same build:
without wait states : 99,638 cycles/frame
with wait states : 286,958 cycles/frame factor 2.88
The calibrated values (silicon measurements by the emulator author; we re-measured vram_wait against hardware ourselves and got the same):
m.set_cart_wait(3) # instruction fetch from cart
m.set_cart_data_wait(0) # data reads from cart - measured; an earlier guess of 5 was wrong
m.set_ldir_cost(14) # datasheet says 7, but that is a floor
m.set_vram_wait(3) # single-byte VRAM access
That single default is why plausible optimisations kept measuring "exactly zero" for us: the saving sat in instruction fetch, which was not being billed. It also means instruction count matters as much as cycle count — shorter code is directly faster. Same reason padding a struct to a power of two can backfire: the field offsets fall out of the 8-bit displacement, and every extra instruction byte costs another three ticks.
2. Count game frames through the scroll register, not through time.
No symbol table needed, nothing to instrument. SCR1_Y sits at 0x8033 and is readable from outside the machine:
cycles per game frame = total_cycles / (pixels_scrolled × game_frames_per_pixel)
Run both ROMs until the same number of pixels has scrolled, and you are comparing equal amounts of game, not equal amounts of wall clock.
3. Never measure at the frame cap.
If the main loop waits for VBlank, every build reports the same number — you are measuring the wait, not the work. We build the measurement ROMs with the VBlank wait removed entirely.
The same trap applies to any on-screen average: ours is VBlanks per 30 frames, so at 20 fps it can never read below 090, no matter how much you save. I once handed out test ROMs with predictions of 075 and 085 that were arithmetically unreachable, and spent a while wondering why hardware "disagreed".
4. The scene is part of the measurement.
The single most expensive lesson here. One optimisation pass measured −18 % in the tower band, −8 % at level start and −3 % in an enemy-heavy scene. All three numbers are correct; they just answer different questions. On the user's actual hardware, in his actual scene, that −18 % showed up as 1 %.
So: decide first which scene the game really drops frames in, and measure there. A block breakdown without naming the scene is worthless. And for A/B comparisons on hardware you need a frozen scene — fixed enemies, scroll held, no spawns — because free play is not repeatable and an 8 % change disappears in the noise.
Two smaller traps, each of which cost us a day:
make does not always rebuild. Change only a #define and the object file may be reused. We once measured a "new" build and got a byte-identical result — same cycles and same instruction count. That exact equality is the tell: a real code change never reproduces it. Delete all objects, not just the one for the file you edited.
A failed build leaves the previous ROM sitting there. Our link step aborted on a RAM overflow, the old .ngp got picked up, and the comparison read "+0.0 %" — which looks exactly like "this change does nothing". Now the build helper hashes the ROM before and after and refuses if it did not move.
And one that is not about tooling at all: you cannot measure what a block costs by switching it off, if that block creates work. We disabled the enemy dispatch loop and it reported 11.9 %. The real cost was 1.3 % — the rest was the spawning, drawing and collision work that no longer happened because no enemies existed. For that kind of block, replace only the loop mechanics and keep the effect.
The whole thing is about 200 lines of Python around the emulator's native core: one script that patches #define switches, builds, and saves the ROM aside; one that runs two ROMs and prints cycles per game frame with the delta. Happy to clean them up and post them if that is useful to you — they currently have our paths baked in.

