There's a whole radio system humming away on 220 MHz across North America, and most of what it says is locked inside a closed Windows program. I wanted to read it. So I spent a few days pointing an AI assistant at Ghidra, dumping live process memory with gdb, and poking at a decoder I couldn't see the source of. The result is OpenITC an open-source, console RTL-SDR receiver for ITC that now decodes the air interface natively. No DLLs from the vendor, no calling out to someone else's binary.

This is the story of how it came apart, layer by layer.

Capture

What's actually on 220 MHz

ITC stands for Interoperable Train Control. It's the radio side of Positive Train Control, the system that stops a train before it runs a switch or blows a work zone. The railroads run it on 220 MHz, right next to everything else in that band. Bases beacon their GPS coordinates and channel lists. Locomotives check in. Wayside boxes report switch and signal states. It's busy, structured, and almost entirely undocumented in public.

There's a popular Windows tool that decodes it: ITCMon (plus its friends mcr and s2p). It works great, it's free for hobbyists, and it is also a pile of compiled binaries. I don't like decoding RF with a black box I can't read, extend, or run on Linux. So I decided to reimplement the whole PHY and link layer from scratch, clean-room style, and give it away under the GPL.

The only problem: to reimplement it, I first had to figure out exactly what it does. That meant reverse engineering.


Wiring an AI into Ghidra

Normally reverse engineering means staring at decompiler output for weeks. I did something a little different this time: I ran Ghidra with the GhidraMCP extension, which exposes Ghidra's decompiler, symbol table, cross-references, and renaming through the Model Context Protocol. That let my AI coding assistant read the actual decompiled C, follow function calls, rename things, and cross-check its guesses against the binary instead of hallucinating from memory.

It's a genuinely good workflow. I'd ask "what does FUN_100402b70 do?", the agent would pull the decompilation, notice it was an interpolating demodulator, and propose a port. Then I'd hit the wall that every RE project hits: the decompiler is a guess, and a confident-sounding guess is the most dangerous thing in the room.


Step 1: The modulation nobody expects

The first surprise was the modulation. 220 MHz train radio sounds like it should be GMSK — that's what ATCS and a lot of rail signaling use. The agent assumed GMSK, wrote a GMSK demodulator, and it decoded garbage.

The disassembly said otherwise. Buried in the symbol decision code were four decision regions centered on 4-DQPSK. Differential, two bits per symbol, no fancy pulse shaping to fight. Once we switched to a differential phase detector, symbols started falling out cleanly.

Lesson one: trust the instructions, not the vibe.


Step 2: Finding the sync

Every burst starts with a 64-bit unique word. We found its origin string in the binary, the literal ASCII f337eeb637660672, expanded nibble by nibble. Correlate that over the demodulated bitstream and you know exactly where a frame begins.

Our first native demodulator locked the sync word perfectly. Which was great, and also a trap. It locked the sync, but the header right after it kept coming out wrong.


Step 3: The header FEC, and the table that lied

After the unique word comes a 48-bit coded header: length, type, and a small checksum. It's protected by a K=7 rate-1/2 convolutional code, decoded with a Viterbi decoder.

The documentation trail (and the code comments we could see) suggested the generator polynomials 0o133 and 0o171 , the textbook K=7 pair. We built a Viterbi decoder around those. It decoded beautifully and produced nonsense. The header checksum failed over and over.

This is the part where an LLM will happily keep going, confident the decoder is fine and the data is wrong. It wasn't the data. The polynomials were wrong.


The move that actually broke it open: runtime ground truth

Static analysis has a ceiling. To get past it, I stopped reading the program and started watching it.

I ran the reference decoder under gdb while feeding it a capture I already knew the answer to, and set breakpoints at each stage of the pipeline:

  • At the demodulator's bit buffer, right before it packed bits into bytes. This dumped the exact 1s and 0s s2p produces from my samples, its ground-truth bitstream.
  • At the Viterbi decoder's input, to grab the six header bytes it actually fed the FEC.
  • At the Reed-Solomon decoder, to dump each 16-byte block after de-interleaving.

Suddenly I wasn't guessing. I had the oracle's own intermediate values in hand, and I could diff them against mine.

Two of those dumps cracked the whole thing:

1. My demodulator's bit order was flipped. The reference's bit buffer started 11110011 00110111 ... = the sync word packed MSB-first, exactly as expected. When I compared the data bits after the sync, mine matched — until they didn't. One of the two bits per symbol was inverted relative to the reference. A single polarity flip on the b0 bit. That one line was the entire reason parsing had failed for days. It wasn't a bug in my math; it was my convention with the sign of the phase.

