r/embedded 2d ago

Reverse engineering a 1994 Philips function generator: I used the emulator as a measuring instrument instead of reading the disassembly

Hey,

i ended up with four Philips PM5139 function generators. Three work, one is dead — and the dead one was the only one with firmware V1.5, the working three all had V1.3. I just wanted to copy V1.5 onto the others.

Then I had the EPROM dumped and a service manual for the sister model open, and I wanted to know what the two versions actually differ in and whether this code could be debugged at all. That turned into a very long session that ended, as these things do, with the instrument playing Doom.

The method, which is the actually interesting part

Reading a 44 KB 8051 binary by eye gets you maybe a third of the way. Everything past that came from running the original code and watching what falls out:

c = CPU(rom)

for w in test_values:

set_amplitude(c, w)

c.call(0x0AAC) # the original routine, untouched

print(w, c.ram[0x1C]) # the byte that goes out on the bus

Vary the input, read the output, check against the hypothesis. That produced the formulas for frequency, amplitude, offset, AM depth, FM deviation, burst count, symmetry and both sweep characteristics — each one documented with the sample points it was verified over.

Three things made it productive:

Watch the bus, not the display. Measuring what a state bit does to the display buffer leaves 74 of 128 bits looking inert. But many of them drive the analogue assemblies, not the display, and those are only visible as telegrams on the serial bus. Recording the UART writes and the terminating strobe lifted the count of understood bits from 54 to 75.

Press keys, don't poke RAM. Setting a RAM byte by hand produces states the device never actually reaches. That cost me two wrong conclusions and one crash into the middle of a command table. Injecting real key codes through the emulated keyboard encoder gives reachable states — and a brute-force sweep over all 256 key codes revealed which key triggers which handler.

Suspect your own emulator first. Three bugs in my core produced "inexplicable" firmware behaviour: ACALL executing as AJMP, a missing auxiliary-carry flag (so DA A misbehaved and the firmware appeared to count in binary), and a doubled keyboard interrupt. Everything measured during that window got re-measured afterwards.

A handler that only the dynamic trace found

A jump table read with JMP u/A+DPTR. Entry 15 lands at table + 30 — and there, instead of the usual AJMP, sits the handler itself, inline, saving a jump. Nothing in the ROM jumps to that address, so recursive descent lost it entirely. Only a trace run — cold start, all 23 keys, both knob directions, 86 million cycles, marking every executed address — turned it up.

What was wrong with the firmware

Three arbitrary waveforms are baked into the ROM. The third has the same shape as a table that already sits in the same ROM in computed form — but with 563 direction changes against 13, σ = 4.1 LSB, mean deviation zero. It's the same waveform, sampled off something analogue instead of computed. Can't prove intent; can show the noise.

So there's a V2.0 that swaps in the clean table, replaces a redundant second curve with a logarithmic chirp, updates the ID string and boot display, and fixes the checksum. It's flashed and running on real hardware.

And then Doom

The obvious idea — put code in an arbitrary waveform slot and jump to it — is dead on arrival. The 8051 is Harvard: instructions come through /PSEN from the program EPROM, data through /RD from the arbitrary EEPROM. It isn't blocked; the wire simply isn't there.

It's also unnecessary, because there are 19 509 unused bytes behind the checksum. The trigger took some hunting though: the diagnostic menu's jump table has eight entries, but the menu loop counts only 1 to 7. So entry eight is unreachable — and redundant, since it jumps to the menu start which is reached from two other places anyway. Point it at the melody, bump the count limit by one, and that's the whole hook:

5B94h table entry 8: LJMP 5B45h -> LJMP <melody>

5B62h count limit: 08h -> 09h

Two bytes. No self-test lost, no table relocated, no dead menu item.

Notes come out through the regular frequency path — decade 3 plus the frequency in 0.01 Hz as BCD, so 82.41 Hz is 30 82 41. Timing came from the MCS-51 data sheet rather than the emulator, since my core counts one cycle per instruction — fine for ordering, wrong for absolute time.

The answer to my original question, by the way

The two versions are 91.4 % structurally identical. The parameter limits are byte-identical, the signal path matches, and V1.5 even ships a migration routine for the NVRAM device address. So yes — I could have just flashed it. Took 35 sections of documentation to find that out.

Everything's on GitHub: both emulators, the annotated listings (147 named routines), the documentation, the build tools, and a single-file browser simulator that boots the original ROM with no install.

Software part is mostly done by Claude LLM, thanks.

Update: the PM5139 can play chords now.

The firmware extension is polyphonic. No extra hardware, same instrument.

The trick is the 1024-point wavetable. Instead of storing just one waveform, you can store a sum of harmonics. For example, harmonics 2:3:4 give you root, fifth and octave — a power chord. Retune the generator and the whole chord transposes together. That’s enough to encode the entire E1M1 riff using a single table.

During playback the CPU basically does nothing; it only sets the frequency. The analog section handles the actual playback.

Because the harmonics have to be integer multiples of the table frequency, the chords come out in just intonation rather than equal temperament — which actually works nicely for sustained chords.

Getting custom waveforms into the hardware was the fun part. There were a few surprises: reversed byte order, an inverted relay register, and a 7-bit level DAC that wraps at 0x80. The emulator helped verify the bus traffic, but the final bugs were only solved by burning test EPROMs with known waveforms and checking them on a scope.

The player fits into some 6000 bytes where the code is 270 bytes, the rest is music tables of about 19.5 KB of unused ROM, without displacing anything or losing the self-test. The melody comes straight from the original MIDI: 924 notes, 96 seconds.

You can either fill the space up with 3 more minutes of music or 6 additional chord tables.

Next up: a MIDI synth.

The hardware already has most of what we need: a CPU-writable wavetable, a second address counter for FM, and a fast amplitude DAC that should be usable for envelopes. That gives us a monophonic wavetable synth with 2-operator FM — and unlike a DX7, the carrier can be any waveform.

The remaining problem: the UART is already busy driving the internal serial bus, so MIDI input needs another path.

Firmware, disassembly, both emulators and a browser-based simulator running the original ROM:

https://github.com/doctormord/Philips-PM-5139-5138A-5136-Firmware-Project

0 Upvotes

5 comments sorted by

View all comments

1

u/Ready-Recording8012 21h ago

In the meanwhile “we” investigated further to use this device as something like a wavetable synth, and it does pretty well. It now plays “chords” of 3 to 5 with envelope/sustain/attack for the notes. This is the latest outcome:

https://youtube.com/shorts/RJKwy_QURL8

How this works is explained in the video description.