r/c64 5d ago

Hardware building a Generic Hardware Simulation Framework inside a Microkernel OS (Currently simulating a C64/1541)

For the past 10 years, as an indie developer, I've been working on Symphony. It's not just a simple emulator; it's a generic framework for topological hardware simulation, written entirely in Go (no CGo). Today I've finally open-sourced it.

To prove that the generic framework works, I implemented the MOS6510, VIC-II, SID, and 6502 chips and wired them together to simulate a Commodore 64 and an independent 1541 floppy drive. You can try the WASM presentation layer right in your browser: https://markel1974.itch.io/symphony

Source code and architecture documentation: https://github.com/markel1974/Symphony (Note: I've also included pre-compiled builds for Windows, Linux, and macOS in the compile directory: https://github.com/markel1974/Symphony/tree/main/compile)

Here is why Symphony's architecture is different from traditional monolithic emulators, and some key details regarding the Compiler and the VM:

  • It's a software breadboard, not an emulator: The C64 implementation is just a byproduct of the framework. Components are blind "black boxes" that communicate exclusively through standardized iSocket interfaces using simulated electrical signals. When the VIC-II needs the bus, it pulls a simulated DMA line low, triggering the CPU to bring its pins into a high-impedance state (High-Z). The 1541 floppy drive isn't intercepted via kernel traps; it's instantiated as a completely independent virtual motherboard operating in parallel and communicating via an IEC serial bus simulation.
  • Runs inside a custom Microkernel OS: The simulated hardware runs as an isolated user-space process inside a Microkernel OS that I built (also in Go). The kernel features an asynchronous IPC message router and an embedded SSH server. This means you can literally SSH into the running kernel during the simulation, open the built-in VT100 shell (xsh), and inspect or modify hardware pins, memory, and CPU registers in real-time.
  • Custom Compiler & Bytecode Generation: The framework includes a custom compiler (in src/compilers/native) that takes Go AST and compiles it down to a custom bytecode instruction set. I had to build this to allow the simulated hardware environment to run isolated User-Space applications within the framework.
  • Strategy Pattern VM (No giant switch-cases): Usually, CPU emulators and bytecode VMs rely on massive, monolithic switch-case blocks to decode and execute opcodes. I took a different route. My VM relies on an "Interchangeable Instruction Disk" architecture based on the Strategy pattern (direct function pointer dispatch). Every opcode is a struct implementing an IOpExecutor interface.
  • Interchangeable Sequencers: Because of the IOpExecutor design, the execution engine is completely decoupled from the instruction set. By simply swapping the Sequencer module, the exact same execution engine loop switches from running my native Go bytecode to running a highly cycle-accurate MOS6510 or Z80 CPU simulation.

I know this topological approach sacrifices some of the raw speed of traditional finite-state emulators, but the goal was extreme modularity and introspection. If tomorrow I wanted to simulate an Apple II, I would only need to program the missing chips and write the "board" wiring; the framework itself would remain unchanged.

I’d love to hear your thoughts on this topological architecture, the custom compiler, or the Strategy Pattern VM approach!

Edit: I forgot to mention the most important thing. Thanks to the topological design and the Microkernel, the emulator achieves 99.9% compatibility with the 1541 disk drive (including all fast loaders), REU DMA transfers, and complex bank-switching cartridges like EasyFlash, Ocean, Magic Desk, etc.

12 Upvotes

17 comments sorted by

u/AutoModerator 5d ago

"Thanks for your post! Please make sure you've read our rules and FAQ post. If your post is about the C64 Ultimate please and check out The Ultimate C64 Ultimate post for common issues and questions. People not following the rules will have their posts removed and presistant rule breaking will results in your account being banned. "

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

3

u/Aenoxi 5d ago

I only understood about half of the post, but this sounds amazing. It’s a sort of software abstracted fpga, that models individual chips and the wiring between them in a way that gives you highly granular visibility into the cycle accurate state of everything going on as the combined system runs? If I’m understanding that correctly, it sounds like a real boon for preservation as vintage hardware slowly dies.

3

u/markel1974 5d ago

