← All writing

Bridging Forza Horizon 6 telemetry to MOZA Pit House

The packets were fine. Nobody was listening.

TelemetryUDPPython
A clay-style racing wheel with glowing mint and orange shift lights, its cable running down to a blue toy track where a red open-wheel car races.
From the game’s packets to the wheel’s lights.

I wanted the RPM lights on my MOZA wheel and the Pit House dashboard to react while I drove in Forza Horizon 6. Pit House had a Forza Horizon 5 profile and no entry for the new game. The Forza × MOZA Bridge is the small tool I wrote to fill that gap until official support arrives.

This is the longer version of how it works: what Forza actually sends, why Pit House ignored it, and how I checked each assumption instead of trusting it.

What Forza sends

Forza games have a setting called Data Out. In Horizon 6 it lives under Settings, HUD and Gameplay. You give it an IP address and a port, choose a format, and the game sends a steady stream of UDP packets describing the car. There is no handshake. If nothing is listening, the packets simply go nowhere.

The format choice matters. “Sled” is the shorter packet with the physics data. “Car Dash” extends it with the fields a dashboard needs, such as speed, gear and pedal inputs. Pit House needs Car Dash.

The bridge’s source records the sizes it cares about:

  • 232 bytes: the Sled section on its own.
  • 311 bytes: Car Dash as Forza Motorsport 7 sends it.
  • 324 bytes: Car Dash as Forza Horizon 4 and 5 send it. The extra 12 bytes are a padding block between the Sled section and the dash fields.

Everything is little-endian. The bridge’s --inspect mode decodes just enough of a packet to confirm it looks right. This is the decoder from fh6_moza_bridge.py:

is_race_on, ts_ms = struct.unpack_from("<iI", payload, 0)
cur_rpm = struct.unpack_from("<f", payload, 16)[0]
speed = struct.unpack_from("<f", payload, 256)[0]   # metres per second
accel = payload[315]
brake = payload[316]
gear = payload[319]

Why Pit House stayed quiet

My first assumption was that Horizon 6 had changed the format and I would need to translate packets. The real problem turned out to be earlier in the chain.

Inside the Pit House installation, a file called GameForwardingInfo.xml declares a listening port for Forza Horizon 5: UDP 30055. Selecting Horizon 5 in Pit House was not enough to open it, though. The listener only appeared when a process named ForzaHorizon5.exe was running. I confirmed that with PowerShell’s Get-NetUDPEndpoint before and after: no Horizon 5 process, no port 30055; the moment a process with that name existed, the port opened.

So the fix had two parts. Pit House needed to see a process with the name it expected, and something needed to move Horizon 6’s packets to the port Pit House was now listening on.

Two small pieces

The first piece is a stand-in process. A PowerShell script copies Windows’ own ping.exe into the project’s spoof folder as ForzaHorizon5.exe and starts it hidden, pinging localhost once a second. It does nothing else. A matching script stops it, and start_all.bat and stop_all.bat wrap both steps for each session.

The second piece is the relay. Horizon 6 sends Data Out to port 4009, the bridge receives it there and forwards it to 127.0.0.1:30055. The destination port accepts a comma-separated list, so the same stream can fan out to Pit House and to a diagnostic tool at once. The listening host defaults to all interfaces, and the Pit House host can point to another machine on the network.

The bridge uses only Python’s standard library and runs on Python 3.8 or later. For people who would rather not install Python, the repository also ships a Windows executable built with PyInstaller.

Normalise, then forward

Before forwarding, each packet goes through one function, normalize_for_pit_house(), that decides what Pit House should receive. Simplified, with the counters removed:

if n == FH5_DASH_SIZE:          # 324: the expected case
    return payload
if n > FH5_DASH_SIZE:           # newer fields appended: keep the known prefix
    return payload[:FH5_DASH_SIZE]
if n == FM7_DASH_SIZE:          # 311: would need 12 bytes inserted at 232
    return None                 # not observed from FH6, so dropped
return None                     # too small to be Car Dash

I wrote the truncation path expecting Horizon 6 to append fields to the end of the packet. Live captures showed otherwise. Every Horizon 6 Car Dash packet I received was exactly 324 bytes and byte-for-byte compatible with Horizon 5. Across tens of thousands of packets, the bridge forwarded all of them unchanged. Truncation stays in the code as a safety net in case a patch changes the format.

Counters as the debugging interface

When a telemetry setup fails, the symptom is the same every time: the wheel does nothing. The useful question is where the chain broke. The bridge prints a single status line, refreshed once a second, with counts for packets received, transmitted, passed through unchanged, truncated and dropped, plus the size of the last packet.

Each counter answers a different question. If received stays at zero, the game is not sending to the bridge; check Data Out and the port. If received climbs but everything is dropped, the format is probably set to Sled. If packets are transmitted but Pit House shows nothing, port 30055 probably is not open, which means the stand-in process is not running. A wrong packet size in the counter is the signal that a patch has changed something.

Checking the layout instead of trusting it

Matching sizes do not prove matching fields. Early on, my notes placed the input bytes near offset 311, and captures showed that byte topping out around 66 at full throttle. That was enough doubt to want a proper check.

find_inputs.py runs a self-paced test in three phases: five seconds with no pedals pressed, ten seconds at full throttle and five seconds at full brake. For every byte from offset 200 to 323, it records the minimum and maximum value in each phase. A throttle byte should be near zero at idle and under braking and high while the accelerator is floored, and the script lists every offset that behaves that way. The repository describes it as the tool used to verify that the Horizon 6 layout matches Horizon 5, and the bridge’s decoder reads throttle at 315, brake at 316 and gear at 319.

The other tools in the folder cover the remaining cases. recon.py hex-dumps the first packet. test_bridge.py fires synthetic 324, 400 and 100-byte packets at a running bridge and checks what arrives, so it works without the game installed. live_monitor.py subscribes to a fan-out port and prints RPM, speed, gear and throttle with a bar showing how close the engine is to its limit. capture_burst.py records a few seconds and reports the RPM range you reached.

The LED curve was not a bridge problem

Once data was flowing, the shift lights only came on close to the limiter. That was a Pit House setting. Forza reports the engine’s absolute maximum RPM, and most cars change gear well below it. Lowering the first LED threshold to around 40 to 50 percent in Pit House’s RPM Lights settings gave a useful spread. Because the bridge runs Pit House in its Horizon 5 profile, that is the profile to tune.

I set this up with an R9 base. The repository asks for reports from other MOZA bases, and for any Horizon 6 packet size other than 324 bytes, since that would mean the game has changed and the bridge needs real translation.

Why it stays small

The whole relay is around 200 lines, and I wanted it that way. It sits between a game and a driver application, both of which will change. When Pit House adds official Horizon 6 support, the bridge should become unnecessary, and nobody should need to unpick anything to stop using it. The source is MIT-licensed, with setup steps, troubleshooting notes and the diagnostic scripts in the same repository.

Sources & further reading

Another question worth following?

Explore the workGet in touch

A good project often starts here.

Leave me
a what if.

All the places to find me ↗
To Satyajit

There’s an app I wish existed.

Tell me who would use it and what they are trying to do. A rough sketch is plenty to start with.

Talk to me on LinkedInLet’s follow the question.