I wrote my first emulators somewhere in the mid-to-late '90s, my first cycle-accurate emulator circa 2000 and have managed to outdo even that, writing a clock-sign-transition-accurate emulator — accurate to the half-cycle.
Two problems usually recur:
- each component's proper implementation may be not only obtuse but possibly unknown; and
- ensuring that the components act simultaneously is difficult.
Most official documentation provides a programming model for a component, not an exhaustive documentation of its internals. E.g. many sound chips have a noise channel. There's almost always an easy way to derive the pattern of noise that channel will produce. It's almost never documented. If it isn't documented, how are you going to figure it out?
If a CPU is running at 3 Mhz and it has a graphics chip that is also running at 3 Mhz, then both are doing work at the same time. If they can interact on any cycle, then you need a way of being able to figure out exactly what state each should have been in at each synchronisation point.
A simplification often made in the old days was that the CPU operates a whole opcode at a time. E.g. if it encounters
PUSH HL then it puts
HL onto the stack, adjusting the stack pointer and memory underneath, then magically and instantaneously warps forwards in time by the eleven cycles that should have taken.
Furthermore, if the graphics chip draws one line every 228 cycles then they can interleave by doing 228 cycles of work on the CPU (with a running error count to deal with not necessarily being able to stop exactly at 228), then drawing one more line of the display.
That sort of emulator is not cycle accurate. All changes the processor made within 228 cycles are seemingly batched up and performed instantaneously, immediately before the graphics chip managed to draw any of the line.
The densest scheme for cycle accuracy, in an example two-chip machine, is: run the CPU for one cycle. Then run the graphics chip for one cycle. Then repeat. Both components need to be written to be able to run a cycle at a time, and your computer's real CPU is going to do a lot of jumping around and cache thrashing.
An obvious improvement occurs for one-directional signalling — e.g. where a CPU may send commands to something like a sound chip, but doesn't receive anything back. Just keep a log of the signals, and then compute their effect later, or even asynchronously. It's producer-consumer, possibly cooperatively scheduled.
A related approach is not explicitly to keep a log, but just to write the dependency to be able to run for N cycles, allowing it to assume constant machine state throughout. E.g. suppose graphics are always drawn from the address range 0x4000–0x8000. Then keep a count of cycles since you last let the graphics processor run. Any time the CPU writes to the graphics range, run the graphics output for that number of cycles and zero the count. Then write the value and continue. You'll do better than lockstep cycle-by-cycle.
As to capturing quirks, that's usually just a matter of understanding the way in which the hardware operates. E.g. the Atari 2600 is very close in ideology to the discrete hardware that it replaced — there are no centralised counters, because propagating a number is a heavyweight operation, there are lots of individual local counters with a common clock and the ability to reset each other. You can't tell a sprite to appear at x=23 because the thing that draws the sprites never knows what the current x is. It just knows that it will receive 160 clocks per line (umm, simplified a little, but let me have it) so whatever it does, it must repeat every 160 cycles. And you can programmatically tell it to reset. It will always trigger sprite output when it overflows. So you can place sprites by telling it to reset at an appropriate moment.
If you model it as a bunch of distinct counters, you'll get much of the correct behaviour for free. If you try to get away with storing the x at which a sprite should appear, and correlating that with a global clock then you've attempted to change the problem and you'll probably end up getting it wrong.