I do the following:
1) Keep a CPU timestamp (obviously). This timestamp, is in "master cycles" (see below)
2) Keep a PPU timestamp -- same idea as CPU timestamp. Again, in "master cycles"
3) Keep a Scanline Counter (-1 through 240).
4) Keep a scanline cycle counter (0-340)
5) Keep a 'VBlank Time' var (this will be more or less constant, but it changes between PAL/NTSC modes).
I do the 'main' timestamps in what I call Master Cycles. These are neither CPU nor PPU cycles -- rather they're a higher resolution so that the ratio between PAL CPU:PPU cycles can be manitained.
- For every 1 NTSC CPU cycle that passes, I increment the CPU timestamp by 15
- For every 1 PAL CPU cycle that passes, I increment the CPU timestamp by 16
- For every 1 PPU cycle that passes (NTSC or PAL), I increment the PPU timestamp by 5
I'd recommend you take PAL into account as soon as possible, as relying on the 3:1 NTSC ratio will make things a pain in the ass later when you finally do decide to add PAL support.
As for implimentation -- the two big functions of my program are RunCPU(int runto) and RunPPU(int runto). RunCPU will emulate CPU instructions until the CPU timestamp reaches/passes the given 'runto' timestamp (typically, RunCPU is only called once in my emu and it told to run the CPU for an entire frame's worth of time). RunPPU does the same thing, but runs the PPU (and renders pixels) until the given timestamp is reached (typically, RunPPU is called many times per frame).
Making these functions work together is simple. If you keep the CPU timestamp updated as you emulate 6502 instructions -- you simply pass the CPU timestamp to RunPPU when you want the PPU to 'catch up' to the CPU. You should have the PPU catch up everytime something on the system which affects drawing changes, and also when the status of the PPU will alter CPU action (in the case of register reads). This includes (but is not necessarily limited to) PPU register writes/reads, Nametable mode changes, and CHR swapping.
For instance when your game is swapping CHR -- updating the PPU would be as simple as something like the following:
Code:
void SwapCHR(int where,int page)
{
RunPPU( cpu_timestamp );
// swap CHR here
}
The tricky part now, is making a RunPPU function which can be entered and exited on ANY given PPU cycle. This is one reason why I keep those Scanline and Scanline Cycle counters I mentioned earlier. If you keep track of the scanline and scanline cycle that the PPU is in, it makes PPU emulation easier. But you also need to keep the main timestamp to keep it synced up with the CPU.
My RunPPU function looks kind of like this:
Code:
void RunPPU( int runto )
{
if( ppu_timestamp < vblank_cycles ) /* vblank_cycles is the number of master cycles VBLank lasts. For example on NTSC this is (20 * 341 * 5) */
{
ppu_timestamp = vblank_cycles; //do nothing in vblank
scanline = -1; // set scanline counter to pre-render scanline
scanline_cycle = 0; // start of cycle 0 of that scanline
}
if( ppu_timestamp >= runto ) return; /* see if we're done -- this should be done every time ppu_timestamp is adjusted */
if( scanline == -1 )
{
// do pre-render scanline stuff
}
while( scanline < 240 )
{
while( scanline_cycle < 256 )
{
/*render 1 pixel, load another tile if needed, adjust PPU address where needed, etc */
scanline_cycle++;
ppu_timestamp += 5;
if( ppu_timestamp >= runto ) return;
}
while( scanline_cycle < 340 )
{
//similar things here
}
scanline_cycle = 0;
scanline++;
}
}
That's gives a rough idea.
Anyway -- there are rooms for optimizations. The two big things I can think of are:
- detecting $2002 read loops and running the PPU until $2002 status changes
- having a faster version of RunPPU which renders full scanlines which can be called when the PPU is to render a full scanline.
Anyway, at the end of the frame, you'd make sure the PPU is caught up to the CPU again, then you subtract CPU/PPU timestamps by the number of cycles in that frame (do not reset the timestamps to 0! Otherwise cycles which "spilled" over to the next frame would be lost).