Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)

Newly activated users can post here
Post Reply
mocarz
Posts: 3
Joined: 15 Jul 2026, 06:35

Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)

Post by mocarz »

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
Go4IT
Pro
Posts: 983
Joined: 08 Feb 2019, 12:25

Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)

Post by Go4IT »

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?
mocarz
Posts: 3
Joined: 15 Jul 2026, 06:35

Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)

Post by mocarz »

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:
  • 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.
Booting for real needs peripherals I don't model, so I jump into the function I care about — for the gauge work that's the real task FUN_0x353DE (@0x3540E) — with registers and SRAM pre-loaded. Two hooks do the rest: a memory-write hook logs every SRAM write (this is how I see exactly what a function touches), and a code hook stubs the calls that would hit hardware, returning a canned value so the function runs to completion. I keep a map of ~3820 function entry points from the disassembly so I can fire any of them on demand. That let me check the gauge patch behaviour — one sweep, resets across a restart, never writes outside its own state word, never sweeps while driving — over many iterations in seconds. 20 such tests ship with the patch.

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
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:
  • 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.
3. Compiling for the MAC7116

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
  1. 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.
  2. Stubbing peripherals in the harness — working out which calls touch hardware so the function under test runs to the end.
  3. 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.
  4. 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
mocarz
Posts: 3
Joined: 15 Jul 2026, 06:35

Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)

Post by mocarz »

How the Convers+ Bluetooth metadata patch was actually built — the full RE walkthrough

The first post covered what the patch does. This one is the how: the reverse-engineering
process, with the two helper scripts that aren't in the repo pasted in full. I'm deliberately not
repeating what's already in the opening post.

The target: cluster MCU is a NXP MAC7116 — ARM7TDMI-S, Thumb-1, big-endian, ARMv4T,
program image based at 0x5000.

————————————————————————————————————————

1. Getting a correct disassembly of the image

Headless Ghidra import with the right processor spec and base address — get either wrong and every
literal pool resolves to garbage:

Code: Select all

analyzeHeadless ./proj convers \
  -import main.bin \
  -processor ARM:BE:32:v4t \
  -loader-baseAddr 0x5000 \
  -postScript ExportFuncs.py
ARM:BE:32:v4t is the key — BE (big-endian) and v4t (ARMv4T / Thumb). The post-script dumps
every discovered function start so the rest of the toolchain has a function map. Full script:

Code: Select all

# ExportFuncs.py  (Ghidra / Jython post-script)
import pickle
fm = currentProgram.getFunctionManager()
starts = sorted(int(f.getEntryPoint().getOffset()) for f in fm.getFunctions(True))
pickle.dump({"starts": starts}, open("fmap.pkl", "wb"))
print("exported %d functions" % len(starts))
~3800 functions. That function map is what lets the disassembler below stop cleanly at the end of
a function instead of running off into the next one.

One thing to keep consistent: the same main.bin feeds both Ghidra and the disassembler below.
It's the raw 0x5000-based code partition with no header, so file offset 0 == address 0x5000.
If your dump has a different layout (an extra header, a different base), every address in this post
will be shifted and nothing will line up — normalise to the bare 0x5000 image first.

————————————————————————————————————————

2. The disassembler — full source