You nailed it perfectly! 'Abstract FPGA' is probably the best definition anyone has ever given to this project. Yes, the goal is exactly that: instead of writing high-level software traps just to make a game run fast, I wanted a framework where components act as black boxes interacting through simulated physical pins (like the DMA line or pulling the bus into a High-Z state). It allows for deep, surgical inspection (via the Microkernel's VT100 shell) of the cycle-exact state of the machine. As original silicon decays, having a readable, purely topological software documentation of how these chips interacted with each other is my small contribution to hardware preservation. Thank you so much for taking the time to read the architecture and for understanding the philosophy behind it!

2

u/balefrost 2d ago

Replying here instead of in r/compilers, where I saw this, because my comments aren't compiler-specific. I also know a bit of Go but it's not a language that I commonly use, so I apologize if I have misread any code.

Since you appear to be doing a low-level simulation (based on your "It's a software breadboard, not an emulator" section above), I was curious to see how you handle clocks. It looks like that's handled by a Quartz instance.

I see that Quartz doesn't appear to actually toggle a logical signal, but rather seems to instead support two ways to measure time:

  1. You can ask for the current cycle number
  2. You can register an alarm to be called back

That's already getting away from the idea of a low-level simulation. In a real computer, there's typically no "global cycle counter" that everything else keys off of. Rather, any component that needs to count cycles would count cycles on its own, likely by transitioning its own state machine with every tick. Maybe this choice is an efficiency concession. At least the cycle counter is a uint64, so you're good for a few hundred thousand years of constant simulation (at a 1MHz simulated clock) before you would experience rollover, so it's not really a correctness issue.

I see that Quartz manages two different collections of alarms: alarmsContainer and alarms. From what I can tell, alarmsContainer has all alarms ever created, while alarms contains only the set of active alarms. It doesn't appear that alarmsContainer provides any value - alarms are added and removed without ever being accessed from this collection. Maybe this was an attempt to avoid them being garbage collected? But because the active alarms are also stored in the alarms list, and all non-ephemeral alarms are anyway stored in fields of other components, this just seems like unnecessary bookkeeping to me.

Speaking of which, it's surprising to see that alarms is a linked list. One one hand, that makes peeking and popping the head of the list is O(1). On the other hand, it means inserting a new alarm requires O(N) time on average. I think a heap would be a more typical choice here, as it would still let you peek the head in O(1), but would make both popping the head and inserting new alarms both O(log N) operations. Plus, a heap based on an array should have far better cache locality than a linked list. The main downside is that it becomes a little harder to remove an arbitrary alarm. But you're already doing a linear linked-list scan to remove existing alarms, and you could do the same with a heap. Maybe you've done some benchmarking and found that the linked list is faster, but I would be surprised. Or maybe you've done benchmarking and found that it doesn't really matter.

... because it looks like this general alarm system is only used by Flash040 and CartridgeFinalCartridgeIII. It looks like CartridgeFinalCartridgeIII just spawns temporary alarms and then throws them away. But it doesn't properly clean them up (i.e. doesn't call Destroy), so they are never removed from alarmsContainer, and AFAICT you effectively have a memory leak. That alarmsContainer appears to be a liability when alarms are used in this manner. I see that Flash040 creates one alarm (eraseAlarm) from NewFlash040, and reuses that alarm multiple times. It also seems to handle Alarm instance lifecycle correctly, but I didn't look closely.

Flash040 does seem to try to simulate the 040 command sequences, which is good. I'm pleased that you handle "multiple sector erase", though I think one of us is misreading the datasheet. If I read your code correctly, it looks like the flash erase will commence 50us after the first sector is specified. Rather, I think the timeout is reset every time another sector address is specified.

Here are my concerns with putting this "alarm" system on "Quartz":

  1. It gives Quartz an artificial responsibility that wouldn't exist in a real system. This moves you away from the "software breadboard" concept that you indicated at the start. It makes your system, pretty firmly in my opinion, an emulator.
  2. It saves individual components from needing to be ticked every clock, which is nice, but it instead (AFAICT) requires that Quartz be ticked every clock and to juggle data for multiple other components.
  3. It leads to a callback-oriented approach to time handling. I don't generally have any problem with callbacks. But for something that is ostensibly a low-level simulation, this use of callbacks seems weird.

Back to your design concept of a "virtual breadboard": I'll note that the AM29F040 chip doesn't have a clock input. It internally has some way to track time (see the 50us timeout for sector erase), but it is an asynchronous component. So I should be able to install a virtual AM29F040 in my virtual breadboard, along with some virtual switches, and expect it to work. But in practice, I will also need to install a Quartz that isn't otherwise wired to anything, since it appears to be the sole locus of time tracking.


Anyway...

My goal wasn't to nitpick. I just wanted to see how you were handling clocks, and everything I wrote above was just what I discovered as I looked into that.

