On the C64 there was no threading, so how did games handle treating at the same time music and game code? Music requires exact timings, and the SID had to be instructed to change the note at the right time. How did they achieve this? I suspect master usage of interrupts, but I want to know more.
Your assumptions about timing and interrupts are correct. Actually, it is surprisingly easy to add SID music to games because they tend to follow a basic pattern.
- Compose the tune in a C64 SID editor. There are many of these.
- The SID editor generates a SID file, which actually includes both the data and code for playback. This file is easily incorporated as a DATA block into the game's assembly code. Later, attempts were made to standardize the format for "PSID files" for the HVSC project.
- Initialize the code in the SID file with a call to some specified base/load address.
- Set up a VBLANK interrupt.
- In your VBLANK IRQ handler, call the subroutine provided in the SID file. It will be a specific offset from the base/load address.
Since the heavy-lifting is already done by the SID editor, the programmer's part is easy. As with any game on a platform like the C64, there's normally already a VBLANK IRQ to handle things like sprite updates. So it just becomes one extra sub-routine to call in your handler.
The correct playback frequency is "guaranteed" based on the video running at either 50 Hz (PAL) or 60 Hz (NTSC). Naturally, this means you have to compose the music specifically for either PAL or NTSC C64's, lest you have the playback speed faster or slower than you intended for the composition.
Mostly, the same way that embedded code still does it today.
Embedded code doesn't use threads. In general, it also makes less use of stack and usually no use at all of dynamic memory allocation. Instead we make widespread use of global variables (or at least static variables) which are statically allocated. Reasons are twofold: firstly it's faster, and that matters when your processor is not very powerful; and secondly if you have limited stack/heap available then running out of stack/heap is a very real problem. If what you're programming is something like a game where the processing loop keeps running with the same data, instead of a word processor where new files have to be loaded in, then there's no point in making life harder for yourself.
Of course if you have lots of global variables then you have an encapsulation problem. Structured programming solves that problem at the cost of stack/heap usage, and associated processing overhead. The alternative of course is that you acknowledge the problem exists and be careful. If the expected interfaces to a block of code are clear, it doesn't matter too much if you can see the internals, so long as you're careful not to mess around with them. Or of course the third option is that you write spaghetti code - and that was not at all uncommon back in the 8-bit days!
Back to the processing. As described by BrianH, you'd have an interrupt triggered at a regular time interval. That time interval gives you a window of processing, during which all your game play, all your sound, all your display updates, and anything else needs to happen. This is real-time processing, and there is no difference conceptually from running a game like this, or running the control loop on your car engine controller.
For anything which doesn't need to be updated that fast, you can run that processing every 10 ticks of the main loop, say. Or if you have a larger chunk of calculation which doesn't have to be run fast but takes a lot of number-crunching, then you can run part of it each time through the main loop. Both are popular strategies in embedded code, if you have a low-end micro without prioritised interrupts.
There are already good answers, so I just add a few thoughts. But first, to make this a complete answer, the basic concept:
You're doing all the SID register updates from an ISR (interrupt service routine) that's triggered regularly. That's by the way the "lowest level" progamming model to achieve some sort of parallelism. Other things that should work in the background (like e.g. querying input devices like keyboard, joystick, mouse) are often implemented in ISRs as well.
So yes, basically it's simple. Whether you use a music editor generating some code or you write all the code yourself, it's always the same scheme: One routine initializes the sound chip and some internal data, this is only called once to start the music. The other routine is designed to be called regularly and updates registers as needed.
Now you need to decide what you use as the source of your interrupt. The C64 has CIA chips with timers that can generate interrupts, they seem like a good choice at first, as the timing is guaranteed. Most of the time, you will instead use the VIC (graphics chip) for the interrupt source anyways, even if this means added complexity if your music should sound somewhat similar on PAL and NTSC machines, because they have differing frequencies. The reason is that you need to do other work in VIC-triggered interrupts anyways for anything displayed on the screen that is non-trivial, and often the timing for this is critical. So, using a timer interrupt for music, it would happen at different places on the screen, and eventually delay your timing-critical VIC interrupt from being served, thus ruining your display. Therefore, you pick a place on the screen where you know you don't have to do anything else (might be the border area), arrange to get a raster IRQ from the VIC there and in the ISR, call your music play routine.
This covers most needs and is indeed very simple to do, escpecially if you use a music editor (or tracker) to compose your music, which generates ready-to-call routines for you. But there are at least two scenarios that are a lot more complex:
You might need/want sound effects that require SID register updates much more often than once per screen frame. A typical example is playback of digital samples. I won't go into depth here, as there are a lot of approaches, but it requires tricky coding and you are very limited with what can happen on the screen at the same time.
You need to access the floppy drive while playing music. Unfortunately, this can't be done using the builtin I/O routines. For ...*) reasons, floppy I/O is done completely in software on the C64 (bit-banging) and the builtin routines are very timing sensitive, they only work with IRQs completely disabled. The solution here is to use own routines with a different communication protocol (which requires to reprogram the floppy drive side as well). To know more about that, ask google for "IRQ loaders".
*) basically: crappy/buggy hardware that didn't work as designed, namely the VIA I/O chips in the floppy...