Building Pulsar
I wanted to use a Dreamcast controller without the cable. That was the whole brief. I had an nRF52840 left over from a previous project, a controller from 1999, and no real idea how much sat between the two.
Pulsar is what came out: a small battery-powered board that slides into the controller’s expansion slot, speaks the controller’s own protocol on one side and Bluetooth LE on the other, and updates itself from a browser tab. This is the story of how it got there. It is mostly a story about being wrong in instructive ways, and about how many other people it took.
A slow start
The first commit is from May 2025 and has nothing to do with the Dreamcast. It is a Nordic dev kit blinking an LED four different ways, because that is how you learn a chip. Then life happened, and the next commit is from December, with the message “committing incomplete setup since I am not sure when I’ll get back to this.”
I got back to it in February. On the 6th the board read its first Maple bus frame. By the 7th it was parsing every button and presenting itself to a laptop as a gamepad. The fast part of this project was fast. Everything after was the slow part.
The bus
The Dreamcast talks to its controllers over the Maple bus, a two-wire scheme where each wire clocks the other. At 2 Mbps, a bit is one microsecond, and the controller answers a request within a couple of hundred. The approach that worked was to not be clever in real time: sample both wires into a large buffer as fast as the core can, then decode the buffer afterwards. The nRF52840 gets about four samples per bit that way, which is enough.
The first frames decoded as garbage. The decoder was treating the first falling edge on one line as the start of a phase, when the protocol starts on the other line, so every byte came out shifted by one bit. That was an afternoon.
The one I still think about came a week later. I had moved the same code from the dev kit to a
tiny XIAO module, and the XIAO captured ten bits where the dev kit captured hundreds. I chased
radio interrupts, pull-up strength, missing inline resistors and a pin conflict, and disproved
each one. The XIAO had been flashed without --release. In a debug build, the “set this pin
high” call becomes four nested function calls, and the delay loops run a few hundred times
slow. There was nothing wrong with the hardware. There was nothing wrong with the code. I had
just not typed one flag.
Becoming a gamepad
Bluetooth was supposed to be the easy half, and in one sense it was: a gamepad is a standard HID device, and the first version worked on the first evening. The hard part was deciding which gamepad to be.
Pulsar presents itself as an Xbox Wireless Controller. Not because I have any attachment to it, but because that is the one identity Windows, macOS, Linux and Steam all recognise without a driver. Getting it to actually pass as one took three rewrites of the descriptor, and each rewrite was forced by a host that disagreed.
Windows showed the device and then failed it with a Code 10. A real Xbox controller worked on the same PC, which ruled out the Bluetooth stack. Comparing byte for byte against a dump of the real thing, the input report was identical. The only difference was that I had trimmed out the rumble output report, and it turns out Windows’ driver will not initialise without one. So Pulsar advertises rumble, and always will, because the identity requires it.
BlueRetro, which lets you use Pulsar on an actual Dreamcast, mapped Start to the wrong button. It was not reading the name or the vendor ID at all; it fingerprints the descriptor itself, and mine was a plausible hybrid that matched neither a real Xbox pad nor a plain generic one. It fell through to the generic parser, which assigns buttons in order, and one missing button shifted everything after it. The fix was to stop declaring something plausible and copy the real device exactly. I built a small harness that runs BlueRetro’s firmware in an emulator so that lesson can never quietly un-learn itself.
And macOS, on the neutral “Dreamcast Wireless Controller” profile, would happily pair, report at a steady 66 Hz to any tool that asked, and show nothing at all to a browser. Apple’s game controller framework only surfaces vendor and product IDs it knows. A clean descriptor is necessary and not sufficient.
Sharing the antenna
One chip runs both the bus and the radio, and the radio does not negotiate. Nordic’s Bluetooth stack owns it outright, wakes it for every connection event on a schedule the host sets, and takes the CPU with it. A VMU screen write is about 1.65 ms of precisely timed bus traffic. If a connection event lands in the middle of it, the frame is corrupted, and if the firmware fights back by holding off interrupts, the stack asserts and the whole chip reboots.
My first answer was the Radio Timeslot API, which lets the application ask the stack for a guaranteed slice of CPU time. I pre-computed the whole waveform and blasted it out inside the slot. The signal was perfect. And Bluetooth fell apart: latency spikes, disconnects, and a fragility that outlived the slot itself. The stack will lend you the radio, but it does not forgive you for it.
The answer that stuck was the other thing the stack offers, radio notification: a signal just before and just after every radio event. Listen to it and you know exactly when the antenna is busy and when it is quiet, and the quiet gaps between connection events are six to ten milliseconds wide. So the firmware does bus work only in the gaps, and if a frame is lost anyway it just tries again on the next one. Nothing about the radio changes; the bus learns to wait its turn.
I did not trust that answer for a while. In June the firmware started crashing about once a minute, deep in the stack, and I spent two days blaming everything near it: the VMU writes, the notification gate, the acknowledgement mode, the frame rate. The culprit was the instrumentation I had added to find the crash. A timing probe was masking interrupts a few hundred times a second and a debug print was rendering inside a critical section, and Nordic’s documentation warns about exactly that blackout, nearly verbatim. The VMU was innocent. So was the gate. I had written the note “the VMU was working well before we started this process” on day one and then not believed it. The observation apparatus goes on the suspect list first.
By August, radio notification had gone from a workaround to the spine of the timing. The controller poll itself is now anchored to the quiet window, and collisions between the bus and the radio are not rare so much as structurally absent. If I had to name the single most useful thing I learned about this chip, it is that the stack tells you when it is about to use the radio, and everything else should listen.
The lottery
The strangest bug in the project had no reproducible cause for months. Identical source, built twice with a trivial change between, would time the bus differently: one build read every frame, the next garbled two thirds of them, and adding a log line could flip it. Part of it was the compiler landing the sampling loop at a misaligned address, which costs a Cortex-M4 one extra fetch cycle per iteration, and over twenty-four thousand iterations that was exactly the shortfall I was measuring. But that was one of three coupled clocks, and I only found the other two by disassembling archived good and bad binaries side by side instead of taking yet another capture. The sampling loop is now hand-written assembly pinned to an aligned address, and a script reads the compiled binary before every build and refuses to continue if that has been undone.
Reading the manual
For months the firmware never once detected a VMU, and nothing noticed, because every other VMU operation is transmit-only and a working screen never implied a working read. I proposed five theories and disproved all five on hardware before stopping to read the protocol spec, where the answer had been the whole time: a Maple device sets a bit in its own sender address for each accessory attached, and I was reading that byte and throwing it away. One method later, hot-plugging a VMU worked. Two disproved hypotheses in a row means the model is wrong, not the constants.
The shell
Pulsar has to slide into a slot Sega sized in 1998, so the outside of the case is not mine to design. It is a VMU, or it does not fit. I tried to have someone make the model for me first, and that did not work out, so I opened Blender for the first time and learned it on this.
Wesk had scanned a real VMU and shared the scan, which was the gift that made it possible. A scan is not a model, though. It is a cloud of triangles with no idea which of them are walls. My route was to work from the known outer shell to a version that captured most of its vectors cleanly, and from there to add and subtract simple shapes until the inside held a board, a cell and a cable. It is not the efficient way. Blender has at least four ways for a boolean to fail silently, with no error and a wrong result, and I found all of them. But I am proud of how it turned out for a first model, and the printed shell is what ships.
The thing that actually decides the battery is not the cell. It is the height of the tallest connector on the board, because the cavity is fixed and everything stacks. I learned that by rejecting a larger cell on paper over a margin of 0.17 mm and then, a week later, finding that it fit with room. Sub-millimetre CAD margins are noise, not verdicts.
Which is why, if there is a v2 of the board, I may swap some of those connectors for solder pads and hand the height back to the battery. For the first build, though, the connectors were the right call. They make a unit repairable, they make the build forgiving, and they work.
Updating from a browser
I wanted updates without a cable or an app, and Web Bluetooth makes that possible in Chrome. The first plan was to use the bootloader already on the module. That died the same day, on the bench: Chrome maintains a blocklist of Bluetooth services a web page may not touch, and that bootloader’s service is on it, by name, permanently. Nordic’s Secure DFU service is not. So retail units carry a different bootloader than the dev boards, the flash layout got audited and rearranged that night, and the first signed update from a browser tab landed a few hours later.
The updater on the site is a line-for-line port of the test page I hardened over seventeen real flashes. It has a retry and reconnect behaviour that looks like paranoia until you watch a transfer: the first connection after entering update mode drops the data wholesale, every time, and the only recovery is a fresh connection and a resync. That behaviour is documented as normal in the runbook, and I have not simplified it since.
Downgrade protection got its hardware proof by accident. The first flash through the deployed site offered an older package to a board that had moved on, and the bootloader refused it before a byte of data moved. That is the exact test the runbook had always wanted. It also revealed that two of my own documents disagreed about whether re-installing the same version was allowed. The flash settled it: it is, and the page that said otherwise was wrong.
What it does not do
The site has a section called “Not yet,” and I would rather it be honest than short.
Rumble is wired. The board carries a motor output, and the Xbox profile has to advertise it. No unit ships with a motor, and no board has buzzed on my bench, so the site says nothing drives it. The claim moves when the motor moves.
I have never measured latency, in the button-to-screen sense. What I have measured, many times, is report rate and jitter: about 66 Hz, which is the interval macOS negotiates whatever the firmware asks for. And I learned that a short capture lies. The fraction of doubled intervals on one binary swung from 2.5% to 18.5% within a single connection, and an afternoon of A/B flashing produced “differences” that were entirely that.
Battery life on the shipping board is deliberately not quoted. Every figure I have is from the XIAO bench build, which has a different power topology. The retail board gets characterised on its own, and until then the site stays qualitative.
The community
I would not have a board without other people, and I want to be specific about that.
I posted to Reddit more than once, early, when all I had was a perfboard and a question. Those threads led me to cluoma, whose Dreamwave project set out to do the same thing on a Pico, started around the same time as mine, and which I only stumbled on afterwards. Eventually they led me into his Discord, which is where I ran into Midwest Mods, who introduced me to The Darsh, who goes by triaxlullaby, and who designed the board that ships.
Working with Darsh was the most fun I had on the whole project. After a few misfires on my side, where I shared the design in ways that were clear to me and to nobody else, he had it flipped into a proper layout in a day or two and was asking what else we could do. We added a connection point for a rumble motor. We considered an SD slot and decided against it. Every exchange made the board better, and every exchange was a lesson in how to hand a design to someone who is better at the next step than you are.
Xcynz was, as far as I know, the first person to set up a board themselves, and they filed a GitHub issue for every problem and every question they had. I loved that. I tried to be as responsive as I could, and their reports are the reason several things in this post exist. I was fighting the radio directly at the time, and their description of inputs skipping in a fighting game is what pushed me to actually measure reactivity instead of guessing at it. That measurement is what got the dropped frames and misrotations cleaned up, and it is what made the BlueRetro support solid instead of coincidental. They dogfooded every attempt, including the ones that did not work for several tries in a row, and they never gave up on me.
The perfboard route in the repository is still my original design, and the board is Darsh’s, worked up from it. The shell stands on Wesk’s scan, with a cable plug from byt3swap. I am still learning how to be involved in a community well, but I would jump at working with people like this again, every time.
Made to order
The first run was small, and it is counted in donor controllers as much as in boards, because they are the same stock. Past the run, a Pulsar is made to order, which is the honest phrase: it is not a pre-order, the thing exists, the only missing step is a parts order.
The firmware, the perfboard build and the 3D files are public under GPL-3.0, and the contribute page says what kind of help would land.
If you have tried Pulsar on a host that is not on the tested list, or built one from the perfboard route, I would genuinely like to hear how it went. And if you have already solved one of the problems above better than I did, I would like to hear that more.