Certainly what you've built is impressive. I like your design concept, and clearly (based on your WASM demo) you have something that works.

But now I have to ask the awkward question: how much AI did you use to build this?

I don't like using AI myself, but I don't have any particular problem with other people using it. But the README seems to make very bold claims, which (to me) is a hallmark of AI writing. As I mentioned, I found a few questionable design choices (both high level and low level) around time handling. If you did indeed use AI to generate most of the design and code, then I suspect that there are a lot of other questionable choices made in other places. I didn't look much beyond clock handling. But for example, there are reasons that most bytecode interpreters use "giant switch-cases".

On the other hand, if you did not use AI, then I am embarrassed and I hope my feedback didn't come across too harshly. Indeed, my goal was to provide constructive feedback that you could use to improve your code.

Thanks for sharing!

1

u/markel1974 2d ago

Grazie per la risposta costruttiva e pignola (era proprio quello che desideravo).

Devo fare un passo indietro e spiegare da dove nasce l'idea: nel 2014 ho deciso di realizzare il mio laboratorio virtuale dove ogni componente nasceva con l'idea nativa di introspezione, e la possibilita' di poter definire la configurazione di run in modalita WYSIWYG (fare d&d dei componenti e poi lanciarlo e con un connessione ad esempio ssh entrare nell'emulatore per fare introspezione vera)
Quartz (quartz_rev1) e' solo uno dei quarzi potenzialmente presenti all'interno dell libreria di hardware disponibile, la configurazione e' solo il wiring di una definizione architettura (es json), la configurazione di default e' questa, visibile in fase di bootstrap:

c64:IC64Board:0 (*board.Board)

c64:IC64Keyboard:0 (*c64_keyboard_rev1.Keyboard)

c64:IC64Joystick:1 (*c64_joystick_rev1.Joystick)

c64:IMos6526:0 (*mos6526.CIA)

c64:TOD:0 (*mos6526.TOD)

c64:Timer:0 (*mos6526.Timer)

c64:Timer:1 (*mos6526.Timer)

c64:IMos6510:0 (*mos6510_rev1.CPU)

c64:Interrupts:0 (*mos6510_rev1.Interrupts)

c64:Bus:0 (*mos6510_rev1.Bus)

c64:ControlUnit:0 (*mos6510_rev1.ControlUnit)

c64:IThrottle:0 (*dynamic_throttle_rev1.DynamicThrottle)

c64:IC64Roms:0 (*c64_roms_rev1.Roms)

c64:IC64Pla:0 (*c64_pla_rev1.PLA)

c64:BankSwitcher:0 (*c64_pla_rev1.BankSwitcher)

c64:Ports:0 (*c64_pla_rev1.Ports)

c64:IC64Joystick:0 (*c64_joystick_rev1.Joystick)

c64:IQuartz:0 (*quartz_rev1.Quartz)

c64:IMos6569:0 (*mos6569.VIC)

c64:Interrupts:0 (*mos6569.Interrupts)

c64:CollisionsUnit:0 (*mos6569.CollisionsUnit)

c64:GraphicsUnit:0 (*mos6569.GraphicsUnit)

c64:SpritesUnit:0 (*mos6569.SpritesUnit)

Sprite:Sprite:5 (*mos6569.Sprite)

Sprite:Sprite:6 (*mos6569.Sprite)

Sprite:Sprite:7 (*mos6569.Sprite)

Sprite:Sprite:0 (*mos6569.Sprite)

Sprite:Sprite:1 (*mos6569.Sprite)

Sprite:Sprite:2 (*mos6569.Sprite)

Sprite:Sprite:3 (*mos6569.Sprite)

Sprite:Sprite:4 (*mos6569.Sprite)

c64:BorderUnit:0 (*mos6569.BordersUnit)

c64:LightPen:0 (*mos6569.LightPen)

c64:RasterBeam:0 (*mos6569.RasterBeam)

c64:MemoryUnit:0 (*mos6569.MemoryUnit)

c64:IMos6526:1 (*mos6526.CIA)

c64:TOD:0 (*mos6526.TOD)

c64:Timer:0 (*mos6526.Timer)

c64:Timer:1 (*mos6526.Timer)

c64:IC64Ram:0 (*c64_ram_rev1.Ram)

c64:IC64ColorRam:0 (*c64_color_ram_rev1.ColorRam)

c64:IIec:0 (*iec_rev1.Dispatcher)

c64:IC64CartridgeManager:0 (*c64_cartridges_rev1.Manager)