I did most of the reading outside Ghidra, with this Capstone helper. It loads the image
once, disassembles BE Thumb, stops at a function's return using the map from step 1, and —
crucially — resolves PC-relative literal loads (the ldr rX,[pc,#..] this firmware
uses for every constant), printing the resolved value inline as ; =0x......... That one
feature is what makes the FlexCAN base tables and handler addresses readable at a glance.

Code: Select all

# deasm.py — helper disassembler for a big-endian ARM Thumb image (base 0x5000).
#
# Usage:
#   deasm.py 0x6cd20            # disassemble the function at 0x6cd20 (until return)
#   deasm.py 0x6cd20 0x40       # disassemble 0x40 bytes
#   deasm.py hex 0x79440 0x60   # raw BE16 hex dump of a region
import struct, sys, os, bisect, pickle
from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB, CS_MODE_BIG_ENDIAN
from capstone.arm import ARM_OP_MEM, ARM_REG_PC

BASE = 0x5000
CODE = open("main.bin", "rb").read()
N = len(CODE)
md = Cs(CS_ARCH_ARM, CS_MODE_THUMB | CS_MODE_BIG_ENDIAN); md.detail = True

STARTS = pickle.load(open("fmap.pkl", "rb"))["starts"] if os.path.exists("fmap.pkl") else []

def func_end(st):
    if not STARTS: return min(st + 0x400, BASE + N)
    i = bisect.bisect_right(STARTS, st) - 1
    return STARTS[i + 1] if 0 <= i + 1 < len(STARTS) else BASE + N

def disasm(st, limit=None):
    end = func_end(st) if limit is None else min(st + limit, BASE + N)
    print(f"===== 0x{st:x} .. 0x{end:x} =====")
    for ins in md.disasm(CODE[st - BASE:end - BASE], st):
        line = f"0x{ins.address:x}: {ins.mnemonic:8s} {ins.op_str}"
        if ins.mnemonic.startswith("ldr") and len(ins.operands) >= 2:
            op = ins.operands[1]
            if op.type == ARM_OP_MEM and op.mem.base == ARM_REG_PC:
                lit = ((ins.address + 4) & ~3) + op.mem.disp
                if 0 <= lit - BASE <= N - 4:
                    v = struct.unpack(">I", CODE[lit - BASE:lit - BASE + 4])[0]
                    line += f"   ; =0x{v:08x}"
        print(line)
        if (ins.mnemonic == "bx" and ins.op_str.strip() == "lr") or \
           (ins.mnemonic.startswith("pop") and "pc" in ins.op_str):
            if limit is None: break

def hexdump(va, n):
    o = va - BASE
    for r in range(0, n, 16):
        ch = CODE[o + r:o + r + 16]
        w = ' '.join(f"{struct.unpack('>H', ch[k:k+2])[0]:04X}" for k in range(0, len(ch) - 1, 2))
        asc = ''.join(chr(b) if 32 <= b < 127 else '.' for b in ch)
        print(f"0x{va+r:08x}: {w:<40} |{asc}|")

if __name__ == "__main__":
    a = sys.argv[1:]
    if a and a[0] == "hex":
        hexdump(int(a[1], 16), int(a[2], 16) if len(a) > 2 else 0x40)
    elif a:
        disasm(int(a[0], 16), int(a[1], 16) if len(a) > 1 else None)
    else:
        print(__doc__)
————————————————————————————————————————

3. The strategy: don't write a parser — reuse the USB pipeline

The cluster already renders track text for USB (CAN 0x4C7) and CD. So the plan was never
"decode Bluetooth from scratch" — it was "find where USB text enters the media store, and feed
Bluetooth (0x4B1) into the same entry point." That turns a big feature into a small code
cave. To do that I had to find the CAN receive dispatcher and understand how it decides what a
message is before it hands it off.

Finding the dispatcher. I worked backwards from the media side. The USB screen's text is
reassembled by a handler that reads a FlexCAN mailbox and calls the ISO-TP reassembler —
FUN_0x2326a. Asking Ghidra for xrefs to it (right-click -> References) returned a single
caller: FUN_0x236cc. That's the RX dispatcher. So I disassembled both at once — the caller to find
a hook point, the callee to learn the mailbox math.

————————————————————————————————————————

4. The exact moment it clicked — the dispatcher, side by side

Code: Select all

deasm.py 0x236cc 0x60      # FUN_0x236cc — RX dispatcher
deasm.py 0x2326a 0x70      # FUN_0x2326a — media ISO-TP receiver
FUN_0x236cc — the RX dispatcher (real output):

Code: Select all

===== 0x236cc .. 0x2372c =====
0x236cc: push     {r4, r5, r6, lr}
0x236ce: adds     r4, r0, #0
0x236d0: ldr      r1, [pc, #0x6c]   ; =0x0007a0d4     <- per-bus FlexCAN base table
0x236d2: lsls     r0, r4, #2
0x236d4: ldr      r5, [r1, r0]                          <- r5 = base[bus]
0x236d6: adds     r0, r4, #0
0x236d8: bl       #0x23036
0x236dc: ldr      r0, [r5, #0x30]                        <- IFLAG (pending mailboxes)
0x236de: ldr      r2, [pc, #0x78]   ; =0x40006b72
0x236e0: lsls     r1, r4, #3
0x236e2: adds     r2, #0x72
0x236e4: adds     r1, r1, r2
0x236e6: ldr      r1, [r1, #4]                           <- per-bus enabled mask
0x236e8: movs     r2, #1
0x236ea: lsls     r2, r2, #0xe
0x236ec: ands     r0, r1
0x236ee: bics     r0, r2                                 <- drop mailbox 14 (#0x4000)
0x236f0: adds     r1, r0, #0
0x236f2: beq      #0x2372a
0x236f4: adds     r0, r4, #0
0x236f6: bl       #0x230e4                               <- *** returns the mailbox index (mb) ***
0x236fa: ldr      r2, [pc, #0x84]   ; =0x0007a0dc     <- per-(bus,mb) TYPE table
0x236fc: lsls     r1, r4, #5
0x236fe: adds     r1, r1, r2
0x23700: ldrb     r1, [r1, r0]                           <- type = table[bus*32 + mb]
0x23702: cmp      r1, #1
0x23704: beq      #0x23722                               <- type 1 -> FUN_0x234c2
0x23706: cmp      r1, #2
0x23708: beq      #0x23718                               <- type 2 -> FUN_0x23628 (SIGNALS)
0x2370a: cmp      r1, #3
0x2370c: bne      #0x2372a
0x2370e: adds     r1, r0, #0
0x23710: adds     r0, r4, #0
0x23712: bl       #0x2326a                               <- type 3 -> FUN_0x2326a (MEDIA ISO-TP)
0x23716: b        #0x2372a
0x23718: adds     r1, r0, #0
0x2371a: adds     r0, r4, #0
0x2371c: bl       #0x23628                               <- type 2 handler (signals)
0x23720: b        #0x2372a
0x23722: adds     r1, r0, #0
0x23724: adds     r0, r4, #0
0x23726: bl       #0x234c2                               <- type 1 handler
0x2372a: adds     r0, r4, #0
FUN_0x2326a — the media ISO-TP receiver (real output):

Code: Select all

===== 0x2326a .. 0x232da =====
0x2326a: push     {r4, r5, r6, r7, lr}
0x2326c: sub      sp, #0x14
0x2326e: adds     r5, r0, #0
0x23270: adds     r3, r1, #0
0x23272: ldr      r6, [pc, #0xb4]   ; =0x0000ffff
0x23274: ldr      r0, [pc, #0xb4]   ; =0x0007a0d4     <- same FlexCAN base table as the dispatcher
0x23276: lsls     r2, r5, #2
0x23278: ldr      r4, [r0, r2]
0x2327a: mov      r0, sp
0x2327c: bl       #0x23e28
0x23280: lsls     r0, r3, #4                             <- mb * 16
0x23282: adds     r0, r0, r4
0x23284: adds     r0, #0x80                              <- mailbox = base + mb*16 + 0x80
0x23286: ldrh     r1, [r0]
0x23288: lsls     r1, r1, #0x17
0x2328a: bmi      #0x23286                               <- spin until mailbox not busy
0x2328c: movs     r1, #1
0x2328e: lsls     r1, r3
0x23290: str      r1, [r4, #0x30]                        <- ack IFLAG
0x23292: ldrh     r1, [r0]                               <- read 8 data bytes out of the mailbox
0x23294: add      r3, sp, #0
0x23296: strh     r1, [r3, #4]
   ... (copies the CAN frame into a stack struct) ...
0x232b0: ldr      r0, [sp]
0x232b2: bl       #0x751e8
0x232c2: bl       #0x22f48                               <- *** ISO-TP reassembly ***
There it was. Reading those two windows next to each other settled the whole design:
  1. The message type is not in the frame — it's a lookup. At 0x23700 the dispatcher
    does type = table[bus*32 + mb] against the table at 0x7A0DC. Type 2 -> 0x23628
    (signals), type 3 -> 0x2326a (media). 0x4B1 lands in a mailbox typed as a plain signal, so
    the rich ISO-TP receiver at 0x2326a never sees it. That's the whole reason the stock BT
    screen is blank — the text is thrown away one branch too early.
  2. The type split happens after 0x236F6. The bl 0x230e4 at 0x236F6
    returns the mailbox index, and only the next instruction (0x23700) reads the type. So a
    hook right there runs for every received message, with the mailbox already identified, and
    before the type decision can route 0x4B1 into the wrong (signal) handler.
  3. 0x2326a showed the exact mailbox math to copy. base[bus] from table 0x7A0D4,
    mailbox = base + mb*16 + 0x80. The cave reads the mailbox the same way, filters
    ID == 0x4B1, reassembles the ISO-TP payload itself, then feeds it into the media field handlers —
    the same store the USB path fills.
So the hook is a single re-pointed bl at 0x236F6 -> cave.

————————————————————————————————————————

5. Confirming 0x4B1 is even accepted

A hook is useless if the hardware filters the ID out before it reaches a mailbox. The acceptance
table is at 0x79440 (found via xrefs to the CAN filter init); same tool, hex mode:

Code: Select all

deasm.py hex 0x79440 0x50

Code: Select all

0x00079440: FFFF FFFF FFFF 04B1 FFFF 04B3 FFFF FFFF  |................|
0x00079450: FFFF FFFF FFFF FFFF FFFF FFFF FFFF FFFF  |................|
0x00079460: FFFF 0C00 04C0 04C1 FFFF FFFF 0C05 FFFF  |................|
0x00079470: 04C6 04C7 FFFF FFFF FFFF FFFF FFFF FFFF  |................|
0x00079480: FFFF FFFF 04D0 FFFF 04D2 FFFF 04D4 04D5  |................|
0x4B1 is right there (and so is 0x4C7, the USB ID). The frames are accepted by the hardware
— the firmware just wasn't doing anything useful with 0x4B1. This is also why the experimental
retarget option only works for IDs already in this list (0x4C6 yes; 0x4B0/0x4C3 no).

————————————————————————————————————————

6. From there to the patch

With the hook pinned, the rest is the code cave: read the mailbox the way 0x2326a does, filter
ID == 0x4B1, reassemble the ISO-TP payload (First / Consecutive / Single frames) into a scratch
buffer, and dispatch each field into the media handlers that already fill the store the screen
reads — returning mb so the whole thing is transparent to the dispatcher. The field handlers
and the store aren't guesswork: they're the same field-code dispatch table the USB path already
uses
(title / artist / album -> their handlers), reached from the media receiver above — the
cave just walks that same table. A single guard (one clamp plus a per-frame bound) keeps oversized
or malformed frames from overflowing anything. That builder, plus a small renderer tweak, is what's
published — no need to re-paste it here.

————————————————————————————————————————

7. Proving it without a car — Unicorn

Before ever touching a cluster we ran the real firmware in a Unicorn emulator (BE ARM Thumb
ARMv4T, code at 0x0, SRAM at 0x40000000), patched a throw-away copy exactly the way you'd flash
it, injected a synthetic 0x4B1 ISO-TP frame, ran the actual cluster code, and read the BT
metadata store back out. Title and artist landed in the store — before any flashing risk, and
reproducible with no hardware.

————————————————————————————————————————
Post Reply