Hello everyone,
I've been reading here for a while and finally got my account working, so let me introduce myself. I drive a Mondeo MK4 facelift with a Convers+ cluster and I've spent the two months reverse-engineering its firmware. This forum was a huge help along the way, so I'd like to give something back.
I've published two open-source projects. Both patch the cluster firmware itself — no external hardware, no Raspberry Pi, no gateway injecting fake CAN IDs. And neither repo contains any Ford firmware: only the tools, so everyone patches their own dump.
1) convers-bt-audio — native Bluetooth-Audio track title & artist on the cluster
https://github.com/andrzejogh/convers-bt-audio
This one ties directly into the "Transmitting infodata on MM-CAN" and "can bus node app" threads. You had already mapped the media IDs there — USB titles on 4C7, and 4B0/4B1 as "Bluetooth Musik". The factory firmware happily shows USB titles (4C7) but simply ignores the Bluetooth-Audio stream. So instead of injecting text from the outside, I patched the firmware to route 0x4B1 into the same media store the factory uses for 0x4C7. The result: the cluster shows the BT track title and artist natively, exactly like it does for USB — up to 18 characters per field. Confirmed working on a real 1412-FL (part CS7T-14C026-CD).
2) convers-gauge-sweep — welcome needle sweep at ignition
https://github.com/andrzejogh/convers-gauge-sweep
A single full needle sweep when the cluster powers up, driven by the cluster's own factory sweep routine rather than any external signal. It ships in two variants: a default one with no menu entry, and one that adds a "Gauge sweep" item to the Advanced menu for people running m0rtar's modified firmware. This is a newer project, published for testing — feedback from other cars is very welcome.
Both projects come with full documentation and a Unicorn-based emulator test suite, so the patches are verified before anyone flashes anything.
Along the way I worked out a few things I'd be glad to share and discuss in the disassembly sections: the SRAM layout derived from the C-runtime startup, the gauge module's state machine, and the format of the Advanced-menu descriptors.
One small thing — my account can read everything but can't yet download attachments or post in the technical sub-forums. If an admin could activate that, I'd love to contribute directly to the Convers+ disassembly threads.
Thanks for running this place,
mocarz
Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
This is a very neat and interesting project, i'm curious to get deeper into details. Can you elaborate more about the simulator and how found find the entrypoints to inject own code and how you compile that code for the MAC?
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
Thank you Go4IT — coming from you, who built this place and wrote so much of what I learned from, that really means a lot. I'll go into as much detail as I can, and please correct anything that's off — some of this is still hypothesis on my side.
Up front: a large part of this was done with AI assistance (Claude / Claude Code). I did the reverse engineering and the decisions, but the AI was a real force multiplier — driving Ghidra and Capstone, reading large amounts of Thumb disassembly, and writing the emulator harness and the assembler. The habit that made it dependable: treat it as a hypothesis generator and verify every claim against the emulator or the raw bytes. It was confidently wrong about big-endianness and a few addresses early on, so I check everything now. I'm glad to attach the analysis scripts it produced (the Ghidra/Capstone helpers and the harness).
1. The simulator (and setup)
Two different things here.
What I use day to day is a thin harness on the Unicorn Engine (the CPU core from QEMU), configured big-endian ARM / Thumb / ARMv4T. Setup is:
The full end-to-end emulation is a different, harder achievement, and credit for that goes to another member here, wojtkowiak — he got a complete emulator running and could explain it far better than I can. He's also waiting on activation here; if his account were enabled too, I think he'd happily share that work.
2. Finding the entrypoints, and the memory map
The SRAM map mattered most, and I recovered it by disassembling the C-runtime startup (the ARM code at 0x5348..0x5410) and reading what it initialises:
Those last 16 bytes are where the gauge patch keeps its state (two words at 0x4000BFF4 and 0x4000BFF8, tagged with a 0x5B39 header so I can tell my data from random power-up garbage). Justifying that location took some care: the whole .bss is occupied — my worst bug was the sweep firing while driving, because an early state byte lived inside a 500-byte text buffer at 0x400092DE that gets reused. What convinced me 0x4000BFF0..BFFF is safe is that the firmware's own address validator at 0x43F74 uses 0x4000BFFF as the RAM upper bound — so it's provably valid RAM, yet nothing references it and startup never clears it, which is exactly what a persistent flag needs.
For the injection points themselves, static disassembly to form a hypothesis, the harness to confirm:
Because the patches are small — the trampoline plus a few dozen instructions — I didn't set up a cross-compiler. I hand-wrote a small Thumb-1 assembler in Python that emits the exact encodings, including the 4-byte BL that reaches the cave. That gives byte-exact control and lets me diff the output against the surrounding factory code. Each patch is additive: place the code in an unused region of the code partition (I've used 0x83240 and 0x9B8AC, each a few hundred bytes of padding), repoint one existing bl to a trampoline there, and the trampoline ends by calling that bl's original target — so the factory path always still runs.
For a real toolchain the equivalent is arm-none-eabi-gcc with -mbig-endian -mthumb -mcpu=arm7tdmi -mfloat-abi=soft, remembering it's a BE-32 target (big-endian code and data) and that the code has to be placed at the cave address via the linker script. For patches this size the hand assembler was simply less friction.
The hardest parts
Happy to go deeper in the disassembly section once I can post there, and to share the harness and the scripts.
Best regards,
mocarz
Up front: a large part of this was done with AI assistance (Claude / Claude Code). I did the reverse engineering and the decisions, but the AI was a real force multiplier — driving Ghidra and Capstone, reading large amounts of Thumb disassembly, and writing the emulator harness and the assembler. The habit that made it dependable: treat it as a hypothesis generator and verify every claim against the emulator or the raw bytes. It was confidently wrong about big-endianness and a few addresses early on, so I check everything now. I'm glad to attach the analysis scripts it produced (the Ghidra/Capstone helpers and the harness).
1. The simulator (and setup)
Two different things here.
What I use day to day is a thin harness on the Unicorn Engine (the CPU core from QEMU), configured big-endian ARM / Thumb / ARMv4T. Setup is:
- map the application code from 0x5000,
- map SRAM at 0x40000000, length 0xC000 (48 KB),
- set SP to the factory value 0x4000BFEC (taken straight from the reset code — more below),
- then call individual firmware functions directly instead of booting the image.
The full end-to-end emulation is a different, harder achievement, and credit for that goes to another member here, wojtkowiak — he got a complete emulator running and could explain it far better than I can. He's also waiting on activation here; if his account were enabled too, I think he'd happily share that work.
2. Finding the entrypoints, and the memory map
The SRAM map mattered most, and I recovered it by disassembling the C-runtime startup (the ARM code at 0x5348..0x5410) and reading what it initialises:
Code: Select all
0x40000000..0x40000810 noinit - startup never touches this
0x40000810..0x4000190C .data - copied from ROM 0xD630C on every start
0x4000190C..0x4000B008 .bss - zeroed on every start
0x4000B008..0x4000B7F0 heap - filled 0xEFEFEFEF on every start
0x4000B7F0 lower stack canary (0x5A, checked at 0x1E5EE)
0x4000B7F4..0x4000BFEC stack - filled 0xEFEFEFEF on every start
0x4000BFEC upper stack canary = initial SP (set at 0x1E5E6)
0x4000BFF0..0x4000BFFF noinit - and not one reference to it anywhere in the code
For the injection points themselves, static disassembly to form a hypothesis, the harness to confirm:
- Gauge sweep: the module runs a state machine (a dispatcher on a state variable). The consumer is FUN_0x353DE: when the power state returned by FUN_0x22C32 is 2 or 3 (I read it from 0x40006B31) and the module state *(u8)0x400075E8 == 9, it acts on the flags at 0x400075EA. The physical movement is entirely factory — FUN_0x34F6E drives zero -> full-scale -> zero, each stage gated by FUN_0x34B38 confirming the steppers arrived. So my patch is literally setting one bit (bit 7 at 0x400075EA); the sweep is 100% the cluster's own routine. I hook two points: 0x34FCA (the power-ON branch) to arm and gate it, and 0x35682 (the dispatcher case for module state 22) to clear the once-per-ignition flag.
- BT-Audio: the cluster accepts a fixed set of media IDs into the FlexCAN mailboxes (acceptance table at 0x79446: 4B1, 4B3, 4C0, 4C1, 4C6, 4C7, 4D0, 4D2, 4D4, 4D5). USB titles (0x4C7) have a callback (FUN_0x25c3f) and ISO-TP reassembly (0x25641) that fill the media text store; 0x4B1 is accepted into the mailbox, but its content never reaches that store. So I hook one bl at 0x236F6 — before the mailbox-type split, so it runs for every frame — into a cave that reads the mailbox directly (per-bus base 0x7A0D4, mailbox = base[bus] + mb*16 + 0x80), takes CAN-ID = (word >> 18) & 0x7FF, and if it's 0x4B1 does the ISO-TP reassembly itself into the media store (0x40009300), then returns the mailbox pointer untouched so the original handler is none the wiser. One gotcha: 0x4B0 carries the same text, but handling both interleaves and corrupts the buffer, so I filter strictly to 0x4B1.
Because the patches are small — the trampoline plus a few dozen instructions — I didn't set up a cross-compiler. I hand-wrote a small Thumb-1 assembler in Python that emits the exact encodings, including the 4-byte BL that reaches the cave. That gives byte-exact control and lets me diff the output against the surrounding factory code. Each patch is additive: place the code in an unused region of the code partition (I've used 0x83240 and 0x9B8AC, each a few hundred bytes of padding), repoint one existing bl to a trampoline there, and the trampoline ends by calling that bl's original target — so the factory path always still runs.
For a real toolchain the equivalent is arm-none-eabi-gcc with -mbig-endian -mthumb -mcpu=arm7tdmi -mfloat-abi=soft, remembering it's a BE-32 target (big-endian code and data) and that the code has to be placed at the cave address via the linker script. For patches this size the hand assembler was simply less friction.
The hardest parts
- Big-endian Thumb. Nearly every tool — and the AI — defaults to little-endian; until Ghidra, Capstone, Unicorn and the model were all consistently BE, the output looked plausible and was wrong. This cost the most time by far.
- Stubbing peripherals in the harness — working out which calls touch hardware so the function under test runs to the end.
- Timebase. An early gauge version trusted the 128 ms coarse timer (0x40006672, from the ISR FUN_0x1E6D4) and gave four sweeps at ignition on the real car. I stopped trusting wall-clock timing and switched to counting task passes instead — that fixed it.
- Finding a safe byte of RAM — the 0x4000BFF0 story above.
Happy to go deeper in the disassembly section once I can post there, and to share the harness and the scripts.
Best regards,
mocarz