Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
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
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
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
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
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:
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:
~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.
————————————————————————————————————————
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
FUN_0x236cc — the RX dispatcher (real output):
FUN_0x2326a — the media ISO-TP receiver (real output):
There it was. Reading those two windows next to each other settled the whole design:
————————————————————————————————————————
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:
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.
————————————————————————————————————————
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.pyevery 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))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 receiverCode: 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, #0Code: 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 ***- 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. - 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. - 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.
————————————————————————————————————————
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 0x50Code: 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 |................|— 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.
————————————————————————————————————————
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
Excellent, give me some time to fully understand and reproduce your findings.
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
Take your time, no rush at all - and just ask if anything is unclear.
One thing I'd honestly recommend while you're working through it: give the AI
tools a try. A large part of this work - reading the decompiled code, testing
patch ideas in the emulator, tracking down where a given string actually comes
from - I did together with Claude, and it saved me a lot of hours.
Since I can't send private messages here, I've sent you one over on
mk4-forum.denkdose.de instead, with a referral link - that way you can test the
Pro version for a week free of charge and see for yourself whether it's useful
for this kind of work. No obligation, I just thought it might be worth your time.
One thing I'd honestly recommend while you're working through it: give the AI
tools a try. A large part of this work - reading the decompiled code, testing
patch ideas in the emulator, tracking down where a given string actually comes
from - I did together with Claude, and it saved me a lot of hours.
Since I can't send private messages here, I've sent you one over on
mk4-forum.denkdose.de instead, with a referral link - that way you can test the
Pro version for a week free of charge and see for yourself whether it's useful
for this kind of work. No obligation, I just thought it might be worth your time.
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
How did you start it? In the early days i've completely decompiled the Flash using IDA Pro. Maybe Claude or Codex can do this without the need of a decompiler?
Naming strings and CAN/OBD/UDS IDs and bits/bytes might help to find the sources. Best would be to habe a way to get the full source code back to do own compiles/Flashpacks. Otherwise you need to find presumably free areas where to place the code. This make all patches almost potentially instable, bit doable. Usually, Flash memory is never used fully, mostly olenty of space available.
Naming strings and CAN/OBD/UDS IDs and bits/bytes might help to find the sources. Best would be to habe a way to get the full source code back to do own compiles/Flashpacks. Otherwise you need to find presumably free areas where to place the code. This make all patches almost potentially instable, bit doable. Usually, Flash memory is never used fully, mostly olenty of space available.
Re: Hello + two open-source Convers+ firmware patches (BT-Audio titles, gauge sweep)
How I started, and which tools
Not in the firmware at all, but on the bus. The car shows the track title over USB and leaves the Bluetooth Audio screen blank, so I first logged CAN with a PCAN to see what the BT module actually puts out: ISO-TP text frames on 0x4B1, framed exactly like the USB text on 0x4C7 — and the cluster ignoring them. Before opening a single byte of firmware I knew the data was already arriving and being discarded, which made the target narrow from day one. Only then did I dump the cluster.
That was July 2026, with no ARM background and no IDA licence — headless Ghidra was enough for all of it.
Since you asked about AI: the model generation matters more than anything else in the setup. I tried several of what was available then, GLM 5.2 among them, and none was usable for this — plausible readings of disassembly that fell apart the moment a script checked them. Opus 4.8 was the first that actually coped, and from there the project moved in weeks instead of stalling. With a weaker model you will spend your time debugging hallucinations rather than firmware.
One anecdote from that period, since it concerns this forum. Early on Opus 4.8 was reluctant to touch the firmware at all — it kept hedging about modifying a safety-related ECU and steering me towards "safer" options. I told it not to be scared: people do this every day, in the open, with dumps, documentation and recovery procedures, microhacker being the obvious example. After that it stopped arguing and got on with the work. So this forum is itself a kind of precedent — it is what let me show the tool that ECU reverse engineering is normal documented practice rather than something reckless.
Now to your actual question — whether Claude or Codex can do this "without the need of a decompiler". No. And the decompiler is precisely the part you cannot take away: the model never sees a byte of the binary. Here is the real chain, layer by layer, with real output from this project.
Layer 0 — what is in the file
Nobody reads this. The model never receives it.
Layer 1 — disassembly (bytes to instructions)
Same region through my Capstone script (big-endian Thumb, PC-relative literals resolved inline). The left column is Layer 0, the right column is what it means:
F7FF FCF5 is bl 0x230e4. The patch replaces those four bytes with F0 5F FD A3 = bl 0x83240, the code cave. That is the entire CAN-side modification.
Layer 2 — decompilation (instructions to C)
Ghidra, processor spec ARM:BE:32:v4t, raw output — nothing renamed, nothing typed by hand:
That is the text the model reads. No names, no types, no comments — param_1 is the bus, iVar2 is the mailbox, and the whole routing decision is cVar1: one byte pulled from DAT_00023780.
Reading this next to the USB handler is what cracked the project. The frame's own content decides nothing; a table byte does. So the question stopped being "how does the display work" and became "what does that table hold for 0x4B1". Answer: 2 — a dead 2-byte signal, so the text is discarded before ISO-TP reassembly ever runs. And 0x236F6 is the last point before the split, which is why the hook goes there and nowhere else.
How the model actually gets that text out of Ghidra
There is no "model looking at Ghidra". Throughout this project it was headless — no GUI, no plugin, no live connection. A ~20-line Java post-script prints decompiled C to stdout, and that text is what the model reads. This is what produced the listing above:
-noanalysis -readOnly is the part that matters: it reuses the analysis already in the project and cannot modify it, so the same command a month later returns identical text. Every address I have quoted in this thread came out of that path — re-run it against your own import and you get the same output.
Import once with the processor spec set explicitly (ARM:BE:32:v4t, base 0x5000) and after that Ghidra is a batch tool you query, not an IDE you sit in.
And Ghidra is not in the loop for everything. Function boundaries come from a local scan: fmap.py walks the image for Thumb prologues (B5xx = push {..,lr}, E92D = push.w {..,lr}) and builds fmap.pkl — function starts, a bl cross-reference index, and every literal each function loads.
3820 entry points, no Ghidra required. That is what lets deasm.py do "disassemble from here to the end of this function".
(I have since added the GhidraMCP plugin, which lets the model query and rename inside a live Ghidra session — convenient, and names accumulate in the database instead of the chat log. None of the work described here used it.)
What most of the image actually looks like
FUN_000236cc above is unusually clean. The USB handler, unedited and trimmed:
Ugly, but look at line 4: param_2 * 0x10 + iVar8, then + 0x80. That is mailbox = base + mb*16 + 0x80 — the mailbox addressing my cave uses, lifted from the stock code rather than from a datasheet. The do/while above it is the mailbox busy-wait, which the cave also has to respect.
Where the decompiler stops and scripts start
Notice it wrote DAT_00023780, not the table address — that is a literal-pool slot, not the table. The model cannot resolve it, and if you let it guess it will guess confidently and wrongly. So: a script. Twenty lines, one question answered exactly.
DAT_00023780 holds 0x7A0DC, and that table is referenced from exactly one place in the whole image. Also useful: 0x79440 — the FlexCAN acceptance table — has no literal reference at all, because it is reached as a struct offset. Don't go hunting for code that "points at" it.
My tools folder holds about forty scripts like this. That is what the AI actually produced in this project — not conclusions, but the tooling that turns a guess into a fact.
On naming strings and CAN/UDS IDs
You are right that this is where the handholds are, and I used them. String search plus cross-references will label maybe ten percent of the functions for free, and on the CAN side there are tables worth naming immediately: the acceptance list at 0x79440, the message-type table at 0x7A0DC, the media field-handler list at 0x8EFF0. The protocol constants fall out of those — field 0x46 title, 0x42 artist, 0x3F album; source byte 0x12 Bluetooth, 0x13 USB, 0x15 CD; IDs 4C7 USB, 4B0/4B1 Bluetooth, 4C1 CD. That made the RX framework readable in an afternoon.
But it is not what solved it. What solved it was a differential: the cluster already renders track text — for USB. So I never had to understand the display stack, the font renderer or the screen manager at all, only the one point where the two paths diverge — a single byte in a table.
It also hands you a test oracle before you write any patch. Inject [0x46][0x12][01][01]"TEST" on the working USB ID 0x4C7 — field "title", source byte "Bluetooth" — and "TEST" appears on the Bluetooth Audio screen. That proves the whole downstream pipeline already handles Bluetooth, and that the only missing piece is delivery of the 0x4B1 text.
So before naming anything, look for a feature that already works and is a sibling of the one you want. Far cheaper than understanding the subsystem.
How we found the space for the cave
Not from the forum — a scan. The VBF erases the whole code partition 0x5000-0x100000, so anything in that range is fair game provided nothing uses it. Long runs of a single fill byte are inter-function alignment padding and end-of-image filler:
0x83240 — 713 zero bytes sitting between functions — is where the 296-byte cave went on 14.12, the version I run in my own car. The large 0xEF runs at the tail are erase filler and are equally usable in principle.
Note that all of this is per-image. On another firmware version — 15.07, say — the picture from this same scan looks different, so the cave address, the fill byte and every hook anchor will most likely be different values, and they have to be measured on that image rather than carried over. Nothing is published for any version other than 14.12 at the moment.
Either way the apply script re-verifies the region is still empty before it writes and refuses otherwise, because "unused flash" is a property of one specific image, not of the ECU. My byte probe across the whole Convers+ family (47 VBFs / 38 APP images) turned up plenty of images with a big enough hole and zero matching patch anchors. A hole is necessary, never sufficient.
On your point that this makes patches "almost potentially instable" — I would put the risk somewhere else entirely. Code does not become unstable because of where it lives; padding between functions executes exactly like any other flash, and the cave is as deterministic as the code around it. The three things that actually destabilise a patch are:
The arbiter
Unicorn: image mapped at 0x5000, SRAM at 0x40000000, peripherals stubbed, one function called directly with the registers I choose. Real run against a stock dump — it patches a throwaway copy and A/Bs the two:
Identical firmware, identical frame, the patch as the only variable. That ran hundreds of times before anything went near the car. tools/emulate.py in the repo does exactly this on your own dump — no car, no CAN adapter.
Rather than just offer the harness, here is the useful core of it. This is the whole idea in 40 lines: map the code partition and SRAM, then call one firmware function directly with the registers you choose. It runs as-is — the output below is real:
Three details matter more than the rest of the file. UC_MODE_THUMB | UC_MODE_BIG_ENDIAN — get the endianness wrong and the instruction stream decodes to plausible nonsense instead of erroring, the biggest time sink on this target. LR pointed at a fake, mapped return address with the Thumb bit set — that is what lets emu_start stop cleanly when the function returns. And call it, don't boot it: no reset vector, no clock setup, no peripherals, because none of that is needed to ask one function what it does.
From there you grow it: a UC_HOOK_MEM_WRITE to log every store (that is how I found which SRAM the media handlers fill), and stub anything touching hardware by writing a bare "bx lr" over its entry point. That is all tools/emulate.py really is, plus the plumbing to patch and A/B a dump.
On recovering full source instead of patching free space
You have completely decompiled a Flash in IDA before, so you know the size of that job far better than I do. I would still argue it is the wrong target here, for practical rather than theoretical reasons:
Not in the firmware at all, but on the bus. The car shows the track title over USB and leaves the Bluetooth Audio screen blank, so I first logged CAN with a PCAN to see what the BT module actually puts out: ISO-TP text frames on 0x4B1, framed exactly like the USB text on 0x4C7 — and the cluster ignoring them. Before opening a single byte of firmware I knew the data was already arriving and being discarded, which made the target narrow from day one. Only then did I dump the cluster.
That was July 2026, with no ARM background and no IDA licence — headless Ghidra was enough for all of it.
Since you asked about AI: the model generation matters more than anything else in the setup. I tried several of what was available then, GLM 5.2 among them, and none was usable for this — plausible readings of disassembly that fell apart the moment a script checked them. Opus 4.8 was the first that actually coped, and from there the project moved in weeks instead of stalling. With a weaker model you will spend your time debugging hallucinations rather than firmware.
One anecdote from that period, since it concerns this forum. Early on Opus 4.8 was reluctant to touch the firmware at all — it kept hedging about modifying a safety-related ECU and steering me towards "safer" options. I told it not to be scared: people do this every day, in the open, with dumps, documentation and recovery procedures, microhacker being the obvious example. After that it stopped arguing and got on with the work. So this forum is itself a kind of precedent — it is what let me show the tool that ECU reverse engineering is normal documented practice rather than something reckless.
Now to your actual question — whether Claude or Codex can do this "without the need of a decompiler". No. And the decompiler is precisely the part you cannot take away: the model never sees a byte of the binary. Here is the real chain, layer by layer, with real output from this project.
Layer 0 — what is in the file
Code: Select all
py deasm.py hex 0x236f0 0x20
0x000236f0: 1C01 D01A 1C20 F7FF FCF5 4A21 0161 1889 |..... ....J!.a..|Layer 1 — disassembly (bytes to instructions)
Same region through my Capstone script (big-endian Thumb, PC-relative literals resolved inline). The left column is Layer 0, the right column is what it means:
Code: Select all
0x236f4: 1C 20 adds r0, r4, #0
0x236f6: F7 FF FC F5 bl #0x230e4 <- the four bytes this project turns on
0x236fa: 4A 21 ldr r2, [pc, #0x84] ; =0x0007a0dc
0x23700: 5C 09 ldrb r1, [r1, r0] <- type byte fetched from that table
0x23702: 29 01 cmp r1, #1
0x23706: 29 02 cmp r1, #2
0x2370a: 29 03 cmp r1, #3
0x23712: F7 FF FD AA bl #0x2326a <- the USB text pathLayer 2 — decompilation (instructions to C)
Ghidra, processor spec ARM:BE:32:v4t, raw output — nothing renamed, nothing typed by hand:
Code: Select all
void FUN_000236cc(int param_1)
{
char cVar1;
int iVar2;
iVar2 = *(int *)(DAT_00023740 + param_1 * 4);
FUN_00023036(param_1);
if ((*(uint *)(iVar2 + 0x30) & *(uint *)(param_1 * 8 + DAT_00023758 + 0x76) & 0xffffbfff) != 0) {
iVar2 = FUN_000230e4(param_1);
cVar1 = *(char *)(param_1 * 0x20 + DAT_00023780 + iVar2);
if (cVar1 == '\x01') { FUN_000234c2(param_1,iVar2); }
else if (cVar1 == '\x02') { FUN_00023628(param_1,iVar2); }
else if (cVar1 == '\x03') { FUN_0002326a(param_1,iVar2); }
}
FUN_00022fe8(param_1);
return;
}Reading this next to the USB handler is what cracked the project. The frame's own content decides nothing; a table byte does. So the question stopped being "how does the display work" and became "what does that table hold for 0x4B1". Answer: 2 — a dead 2-byte signal, so the text is discarded before ISO-TP reassembly ever runs. And 0x236F6 is the last point before the split, which is why the hook goes there and nowhere else.
How the model actually gets that text out of Ghidra
There is no "model looking at Ghidra". Throughout this project it was headless — no GUI, no plugin, no live connection. A ~20-line Java post-script prints decompiled C to stdout, and that text is what the model reads. This is what produced the listing above:
Code: Select all
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.address.*;
public class DecompOne extends GhidraScript {
public void run() throws Exception {
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
for (String a : getScriptArgs()) {
Address addr = currentProgram.getAddressFactory().getAddress(a);
Function f = getFunctionContaining(addr);
if (f == null) { println("NO FUNCTION CONTAINING " + a); continue; }
DecompileResults res = di.decompileFunction(f, 90, monitor);
println("===== " + f.getName() + " @ " + f.getEntryPoint() + " =====");
println(res.decompileCompleted() ? res.getDecompiledFunction().getC()
: "FAILED: " + res.getErrorMessage());
}
}
}Code: Select all
support\analyzeHeadless.bat "D:\...\firmware_convers\ghidra_re" convers_re ^
-process -noanalysis -readOnly ^
-scriptPath <dir> -postScript DecompOne.java 0x236ccCode: Select all
INFO Opening existing project: ...\ghidra_re\convers_re
INFO REPORT: Processing read-only project file: /1412_main_blk0_00005000.bin
INFO REPORT: Execute script: DecompOne.java '0x236cc'
INFO DecompOne.java> PROGRAM: 1412_main_blk0_00005000.bin base=00005000
INFO DecompOne.java> ===== FUN_000236cc @ 000236cc =====
INFO DecompOne.java> void FUN_000236cc(int param_1) { ... }Import once with the processor spec set explicitly (ARM:BE:32:v4t, base 0x5000) and after that Ghidra is a batch tool you query, not an IDE you sit in.
And Ghidra is not in the loop for everything. Function boundaries come from a local scan: fmap.py walks the image for Thumb prologues (B5xx = push {..,lr}, E92D = push.w {..,lr}) and builds fmap.pkl — function starts, a bl cross-reference index, and every literal each function loads.
Code: Select all
n_starts = 3820 first = 0x5478 ... last = 0xf8f58(I have since added the GhidraMCP plugin, which lets the model query and rename inside a live Ghidra session — convenient, and names accumulate in the database instead of the chat log. None of the work described here used it.)
What most of the image actually looks like
FUN_000236cc above is unusually clean. The USB handler, unedited and trimmed:
Code: Select all
void FUN_0002326a(int param_1,uint param_2)
{
...
int extraout_r1;
iVar8 = *(int *)(DAT_0002332c + iVar5);
iVar3 = param_2 * 0x10 + iVar8;
do { } while ((int)((uint)*(ushort *)(iVar3 + 0x80) << 0x17) < 0);
*(int *)(iVar8 + 0x30) = 1 << (param_2 & 0xff);
local_24 = *(ushort *)(iVar3 + 0x80);
local_20 = *(undefined2 *)(iVar3 + 0x84);
...
}Where the decompiler stops and scripts start
Notice it wrote DAT_00023780, not the table address — that is a literal-pool slot, not the table. The model cannot resolve it, and if you let it guess it will guess confidently and wrongly. So: a script. Twenty lines, one question answered exactly.
Code: Select all
# find_refs.py - find 32-bit literal pool references to target addresses
import struct, sys, os
BASE = 0x5000
CODE = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "images", "main_code_0x5000.bin"), "rb").read()
for target in [int(x, 16) for x in sys.argv[1:]]:
tb, refs, pos = struct.pack(">I", target), [], 0
while True:
p = CODE.find(tb, pos)
if p < 0: break
refs.append(BASE + p); pos = p + 1
print("0x%05x: %s" % (target, ", ".join("0x%05x" % r for r in refs) or "(none)"))Code: Select all
py find_refs.py 0x7a0dc 0x79440 0x8eff0
0x7a0dc: 0x23780
0x79440: (none)
0x8eff0: 0xd73f0My tools folder holds about forty scripts like this. That is what the AI actually produced in this project — not conclusions, but the tooling that turns a guess into a fact.
On naming strings and CAN/UDS IDs
You are right that this is where the handholds are, and I used them. String search plus cross-references will label maybe ten percent of the functions for free, and on the CAN side there are tables worth naming immediately: the acceptance list at 0x79440, the message-type table at 0x7A0DC, the media field-handler list at 0x8EFF0. The protocol constants fall out of those — field 0x46 title, 0x42 artist, 0x3F album; source byte 0x12 Bluetooth, 0x13 USB, 0x15 CD; IDs 4C7 USB, 4B0/4B1 Bluetooth, 4C1 CD. That made the RX framework readable in an afternoon.
But it is not what solved it. What solved it was a differential: the cluster already renders track text — for USB. So I never had to understand the display stack, the font renderer or the screen manager at all, only the one point where the two paths diverge — a single byte in a table.
It also hands you a test oracle before you write any patch. Inject [0x46][0x12][01][01]"TEST" on the working USB ID 0x4C7 — field "title", source byte "Bluetooth" — and "TEST" appears on the Bluetooth Audio screen. That proves the whole downstream pipeline already handles Bluetooth, and that the only missing piece is delivery of the 0x4B1 text.
So before naming anything, look for a feature that already works and is a sibling of the one you want. Far cheaper than understanding the subsystem.
How we found the space for the cave
Not from the forum — a scan. The VBF erases the whole code partition 0x5000-0x100000, so anything in that range is fair game provided nothing uses it. Long runs of a single fill byte are inter-function alignment padding and end-of-image filler:
Code: Select all
# find_cave.py - runs of fill bytes in the code partition
BASE = 0x5000
CODE = open("images/main_code_0x5000.bin", "rb").read()
MIN = 296 # the size the cave needs
runs, i, n = [], 0, len(CODE)
while i < n:
b = CODE[i]
if b in (0x00, 0xFF, 0xEF):
j = i
while j < n and CODE[j] == b: j += 1
if j - i >= MIN: runs.append((BASE + i, j - i, b))
i = j
else:
i += 1
runs.sort(key=lambda r: -r[1])
for start, ln, b in runs[:8]:
print("0x%08x %4d B fill 0x%02X" % (start, ln, b))Code: Select all
0x000ddc64 103840 B fill 0xEF
0x000fb1f4 19972 B fill 0xEF
0x000d8900 5888 B fill 0xEF
0x00014514 2796 B fill 0xEF
0x00083240 713 B fill 0x00Note that all of this is per-image. On another firmware version — 15.07, say — the picture from this same scan looks different, so the cave address, the fill byte and every hook anchor will most likely be different values, and they have to be measured on that image rather than carried over. Nothing is published for any version other than 14.12 at the moment.
Either way the apply script re-verifies the region is still empty before it writes and refuses otherwise, because "unused flash" is a property of one specific image, not of the ECU. My byte probe across the whole Convers+ family (47 VBFs / 38 APP images) turned up plenty of images with a big enough hole and zero matching patch anchors. A hole is necessary, never sufficient.
On your point that this makes patches "almost potentially instable" — I would put the risk somewhere else entirely. Code does not become unstable because of where it lives; padding between functions executes exactly like any other flash, and the cave is as deterministic as the code around it. The three things that actually destabilise a patch are:
- The region being free in your image but not in the next version. Handled by asserting the bytes and refusing, never by assuming.
- The hook site. Mine sits in the CAN receive path and runs for every frame on the bus, including the ones carrying speed, RPM and warnings. So the cave has to preserve every register it touches, stay re-entrant-safe and be short. That is where a cluster gets destabilised — not at the address the code happens to occupy.
- The state you keep. I needed 24 bytes of SRAM scratch for ISO-TP reassembly, and finding RAM the firmware genuinely never touches took me longer than writing the cave did — the obvious-looking gaps in .bss are not free at all.
The arbiter
Unicorn: image mapped at 0x5000, SRAM at 0x40000000, peripherals stubbed, one function called directly with the registers I choose. Real run against a stock dump — it patches a throwaway copy and A/Bs the two:
Code: Select all
Patch state: STOCK (hook @0x236f6 = f7 ff fc f5)
[apply_patch_v3.py] version: 14.12 anchors hook/rend/perbus/dlist/SABC/STORE: OK
[apply_patch_v3.py] CAVE 296 B @0x83240; bl->cave f0 5f fd a3
Same 0x4B1 frame, same method, driven through the real hook @0x236f4:
BEFORE stock title='' artist='' -> PASS (dropped, as it should be)
AFTER patched title='Strangers' artist='Kenya Grace' -> PASS (reaches the store)
renderer title='Strangers' artist='Kenya Grace' -> PASS (drawn on screen)
CONTROL wrong 0x4B0 title='' artist='' -> PASS (correctly ignored)Rather than just offer the harness, here is the useful core of it. This is the whole idea in 40 lines: map the code partition and SRAM, then call one firmware function directly with the registers you choose. It runs as-is — the output below is real:
Code: Select all
# mini_harness.py - the smallest useful Unicorn harness for the Convers+ firmware
# Demo: call the cluster's own strcpy (0x74f20) and watch it copy a string in SRAM.
# pip install unicorn
from unicorn import *
from unicorn.arm_const import *
BASE = 0x5000 # code partition load address
SRAM = 0x40000000
SRAMS = 0x00040000 # 256 KB is plenty for experiments
STACK = SRAM + SRAMS - 0x400
code = open("images/main_code_0x5000.bin", "rb").read()
uc = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_BIG_ENDIAN) # ARMv4T, BE32
uc.mem_map(BASE & ~0xFFF, (len(code) + 0x2000) & ~0xFFF)
uc.mem_write(BASE, code)
uc.mem_map(SRAM, SRAMS)
DONE = 0x00004000 # fake return address
uc.mem_map(DONE & ~0xFFF, 0x1000)
def call(addr, **regs):
for name, val in regs.items():
uc.reg_write(globals()["UC_ARM_REG_" + name.upper()], val)
uc.reg_write(UC_ARM_REG_SP, STACK)
uc.reg_write(UC_ARM_REG_LR, DONE | 1) # Thumb bit
uc.emu_start(addr | 1, DONE, count=200_000)
return uc.reg_read(UC_ARM_REG_R0)
src, dst = SRAM + 0x100, SRAM + 0x200
uc.mem_write(src, b"Kenya Grace\x00")
uc.mem_write(dst, b"\x00" * 32)
call(0x74f20, r0=dst, r1=src) # strcpy(dst, src)
out = uc.mem_read(dst, 16).split(b"\x00")[0].decode()
print("dst after call: %r" % out)Code: Select all
dst after call: 'Kenya Grace'From there you grow it: a UC_HOOK_MEM_WRITE to log every store (that is how I found which SRAM the media handlers fill), and stub anything touching hardware by writing a bare "bx lr" over its entry point. That is all tools/emulate.py really is, plus the plumbing to patch and A/B a dump.
On recovering full source instead of patching free space
You have completely decompiled a Flash in IDA before, so you know the size of that job far better than I do. I would still argue it is the wrong target here, for practical rather than theoretical reasons:
- Verification. With a cave the diff is ~300 bytes and I can prove byte-for-byte that nothing else moved; regression testing is "rebuild, compare hashes". With a full rebuild you have a megabyte of new binary and no way to prove equivalence. The frightening failure mode is not "won't boot" — you would notice that. It is a timing-sensitive corner of the ABS / odometer / DTC path misbehaving six months later, in a safety-related component.
- The image is not just code. Absolute addresses are baked into data everywhere: the message-type table at 0x7A0DC, the field-handler list at 0x8EFF0, the acceptance filter table, display lists, calibration blobs. Recompiling relocates everything, so all of that has to be regenerated correctly — and much of it you would only discover by having it break.
- Toolchain identity. The original is an early-2000s ARM compiler; matching its ABI, struct layout and optimisation behaviour is a project of its own. Below the APP image there is also a bootloader I still do not have (it is in no VBF), plus the VBF block/CRC16 layer on top — the Flashpack itself.