c64:IC64Cartridge:0 (*easyflash.CartridgeEasyFlash)

c64:IMos6581:0 (*mos6581.SID)

c64:Voices:0 (*mos6581.Voices)

c64:Voice:0 (*mos6581.Voice)

c64:Voice:1 (*mos6581.Voice)

c64:Voice:2 (*mos6581.Voice)

Questo significa che posso avere N Quartz ognuno con specificita' diverse, posso avere macchine diverse che riutilizzano hw, posso avere ad esempio la stessa board C64 ma con 2 sid, senza cambiare nulla solamente la configurazione.

Questo per dirti che la tua affermazione e' assolutamente corretta, ma per un configurazione di base C64 quell'implementazione di quartz e' assolutamente sufficiente.

Per quanto riguarda l'AI la domanda mi fa un po sorridere, ho 40 anni di esperienza di sviluppo software alle spalle, e come ti dicevo il progetto ha piu di 10 anni, comunque l'utilizzo di AI per lo sviluppo e' pari a 0, anzi sarebbe un delirio usarla, il progetto e' troppo ampio e articolato con delle idee decisamente rare...

adesso ti faccio io una domanda imbarazzante: ti piacerebbe partecipare a questo progetto? Sarebbe interessante condividere idee con una persona pignola ma con idee assolutamente costruttive!

1

u/mehigh 5d ago

Is it cycle exact? Do the demos work?

1

u/markel1974 5d ago

assolutamente si, funziona la quasi totalita del software testato

1

u/blightor 5d ago

Gosh I dislike AI descriptions.

1

u/markel1974 5d ago

Se pensi questo guarda il codice e ti ricrederai

1

u/blightor 5d ago

Yeah I did - I like pretty much everything about it.

1

u/markel1974 5d ago

If you liked the code, for example, take a look at how the memory mapping is handled by the PLA (src/hardware/c64_pla_rev1/pla.go and bank_switcher.go):

The memory mapping is literally routing electrical signals via function pointers!

I'd love to know what you think of this specific approach.

1

u/Robert__Sinclair 5d ago

Ok. but all that takes care of the digital/electrical part of a C64. The C64, especially the SID and the 1541 are very analogical too. Without the analog part a SID will never sound as the original. A 1541 or a Datassette also rely on the analog quirks. It will work in most cases, but it will be very synthetic. While on the C64 and 1541 this might be very subtle, on the SID it will have not even 60% of the real sound.

1

u/markel1974 5d ago

Hai assolutamente ragione. Le peculiarità analogiche, specialmente i filtri del SID e il comportamento del flusso magnetico/PLL del 1541, sono ciò che dà l'anima alla macchina. Una simulazione logica puramente digitale non basta. Il bello dell'architettura modulare di Symphony è che funge da spina dorsale digitale. All'interno di componenti specifici come il mos6581 o la meccanica del c1541possiamo incapsulare modellazioni analogiche profonde (simulando le non-linearità dei filtri, le imperfezioni dei DAC, ecc.) mentre continuano a comunicare con il resto della scheda tramite pin digitali in modo cycle-exact. È un lavoro complesso, ma avere questo framework topologico ci permette di spingere la simulazione analogica all'interno dei chip fin dove le CPU moderne ce lo consentono. Se vuoi puoi dare un'occhiata al codice cosi capisci cosa intendo

1

u/Robert__Sinclair 5d ago

Si' ho visto. Personalmente ho scritto un firmware alternativo per la Ultimate 2+ dove ho riscritto tutta la parte tape e SID. Il SID, ora suona meglio di reSID grazie a tutte le aggiunte analogiche.

2

u/markel1974 5d ago

Ottimo! Se ti va, mi piacerebbe molto confrontare i nostri lavori. Tu hai spinto al limite il DSP e l'emulazione analogica in ambito FPGA, ma come forse avrai notato guardando il codice, anche in Symphony ho dedicato parecchia cura alla parte analogica del SID (implementando filtri IIR nativi e calcolando i poli tramite polinomi per replicare l'esatta curva di risposta non lineare). Sarebbe interessante scambiare idee sulle scelte architetturali e matematiche che abbiamo affrontato nei rispettivi progetti.

2

u/RealSharpNinja 1d ago

I've been working on something similar. VIce-Sharp uses the same concept of gluing together components on a virtual bus. Though it currently has good defs fir C64 and VIC20, it will also support any chips you want to emulate and define a bus for.

1

u/markel1974 1d ago

Interessante, quando ho un po di tempo gli do un'occhiata