2. The real Viterbi tables weren't 0o133/0o171. I dumped the actual lookup tables out of the running process and did the algebra: the real taps were 0x6D and 0x4F. With those, the same header bytes that had decoded to noise suddenly decoded to a clean len=29, mode=1, type=0x02, a valid beacon header, matching the reference byte for byte.

The generator polynomials hadn't been secret. They were just wrong in every document I'd looked at.


Step 4: Reed-Solomon, an interleaver, and a four-byte twist

With the header decoding, the payload still needed work. The docs said the data layer used a "12,16 FEC code", a Reed-Solomon (16,12) shortened code over GF(2⁸), which matched the init_rs_char(8, 0x11d, ...) call we'd spotted. Then there was an interleaver on top, plus a CRC-16/ARC checksum at the end.

This is where the runtime dumps really paid off. By dumping the reference's RS blocks and the raw packet bytes side by side, I could read off the exact permutation: a block transpose that scatters the codewords across the burst so a burst of noise hits different blocks. And then a detail I'd never have guessed from instructions alone, the reference rotates the last four bytes to the end of the last block before decoding. Add that, and my output suddenly matched the oracle's Reed-Solomon input exactly, block for block.

CRC over the corrected data: zero. Clean.


Step 5: Turning bits into trains

Once the FEC was solid, the rest was just fields. Every packet has a type:

  • 0x02 — Base Beacon (GPS position, local channel, channel list)
  • 0x01/0x03 — QSTAT / ACK (slot requests and acknowledgements)
  • 0x04 — ACQ (a unit acquiring a base)
  • 0x05 — CNTL (base TDMA slot allocation)
  • 0x06 — TIME (UTC clock broadcast)
  • 0x07 — unknown (more on that below)
  • 0x33/0x34 — Wayside status (switch and signal indicators)
  • 0x70 — everything else, including EMP and ATCS-over-ITC

A beacon payload is a fixed layout: a radio ID, a signed GPS latitude and longitude scaled by 600,000, a local channel, and a six-entry channel list. My first fully native decode, with zero vendor code in the loop, printed:

BEACON id=38282d lat=41.831817 lon=-80.768800 ch=142 rate=1 util=17 list=167,166,165,153,142,127

That's a real base station, on a real channel, decoded by software I could read. I may have made a small noise.


What OpenITC is now

OpenITC is a console RTL-SDR receiver written in C++, built with MSYS2, licensed GPL-2.0. It tunes directly from an RTL-SDR (bias tee included), channelizes a 25 kHz channel, and runs the whole PHY in-process:

  • π/4-DQPSK demodulation and 64-bit sync
  • K=7 Viterbi header FEC
  • block de-interleave, Reed-Solomon (16,12), CRC-16/ARC
  • legacy packet decode, base beacons, TIME, CNTL, and wayside status

There's also a monitor mode that captures the whole 1.875 MHz PTC band in one go and decodes every channel at once, printing packets live until you Ctrl-C it:

[16:57:21] ch142 FR  type=34 WIU   len=11  WIU=a5e904 data=ce43820dd9c0ad60  [typical] sw=X sig=28,4,28
[16:57:22] ch142 FR  type=06 TIME  len=9   TID=38282d  2026-10-11 22:47:21 UTC
[16:57:23] ch142 FR  type=05 CNTL  len=12  TID=38282d seq=76 data=00004040c0000000

The reference binaries were only ever an oracle. They're not shipped, not called, and not required. Pull the source, build it, run it.


What's still a mystery

The honest part: some of it is still dark. The link type 0x07 shows up on the air and nobody, not even the author of the reference tool, who says so in his own docs, knows what it is. Some wayside messages carry an HMAC we can receive but can't verify, so the payloads are authenticated and we just trust them. And the exact per-wayside meaning of every switch and signal bit is normally configured by hand per box, so my indicator decode is a best effort for now.

That's fine. The point was never to have all the answers. It was to stop treating the radio as a black box.


Grab it

The source is on GitHub at github.com/SarahRoseLives/OpenITC, with a Windows build (DLLs included) on the releases page. Point an RTL-SDR at a 220 MHz antenna and run openitc monitor. If you decode something interesting, or you figure out what 0x07 is, I want to hear about it.

And if you're doing RF reverse engineering yourself: don't be afraid to get AI into your decompiler, but don't trust it either. The winning move is never the clever guess. It's the breakpoint.

Sarah