Teaching a 2016 Nissan Murano to Speak Android Auto
A technical account of adding Android Auto to a 2016 Nissan Murano / Clarion QY84-50NA head unit by re-hosting its firmware in emulation, reverse-engineering the boot and rendering paths, and grafting the Android Auto components from a 2018 firmware onto the 2016 image via the SD card the unit already reads. The factory hardware was never removed or modified.
Table of Contents
- Overview and Hardware
- The Dual-QEMU Emulator
- Firmware Storage: ROM, Tooling, and SD-Card Forensics
- The SD Entrypoint: A Skin-Loaded DLL
- The SH4 H.264 Unlock
- Audio Routing into the Bose System
- Provenance of the Android Auto Components
- Tracing and Instrumentation
- Prior Art
- Development Chronology
- Reverse-Engineering the Map Format
- Doom, Gated to a Stopped Car
1. Overview and Hardware
This is a description of adding Android Auto to a 2016 Nissan Murano head unit that never shipped the feature, done entirely by editing the SD card the unit already reads and without removing or modifying the factory hardware. The work was carried out by re-hosting the unit's own firmware in emulation, reverse engineering the boot and rendering paths, locating a later firmware that already contained the Android Auto code, and grafting the relevant pieces onto the 2016 image. A secondary goal — a native Doom build, gated off while the vehicle is in motion — is covered in Chapter 12.
1.1 The hardware
The unit is a Clarion QY84-50NA, the head unit Nissan fitted to the 2016 Murano with the Bose audio package. The car retains its unsubscribed SiriusXM tuner; unsubscribed, it plays a looping advertisement instructing the listener to call an 800 number to subscribe. The factory head unit was never removed from the dash during any of this work; every change lives on the removable SD card.
The QY84-50NA is a two-processor design. Two different CPUs run two different operating systems and cooperate over shared memory:
- ARM side: a Renesas R-Car M1A (R8A7778, a single Cortex-A9) running Windows CE 6. It owns the display, the touch panel, the storage stack, USB, audio peripherals, CAN, the tuner, and navigation. Everything presented as "the head unit" — menus, maps, apps — is a Windows CE program running here. (The board's own descriptor string reads "R8A7779"; the modelled and correct part is the R8A7778 / R-Car M1A.)
- SH4 side: an SH7750R / SH-4A that Clarion calls the SH-PRG, running a Renesas HI7750/4 ITRON real-time kernel. It is the Bluetooth/DSP sub-CPU. It has no display and no USB. It communicates with the ARM through an on-chip mailbox and doorbell and otherwise runs independently.
The two chips are wired together through the SoC's HPB mailbox/doorbell hardware. The ARM's boot sequence depends on the SH-PRG coming out of reset and answering a handshake, so the two cannot be treated as one machine: faithful operation requires modelling both processors and the link between them. That constraint dictated the shape of the emulator (Chapter 2).
1.2 Where the code lives, and where execution starts
The unit boots from firmware in ROM (NOR) flash, not from the SD card. The SD card supplies filesystem content — the map/OS volume, the runtime skin, and the Android Auto modules — but the operating system image itself is resident in flash. The entrypoint is not a bootable image on the card; it is a file the flash-resident firmware chooses to read.
The stock firmware loads its HMI through the Clarion AUI framework
(auiapp.exe + auirtdll.dll + multiskin.dll), all resident in the ARM WinCE
G2 NOR ROM. multiskin composes a NOR-resident base skin with a follow-on
file skin read off the SD card. That follow-on skin is the point of leverage:
its Android-Auto tile carries an ExtFunc string that auirtdll passes verbatim
to LoadLibraryW. Point that string at a full path on the SD card and the
firmware loads an ordinary card file as a DLL. The chain is therefore:
ROM firmware boots → multiskin loads the follow-on skin off the SD →
the modified skin's ExtFunc → LoadLibraryW → our card-resident DLL.
Chapter 4 covers this chain in full, including why the SD-only path was chosen over flashing (the NK kernels, OAL, and bootloader are ROM-masked and failsafe-protected, and the stock updater path was researched and deliberately abandoned — it was used only for a one-time read-only ROM/SRAM dump).
1.3 Provenance of the Android Auto code
The clean base unit is model year 2016 (G116NNNI.560, part 470-5628-11). The
Android-Auto-bearing firmware is model year 2018 (G118/G218/G318NNNI). The
approach here keeps the MY16 base and grafts only the MY18 Android Auto pieces
onto it. The prior art is the inverse: a wholesale swap that runs a later,
Android-Auto-bearing image on the 2016 hardware. The MY18 donor record set and
the firmware image it was carried in are covered in Chapter 9. The ARM WinCE
binaries are cross-built with clang-18 / lld-link-18 (Chapter 4).
1.4 Project timeline
The work spanned roughly five to six weeks in August–September 2026. Dated milestones:
- 2026-08-11 — First firmware reverse-engineering session.
- 2026-08-13 → 08-18 — ARM QEMU bring-up toward first splash; the ~1 GB OTA
UPDATE.DATdecrypted (27-round ARX ECB, null key, recovered fromPRGUpdate.exe);DrawOi.exe/OIDEFALT.imgsplash assets extracted. - 2026-08-22 — First boot-animation frame renders in QEMU.
- 2026-08-28 → 08-30 — Menu/second-boot bring-up in the dual-CPU emulator; warm-reset, card-insert, and LCD-micom handshake fixes.
- 2026-09-03 — Android Auto feasibility work begins, alongside OSM→Nissan map-card conversion.
- 2026-09-06 → 09-07 — SH firmware role and Android Auto codec-service analysis (the task 0x41 / 0x43 map); Android Auto card build.
- ~2026-09-12 — Android Auto reachable and rendering in QEMU via the video HLE path (Chapter 2).
- ~2026-09-13 — Android Auto on the real car: the first hardware projection. On the second boot the phone connected, projected, and accepted input. (The first boot crashed Bluetooth; a follow-up fixed the encoder-hook race.)
- ~2026-09-19 — Audio routing into the Bose DSP solved: reliable projected playback with no dropouts (Chapter 6).
1.5 Chapter map
- Chapter 2 — the dual-QEMU emulator: two QEMU processes over shared memory, booting the real firmware from its reset vector, the CPUCOM link, the display and GPU HLE, and the Android Auto video HLE path that renders projection video inside the emulator.
- Chapter 3 — firmware storage: the ROM/TOC XIP layout, the registry hives, the host tooling, the integrity mechanisms, and full SD-card forensics.
- Chapter 4 — the SD entrypoint: the skin-loaded DLL, why SD-only, what is ROM-fixed versus SD-overridable, and the cross-toolchain that builds the DLL.
- Chapter 5 — the SH4 H.264 unlock.
- Chapter 6 — Android Auto audio routing into the Bose DSP.
- Chapter 7 — provenance and prior art.
2. The Dual-QEMU Emulator
The head unit is re-hosted as two cooperating QEMU 8.2.2 processes, not one VM:
qemu-system-arm -M murano-arm— the R-Car M1A / Cortex-A9, running the unmodified WinCE 6 firmware. It owns the display, touch, storage, USB, audio, and CAN.qemu-system-sh4 -M murano— the SH7750R / SH-4A companion (the SH-PRG), running the ITRON Bluetooth/DSP firmware.
The QEMU machine names murano-arm and murano are just labels for these two
custom boards, not accurate core or SoC identifiers.
The two are coupled entirely in software: mmap'd host files plus UNIX datagram
sockets that model the on-chip HPB mailbox and doorbell. There is no distributed
icount and no true lock-step; the two processes run loosely coupled over shared
memory with generous timeouts. About 47,000 lines of Murano-specific C sit across
the two forks; the largest single body is
target/arm/tcg/op_helper.c (22,275 lines) of guest-PC HLE hooks and tracers.
Both binaries are built at -O2. This is load-bearing: at -O0 the ARM ran
roughly 3× slower, could not service the CPUCOM handshake in time, desynchronized
from the free-running SH4, and never reached rendering.
2.1 Booting the real firmware from the reset vector
The canonical boot path uses no host overlays or fabricated entrypoints. The ARM resets into a first-stage boot ROM, branches to the real WinCE OAL StartUp, and lets the guest kernel build its own page tables, KData, and vector page. The design principle is to model the hardware hand-off, not to poke guest RAM to stand in for it.
The case that fixed that principle in place was a prefetch abort: reset the ARM
directly to the NK image and it wedges at the prefetch-abort vector with a NULL
dereference. The in-image OAL StartUp locates the OEMAddressTable
PC-relatively and converts its own load address to a virtual address via
PaToVa. On real hardware the OAL is entered at physical 0x08000000, where
that resolves; entered instead at the virtual alias 0x88000000, the load
address falls outside every physical range the table covers, so PaToVa
returns 0, the kernel stores that into a boot-map global, and a later
dereference aborts unrecoverably. The real defect is a missing first-stage
bootloader, not a missing RAM value. A four-instruction boot ROM installed via
rom_add_blob_fixed at PA 0x06000000 — a window WinCE never maps —
reproduces the SoC mask-ROM hand-off into the physical NK entry and lets the
in-image OAL do everything else itself. With it in place the board performs
zero guest-RAM writes on the boot path.
The Cortex-A9 model also had to retain already-translated instructions across the
SCTLR.M clear until a normal translation-block boundary (reusing QEMU's XScale
mechanism), so BL's immediate branch after disabling the MMU reaches its physical
continuation. This is an inferred A9 pipeline behavior, regression-tested with a
generic vexpress-a9 case; single-stepping the clearing MCR forces a TB boundary
and defeats the test.
The SH4 core is held in reset (cs->halted = 1) until the ARM writes the release
key 0xa55a0001 into the shared doorbell, mirroring the real HPB reset
controller; the SH entry point comes from the ARM's latched 0xfe400040 value.
The SH4 firmware also uses odd P4 addresses (≥ 0xFFFF7E03) as syscall traps:
letting P4 translate bypasses the firmware's syscall dispatcher entirely, so the
model raises an Instruction Address Error there. Without it the SH4 guest cannot
make a single system call.
2.2 CPUCOM — the ARM↔SH4 link
CPUCOM is the modelled HPB mailbox/doorbell. It consists of:
- A 4 KiB doorbell file of u32 cells mirroring six ARM MMIO addresses plus
sequence counters. Key cells:
0x00reset key0xa55a0001(ARM→SH),0x04SH entry PA,0x08run/halt, the command latch/kick group, and the SH→ARM answer/progress/released cells. - An 8 KiB two-slot packet queue at
0xfe790000: two0xc00-byte slots (request, response) with a0x24-byte little-endian header (total, span,service<<1, command, and six payload words). - A 12 MiB SH-PRG staging window at
0x14000000and a 52 MiB shared window at0x14c00000, both host-file MemoryRegions shared with the SH4 process.
The two CPUs agree on a physical address contract, not a virtual one: the ARM
references the CPUCOM window via cached kernel VAs 0x94c00000..0x97ffffff, and
because the OEMAddressTable maps VA 0x90000000 → PA 0x10000000, those are
physical 0x14c00000..0x17ffffff — exactly the band the SH4 image references.
The SH polls the doorbell every 250 µs on the realtime virtual clock; a kick
raises INTEVT 0x780, and the runtime CPUCOM IRQ is a board-owned INTEVT 0x6c0
(board-owned so guest writes to unrelated SH7750 DMA-priority registers cannot
disturb it). Reset release goes through async_run_on_cpu() because resetting
CPUSH4State directly from the realtime timer raced the running translated
block. Steady-state ARM→SH traffic is service 0x65: version, config, then a
status poll every ~21 s that the SH answers with a compiled heartbeat constant.
2.3 Boot logo and display
The boot splash is DrawOi.exe (CDisplayOpeningImage / OpeningImageFunction),
launched by AppLaunch. GWES is a pure windowing server with no ROM-side
painter, so the first on-screen pixel must come from a UI client. DrawOi.exe
was recovered by decrypting the OTA UPDATE.DAT (a 27-round 64-bit ARX block
cipher, ECB, null key, reverse-engineered from PRGUpdate.exe at VA 0x118e8).
Note that a copy of the splash also exists in ROM: the string OIDEFALT
appears in the ROM OS carve nk_nand_g116.bin at offset 4136928, and the ROM
G116NNNI.560 NK1 is a full XIP OS that boots OAL→MMU→KernelStart→GWES→DrawOi to
the Nissan splash. The splash is not confined to the encrypted OTA blob.
The display is the R-Car DU0 at 0xfff80000. The board programs the DU on
the guest's behalf — murano_arm_gles_du_associate() writes a canned
17-register ARGB1555 plane program, doing what ddi_ncg.dll would have done —
and scans out 800×480. The present path runs eglSwapBuffers →
glReadPixels(800×480) → vertical flip → pack to the live DU plane format →
cpu_physical_memory_write into the guest scanout. Touch is a modelled TMA616
I²C controller driven by the host mouse, with scanout_y = height-1-scanout_y
because the panel reports a bottom-origin Y.
2.4 GPU HLE: PowerVR → llvmpipe
The guest renders its HMI via PowerVR SGX through libEGL.dll / libGLESv2.dll.
That stack is not emulated. Instead the translate boundary recognizes every
DLL-export entry PC (176 exports: 34 EGL + 142 GLES2), emits a helper that
decodes AAPCS arguments from the guest registers/stack, dispatches on the export
ordinal, writes r0, and sets PC ← LR — the guest DLL body never runs. The
host backend is surfaceless EGL on Mesa llvmpipe
(GALLIUM_DRIVER=llvmpipe LIBGL_ALWAYS_SOFTWARE=true EGL_PLATFORM=surfaceless).
Every glShaderBinary IMG binary is discarded and replaced with a single
purpose-built GLSL vertex+fragment pair.
A SIGSEGV during menu draw traced to an llvmpipe texstore over-read past the
last row; uploading one scanline per glTexSubImage2D
(host_tex_upload_rgba_rows) bounds every read in-bounds.
2.5 Android Auto video renders inside the emulator
During development the Android Auto video path was high-level-emulated in QEMU so projection could be seen without the car; this is an emulator-only path and does not run on real hardware. The path:
murano_video_hle.crecognizes the guestH264DecoderFilter_VPU5HD.dllentry PCs (module0xefda, PCs0x8028/0x833c/0x85ec) and maps them to CPUCOM task-0x43 commands0x4304(open),0x4305(decode),0x4307(release).murano_h264_hle.cdecodes the H.264 stream with host FFmpeg (avcodec_send_packet/avcodec_receive_frame),sws_scales toAV_PIX_FMT_NV12, and writes the frames into the guest's registered output pools (33 slot addresses).murano_vio_hle.cstands in for the VIO6C compositor;murano_gles_hle.cpresents via EGL/GLESv2 on host llvmpipe.
This high-level emulation was a development convenience, not something that runs on the unit; the off-switch exists only to exercise a real SH-PRG that actually implements task 0x43. On the real car the video path runs on the actual VPU5HD hardware, which QEMU's SH4 model deliberately lacks (no VPU MMIO), so hardware decode is validated on the unit, not in the emulator.
2.6 The SH4 was also emulated for the H.264 unlock
The SH4 QEMU model was not only for the boot handshake. It was reused to
develop the H.264 unlock (Chapter 5). The stock SH companion (AD13XXNI.705)
registers no CPUCOM task 0x43, so Android Auto's video graph never builds. The
unlock adds that service without reflashing the media core: it borrows the
running stock AAC-encoder task (0x41), snapshots the handful of bytes and
registers that task overwrites at its call boundary, injects a VPU H.264 decode
service as task 0x43 into the freed span, and resumes the encoder where it left
off. The MY18 SH4 codec donor for that service came with the rest of the MY18
material (Chapter 9); Chapter 5 details the thread-borrow and how the patch is
applied.
Emulating this exercised the injection end to end. Under the SH4 machine the staged program passes the real parking ACK and whole-image readback and the grafted task enters real SH code, but its hardware-ready poll never completes because the VPU hardware it waits on is unmodeled. That stall is why final validation of the codec moved to the physical unit.
2.7 USB / AOAP
USB is ARM-exclusive; the SH4 has no USB module. A synthetic usb-aoap QEMU
device (src/arm-qemu/hw/usb/dev-aoap.c) enumerates as a phone already in
accessory mode (Google 0x18D1:0x2D01) so the CE USB stack binds usbaoap.dll
with no host-side switch handshake. Its bulk endpoints bridge over a chardev
socket to the phone's head unit server — the official Android Auto
head-unit-server, a standard Android debug endpoint reached over
adb forward tcp:5277. The binding blocker was root-caused to the stock
catch-all Dummy_Class under LoadClients\Default\Default\Default, which binds
every unclaimed device — an AOAP phone included — to the DMY stub before
usbaoap.dll sees it; the runtime removes it from the live registry (Chapter 4).
3. Firmware Storage: ROM, Tooling, and SD-Card Forensics
This chapter covers three things: how the unit's ROM images and their table-of-contents, filesystems, and registry hives are laid out; the host tooling built to inspect, patch, and rebuild those images; and the forensic map of the boot SD card, including two hidden exFAT partitions and SRAM-backup banks that live outside every partition.
3.1 ROM image and TOC layout
The unit stores Windows CE as three XIP ROM regions discovered through a
vendor descriptor pair — TOC /TOC2/ECEC — at the FMD sub-image header, not
the standard CE XIPCHAIN extension. Region 1 (G1) is the ARM OS
kernel/drivers (96 modules); Region 2 (G2) is the HMI/Navi stack (291 modules);
Region 3 (G3/NK3) is the SHELL/projection XIP (44 modules).
The relevant ROM inputs:
fmd_nor_normal.img— 64 MB FMD NOR (the writable object store / CS0 NOR). The G1 sub-image is at NOR offset0x1c0000.nk_nand_g116.bin— 6.8 MB carve of the G1 ARM XIP (G116NNNI.560, part470-5628-11),physfirst 0x88000000, ROMHDR reached via theECECpointer.sh_nor_03e00000.bin— 8 MB SH4 launcher/recovery NOR (UE12XXXX.012, part470-3573-03, 74 modules includingAppLaunch.dll,SDHC.dll,PRGUpdate.exe).
The multi-region discovery mechanism sits in the G116NNNI.560 sub-image
header: three vendor descriptors (TOC , TOC2, ECEC) near NOR offset
0x1c0000 point at the region-2 and region-3 ROMHDR+TOC pairs and the
region-1 ROMHDR. Their declared sizes match the module counts exactly (291 and
44 modules over CE-6 32-byte TOC and 28-byte FILES entries), so the clean MY16
image already declares a third XIP region rather than one added later. ROMHDR
is standard CE-6 style; the G2 region is MTLD/MTCP-packed with a sector-based
block advance.
Registry hives are three ROM FILES inside G1 — boot.hv, default.hv,
user.hv — in the CE MIKE RegFS format, stored FILE_ATTRIBUTE_COMPRESSED
via the CE ROM-LZX page container. A MIKE hive is a paged store: a fixed
header, a 4096-entry index-block table mapping id blocks to page offsets, and
typed records (keys, values, indices, free tail) reached by 0x20000000 | id
references. murano_hive.py (§3.2) understands this layout; the only property
that matters downstream is that each hive's ROM extent is fixed-size, so an
edited hive must re-encode within it.
The storage stack in G1 includes SDHC.dll, fsdmgr.dll, filesys.dll, and a
single exfat.dll that implements both FATFS and EXFAT; BINFS (partition
type 0x21) is the read-only OS-image partition. SDHC.dll carries the string
CLARION ID: it is the signature Clarion writes to sector 1 (LBA 1) of a
provisioned map/OS card, and the SDHC driver matches it to recognize the card
as a Clarion-authored map/OS volume. A raw byte-exact clone preserves it; a
freshly formatted card lacks it. The registry sets SD AutoFormat=0, AutoPart=0, AutoMount=1, so a blank or cold-erased backing with no valid BPB
will not auto-format and the mount fails.
3.2 The host tooling
A set of Python tools under tools/ inspects, patches, checksum-repairs, and
rebuilds these images. It is a merge/assembler pipeline: it lifts records and
modules from one image into another byte-for-byte and fixes up only what must
change.
murano_pe.py— recovers ordinary CE PE32 files from XIP modules and renames DLL references; reloc-stripped XIP modules are fixed-address, so it never overwrites an input.murano_xip_rom.py— the ROM authoring tool. It appends modules lifted from other ROMs while preserving the base ROM byte-for-byte, updating only the load/relocation fields and the ROMHDR+TOC. It can write an authored ROM into a card copy as a raw firmware record and rename TOC entries in place, including inside the MTLD/MTCP-packed G2 record.murano_hive.py— the registry/hive editor. It decodes the realMIKEhive, edits keys and values, and re-encodes back over the fixed vendor extent (the rewritten stream must fit).murano_skin.py— the skin editor: a lossless AUITK version-7.sknparser/writer/merger that reconstructs the object tree and preserves every unknown byte in record order.assemble-aamerges the Android Auto closure onto the clean base (Chapter 4).murano_aa.py— despite the name, no Android Auto protocol code: a DOS/MBR+EBR image inspector, exFAT walker, WinCE ROMHDR/TOC reader, and byte-exact copier. This is the forensic partition tool.murano_aa_card_build.py— the Android Auto card builder. Because the unit never loads the card's G3 record, it authors nothing there: it keeps both raw firmware records byte-identical and installs the AA stack as ordinary files (Chapter 4).murano_lzx.py/murano_lz4.py— the CE ROM-LZX page-container codec and the raw LZ4 block codec for SCMP skin members.murano_card_files.py/murano_card_skin.py/murano_card_state.py— card copiers and the exFAT contiguous-file replacer; the source is never opened for writing, and output is promoted only after a full readback.patch_position.py— the SRAM-bank patcher and integrity verifier for the SD-backed VSRAM (§3.4).celog_decode.py— a CE-6 CeLog v3 decoder.
3.3 Integrity mechanisms and how they are satisfied
The firmware runs several integrity checks. Each was identified and either rebuilt correctly or avoided:
- Raw-record SUM4 (G1/G3): a zero-seeded, wrapping LE-u32 sum of the record
payload, stored at record
+0x34(markerSUM4at+0x30) — not a CRC32.murano_xip_rom.pyrecomputes it when authoring a record; the card builder sidesteps it entirely by never authoring the G3 record. - MIKE hive index-block/free-slot integrity:
murano_hive.pyrebuilds cell offsets and checksums deterministically after undoing the E8 translation. This matters only for editing a hive on disk; the shipping Android Auto path does not rely on it, because it modifies the registry on the live system rather than by patching a hive file. - AUITK skin footer (
multiskin.dll): the 32-byte footer ispart-number, NUL, u32, "SKIN", "SK16NNNI.560".multiskincompares only the last three footer characters with the NOR SCMP footer. Equal selects the File+Nor composite route (which reads exactly the NOR object-tree size and starts a delayed whole-skin checksum thread), so an oversized hybrid tree gets truncated and reboots; different selects the whole-file route.assemble-aa --file-onlybumps the footer revision.560→.561(a single byte), forcing the whole-file route and avoiding the truncation and the checksum thread. - VSRAM SRST bank checksum: a modulo-2^32 sum of the preceding LE-u32 words,
stored at bank
+0x26bf0(excluding the trailingSREDmagic).patch_position.pyrecomputes it after editing (§3.4). - Activation-record
CalcSCheckSum: summed decoded LE-u32 words with the length at the record's checksum offset, decoded with the firmware key'Clarion 2012 Key'.
3.4 SD-card forensics
storage/card.img (16,206,790,656 B / 31,653,888 sectors, 512 B/sector) is a
byte-exact raw clone of the physical map/OS SD, confirmed by cmp against the
card at the exFAT LBAs. The full reconstructed partition table:
LBA range (byte range) type role / contents
------------------------------------------------------------------------------------
0 (0x00000000) MBR partition table + boot code
1 (0x00000200) -- 'CLARION ID' signature
pre-partition gap (sectors 0..888832) -- UNPARTITIONED PREFIX (~455 MB):
- G3/NK3 raw XIP record @0x02000000
(G316NNNI.560, 30,967,904 B)
- activation records MPIF @0x18700000,
SMDA @0x18700600, SDLF @0x18701200,
NVFL @0x18701800
- VSRAM SRAM-backup banks (SRST/SRED):
bank A @0x18a0e800 (0x26c00 B),
bank B @0x18a35400 (0x26c00 B),
bank C @0x18a5c000 (SRST)
- BASS/MPUP updater headers
MBR p1 888833..29925376 (byte 455,082,496) 0x0c FAT32 \SystemSD (MAP TREE), 29,036,544 sect
(14.87 GB), label '485-2108-00', 64 KiB clusters, vol serial EC56-D4C8
MBR p3 30949377 (byte 15,846,081,024) 0x05 EXTENDED container, 704,511 sect (344 MB)
p5 (logical) 30949378 (byte 15,846,081,536) 0x0c exFAT \SystemSD (HIDDEN), 81,920 sect
OEM 'EXFAT ', 32 KiB clusters,
vol serial 0x082302ac;
/MIP/ABQ/ HUP runtime (HMI.zip, hup.py,
Python 2.7 .pyc, _ssl.pyd, choreo-rootCA.crt)
p6 (logical) 31031299 (byte 15,888,025,088) 0x0c exFAT \SystemSD2 (HIDDEN), 245,760 sect
32 KiB clusters, vol serial 0x0824004a;
/PRIVATE/MIP/ABQ/ HMI web assets + HUPCACHE;
also the SkinChange/002/Substance.skn target
The two exFAT logicals are "hidden" only in that mtools reports them as
non-DOS media; each carries a valid exFAT VBR. A full byte-scan of the clean
card found zero AndroidAuto/CarPlay/AOAP strings — the clean unit's phone
integration is ABQ/HUP, not projection.
SRAM backups outside partitions. The unit's SD-backed VSRAM is fronted by
VSramSD.dll (VSD1:). Its data lives in the unpartitioned prefix of the
card, outside every MBR partition, in two redundant checksummed banks
(SRST/SRED magic, ~159 KB each). Among their contents is the last-known GPS
position — the navigation mesh point the unit falls back to when it has no live
fix, which on this card encoded the owner's home area. Zeroing both banks
changed the map the unit drew on the next boot, which proves those banks are
the position source. Both banks were dumped, the position was relocated, and
each bank's checksum was recomputed with patch_position.py.
The unit does not bind to a card CID/CSD, and it does not use SD card lock. A byte-exact raw clone therefore preserves all card-resident activation and SRAM records intact, which is why a clone boots as the original.
4. The SD Entrypoint: A Skin-Loaded DLL
Code execution on the unit is reached without flashing anything. The flash-resident firmware boots normally, composes its UI skin from a NOR base plus a follow-on skin read off the SD card, and that follow-on skin — a filesystem file, editable — is modified so one tile loads a card-resident DLL. This chapter traces the full chain, explains why the SD-only path was chosen, enumerates what is ROM-fixed versus SD-overridable, and describes the cross-toolchain that builds the DLL.
4.1 The boot/entry chain
The HMI is the Clarion AUI framework: auiapp.exe + auirtdll.dll +
multiskin.dll, all resident in the ARM WinCE G2 NOR ROM. auiapp.exe takes
<skin file> <start object>; multiskin.dll composes a NOR-resident base skin
with a file-based skin (its strings read " Multiskn >> File + Nor => Obj Skin").
The bootstrap skin is a ROM file, ReleaseMain.skn (69,632 B), and its
final named object is the follow-on path
\SystemSD2\SkinChange\002\Substance.skn — a file on the hidden exFAT volume of
the boot card. So the composition reads a NOR base skin and an SD file skin and
merges them.
A skin is a compiled object tree. One object type, ExtFunc, is a native
callback: in the compiled .skn its body is
{module string, export name, int16 argc, BSTR args}, written with the AUI type
tag 0x0406. The mechanism that turns this into code execution is simple:
auirtdll passes the ExtFunc module string verbatim to LoadLibraryW and
resolves the named export. If the string is a full path such as
\SystemSD\AA\aaboot.dll, WinCE loads an ordinary card file rather than a ROM
module.
The full chain:
ROM firmware boots → multiskin composes NOR ReleaseMain.skn + SD
\SystemSD2\SkinChange\002\Substance.skn → the modified skin's Android-Auto-tile
ExtFunc (tag 0x0406) names \SystemSD\AA\aaboot.dll!AABootstrap →
LoadLibraryW loads the card DLL → AABootstrap runs.
The payload swap is produced by
murano_skin.py assemble-aa --file-only --native-module '\SystemSD\AA\aaboot.dll',
which rewrites the projection/Apps tile (ROM rows 311–317) into an ExtFunc
act_AA_Bootstrap that calls aaboot.dll!AABootstrap on the tile tap, before
the stock AA controls are constructed and the original click action dispatched.
The build verifier requires that the packaged skin contain exactly one
act_AA_Bootstrap ExtFunc whose module is \SystemSD\AA\aaboot.dll and whose
export is AABootstrap.
4.2 What the DLL does
aaboot.dll (runtime/aa-register.c, exported entry AABootstrap at ordinal 1)
pins the AA runtime and stands up the phone binding. It:
- Loads
coredll.dll, opens state journals on both\SystemSD\AA_STATE.TXTand\SystemSD2\AA_STATE.TXT. - Pins
aaboot.dll,aalog.dll,aacommon.dll, and probesAndroidAutoCC.dll/AndroidAutoTouchCC.dll— all held resident for the life of the process, because the skin briefly loads the DLL through itsExtFuncand the pointer must stay valid after return. - Writes the COM registry values, then starts an arrival worker and returns to the UI.
- In the worker: writes the USB (AOAP) registry rows, removes the
Dummy_Classcatch-all, requests device-arrival notifications for the AOAP and stream GUIDs, drains already-attached devices, andCreateProcessWs the AA executables (aaaudio.exe,aasystemexe.exe,aausb.exe,AndroidAuto.exe).
Binding to unknown USB devices has two parts. First, the AOAP client key is
permissive: all six Google AOAP product IDs 0x2D00..0x2D05 bind usbaoap.dll.
Second, remove_dummy_class() deletes
HKLM\Drivers\USB\LoadClients\Default\Default\Default\Dummy_Class, the stub that
otherwise binds every unclaimed device — an AOAP phone included — to
USBDummy.dll before usbaoap.dll sees it.
4.3 Why SD-only, and what is ROM-fixed versus SD-overridable
The SD-only skin-file path was a deliberate choice, not a fallback. Two reasons:
- The flash side is masked and failsafe-protected. The NK1/NK2/NK3 kernels,
the OAL/bootloader, and the base ROM assets are XIP and guarded by a
LoaderVerifierplus NK1/NK2/NK3 failsafe images. Flashing them risks bricking with no recovery path, so they were left untouched. - The stock updater path was researched and abandoned. The OEM updater/OTA and the leafsdtools update-mode SD were used only for a one-time read-only ROM/SRAM dump. Deployment writes only SD-card filesystem records; the firmware inputs are never opened for writing. The historical pipeline that authored an Android Auto G3 XIP ROM into the card's G3 record was retired once it was confirmed the head unit does not load the card's G3/NK3 record — "only the card's filesystems are used."
Concretely:
ROM-fixed (cannot change without flashing): the NK1/NK2 kernels; the
OAL/bootloader; the DrawOi.exe splash; auiapp.exe / auirtdll.dll /
multiskin.dll; the base skin \WINDOWS\ObjSubstance.skn and ReleaseMain.skn;
the PROD product-info block in CS0 NOR; the Model Code in battery-backed SRAM;
and the SCMP-compressed Android Auto skin baked into the G2 OTA.
SD-overridable (filesystem files the firmware reads): the follow-on
Substance.skn under \SystemSD2\SkinChange\002\; the Android Auto modules
under \SystemSD\AA\ (aaboot.dll, aalog.dll, aatrace.dll,
AndroidAuto.exe, aasystemexe.exe, aausb.exe, aacommon.dll,
AndroidAutoCC.dll, AndroidAutoTouchCC.dll, usbaoap.dll, sh_ad870.img);
the SD-only NK3 G316NNNI.560 at card LBA 65536 (0x10000); and the
map/nav/speech data.
Because the G3/NK3 XIP record on the card is never executed, the card builder
does not touch it. The single deployed mechanism edits the follow-on skin on
the card — \SystemSD2\SkinChange\002\Substance.skn — in place, rewriting its
Android-Auto tile into the ExtFunc that loads \SystemSD\AA\aaboot.dll.
4.4 The file-DLL constraints
Loading a card file as a DLL imposes two constraints that a ROM XIP module does not have, both discovered on the stock unit:
- A DLL without base relocations fails
LoadLibraryWwith error 193 (BAD_EXE_FORMAT): a reloc-stripped, fixed-address image cannot be placed by the file loader. The builder synthesizes a fixup table for the reloc-stripped CC/TouchCC servers. - A section flagged
IMAGE_SCN_MEM_NOT_PAGED(0x08000000) fails a file DLL with error 14 (ERROR_OUTOFMEMORY); identical bytes with the bit cleared load. The builder clearsNOT_PAGED.
4.5 Building WinCE-ARM binaries with clang-18 / lld-link-18
The DLLs and EXEs are cross-built on Linux. The vendor path (eVC4 + SDK500,
32-bit-Windows-only) was not used; the toolchain is modern clang/lld, with only
the supporting pieces custom — an import library, a PE→CE header pass, and the
card builder.
Each runtime/*.c is compiled with clang-18 --target=armv7-windows-msvc; the
load-bearing flags are -mthumb, -mno-movt, and -fshort-wchar (built
freestanding, no builtins, no stack protector). Linking uses lld-link-18
against a synthesized coredll.lib import library, built from a generated
coredll.def naming the resolved ordinals; the flags that matter are
/subsystem:windows,6.00, /entry:AABootstrap, /base:0x10000000, and
/merge:.rdata=.data.
The .rdata→.data merge is because CE binds the IAT in per-process writable
DLL data. A post-link ce_header() pass then converts the ordinary PE into
the CE image layout, translating import thunks and CE base relocations.
-mno-movt guarantees only HIGHLOW/kind-3 relocations plus kind-7 import
thunks; a MOVW/MOVT pair would raise "non-CE base relocation; check
-mno-movt". The reported ABI is C void AABootstrap(void *VARIANT_arguments),
triggered by skin ExtFunc 0x406, with no shell/Explorer startup dependency.
5. The SH4 H.264 Unlock
Why a codec at all
Android Auto projects its screen to the head unit as an H.264 video stream. The unit has to decode that stream in hardware and composite it onto the display. On this platform the decode block does not live on the main CPU.
The Murano head unit is a two-processor design. The main CPU is an ARM R-Car M1A
(R8A7778, a single Cortex-A9) running Windows CE 6; it hosts the UI, USB, and the
Android Auto application code. Alongside it sits an SH7750R/SH-4A companion — the
"SH-PRG" — running an HI7750/ITRON RTOS that owns the media pipeline, Bluetooth,
the DSP, and the VPU5HD video accelerator. The two are joined by CPUCOM, a mailbox
packet link. The ARM-side H264DecoderFilter_VPU5HD.dll does not decode anything
itself; it marshals decode requests over CPUCOM to a service task on the SH-4A,
which drives the VPU.
The problem is that the service task is not present in the model-year 2016 firmware.
What the stock SH program is missing
The stock MY16 SH program is AD13XXNI.705_470-5710-04: base virtual address
0x80020000, 5,429,760 bytes, expanding to a 0x52DA00-byte flat SH-4A image.
It registers CPUCOM services for audio decode (task 0x42, ATRAC3) and video
render (task 0x45, VIO6C), but it registers no task 0x43 — the VPU5HD
H.264/MPEG4/WMV decoder service (commands 0x4304–0x4307). When Android Auto's
video graph asks for a decoder, the SH side NACKs the request and graph
construction fails with videoout.cpp DIRECTSHOW INITIALIZE ERROR. Task 0x45
is already there; only 0x43 is missing, so the whole unlock targets exactly
that one task.
The MY18 firmware has it. AD13XXNI.870_470-7358-01 is a drop-in same-family
SH-4A program that expands to the same 0x52DA00-byte layout at the same base VA
and adds task 0x43, with request/response sizes that match the MY16 G216 ARM
filter byte-for-byte. So the donor code exists; the work is getting task 0x43
into a running MY16 SH kernel without disturbing anything else it is doing.
Two approaches were built. The first is a static rebuild that produces a hybrid SH image; the second is a live, no-reset injection into the running SH kernel. The live path is what ships, because the shipping requirement is to write nothing to ROM and never reset the media core — the static swap would do both.
Before committing to the SH4 hardware path, the alternative — decoding H.264 in
software on the ARM — was tried and measured. A CE-ARM build of Cisco's OpenH264
(tools/build_openh264_ce.py → h264bench.dll) was packaged onto the card and run
once on the AA worker, decoding an 800×480 test clip and logging its frame rate to
H264BENCH.TXT. It was only ever a benchmark: a single Cortex-A9 has no headroom to
software-decode a real-time projection stream while the rest of the HMI runs, and a
self-built OpenH264 also carries H.264 patent-pool exposure that Cisco's own runtime
binaries would not. The experiment was abandoned and nothing in the shipping path
links it; on the unit, H.264 decode goes to the stock VPU5HD hardware through the
task-0x43 unlock below.
Static rebase: tools/relink_705_h264.py
Both the stock and donor records expand to the same flat program, so the donor's
H.264 closure can be lifted out and placed into regions that are all-zero in the
stock image, leaving every nonzero stock byte untouched. The tool copies the donor
closure — its worker/IRQ code, the decoder library, the command and mode tables,
and the VPU microprogram — into stock-zero destinations with 4-byte-aligned deltas,
which preserves SH mov.l PC alignment and every internal branch, then relocates
each PC-relative address literal through map_address.
The startup descriptor table is rebuilt with the stock rows copied verbatim and one
new task-0x43 row inserted at startup index 4; the startup-table pointer is
repointed to the rebuilt table. The build asserts that task 0x43 occurs exactly
once and that no nonzero stock byte changes except that single pointer, producing a
byte-exact hybrid SH image.
The static hybrid proves the relink is correct, but swapping the whole SH program means resetting the media/BT/DSP core. The live path avoids that.
The live, no-reset injection
The runtime installer is runtime/aa-live-codec.c, driven by tools/vpu_live_codec.py.
An ARM-side aaboot.dll compiled with --live-h264 reaches across CPUCOM into the
running SH kernel's physical RAM and stands task 0x43 up in place, while
Bluetooth, USB, CPUCOM, and Android Auto all stay live. The source header states
the constraint plainly: "it never resets or replaces the running SH."
Map SH physical RAM. map_physical() issues
KernelIoControl(0x01013c6c, {physical, bytes}); apply_live_patch maps
0x14020000 for the full SH_IMAGE_BYTES = 5,429,760 — the same staging window
the ARM StartSH primitive uses to launch the SH at boot. The platform model is
read with KernelIoControl(0x01013c24).
VPU register init. Before any task is created, apply_vpu_init replays about
ten donor register stores that bring the VPU5HD block up from cold. Each store is a
read-modify-write against the live hardware — value = (old & preserve) | set,
followed by a barrier and a readback — and the constants are lifted straight from
the donor's own VPU bring-up sequence rather than guessed.
Write the relocated donor closure. The payload rides on the SD card as
\SystemSD\AA\H264LIVE.BIN, a small container of address/size/CRC records. Before
each range is written into the live image it is proven all-zero and afterward
re-CRC'd, so the injection can only ever land in space the stock program does not
use. The packager emits only pages whose stock bytes are entirely zero and
deliberately excludes the startup-table page, because task 0x43 is created
explicitly rather than through a startup row.
Install the SH helper/trampoline code. A short set of purpose-built SH machine code fragments — the handoff wrapper, a marker, a task-41 prehelper, and a task-43 gate helper — is written into the same zero space and verified against its destinations. The gate helper is bounded to a finite poll count with a timeout, so it can never become a permanent readiness spin, and it preserves the native VPU-ready register writes on success.
The thread-borrow
Creating a task on a live ITRON kernel means running the kernel's own
create/register/start routines from inside that kernel's context, on a real
kernel thread. Nothing outside the kernel can call them, and there is no spare
thread to spawn one. So the injection borrows a thread that is already running
inside the kernel: the stock AAC-encoder task, task 0x41, which wakes on a steady
cadence to encode Bluetooth call audio.
The borrow is done by patching a single call site. SH-4A calls through a literal
pool: the encoder reaches its inner handler with a mov.l @(disp,pc) that loads the
target address out of a nearby literal word and then a jsr. The installer
overwrites that one literal in place — the encoder's call literal at 0x8003fb70 is
rewritten so the call at 0x8009d4aa lands in AAC_HANDOFF_WRAPPER_VA instead of
the real encoder entry. No encoder instruction changes; only the address it loads
does. The next time task 0x41 runs that call, its own thread — already inside the
kernel, at the right privilege, with a valid stack — walks straight into the wrapper.
The wrapper runs on the borrowed thread but must leave no trace, because task 0x41
still has real work queued behind this call. It first snapshots the 388 bytes
the encoder is about to overwrite in its shared CPUCOM staging frame, together with
the registers the encoder clobbers (r8..r14, MACL, r0, SP). The snapshot is
taken before the encoder builds its frame, so the borrowed context can later be
restored exactly.
Running on a private stack, the wrapper then stands up task 0x43 by hand: it builds
the task descriptor (ID 67, priority 120, its own kernel mailboxes) pointed at the
relocated owned decode entry rather than the donor's original address, and calls the
running kernel's own create → register → start. It deliberately uses
create+start rather than the blocking synchronous init-send, so task 0x43's
own thread runs its init and polls the VPU-ready bit in its own context, blocking
nothing else.
Once task 0x43 is registered, the wrapper restores all 388 bytes (byte-verified),
restores the saved registers, removes its own hook by putting the original literal
back, and resumes the stock task-41 dispatcher at 0x8002b302. Task 0x41
continues as if it had merely made a normal encoder call. The AAC encoder keeps
encoding; a new video decoder task now exists next to it.
runtime/aa-register.c fails closed: Android Auto does not claim video support
until task 0x43 has reported its SH-side init complete, task 0x41 has resumed
its interrupted native transaction, and stream 2 is free for real decoding.
CPUCOM carriage
The ARM filter drives the new service over the CPUCOM packet queue. command()
builds a 28-byte request {task, cmd, event_name, 1, 1, body, bytes} and sends it
with DeviceIoControl(cpm, 0x80072000, …); receive() polls the reply mailbox with
DeviceIoControl(cpm, 0x80072004, …), treating ERROR_NO_DATA (232) as "keep
waiting" and ERROR_MORE_DATA (234) as "payload ready." Service-to-stream mapping:
| Service | Commands | Owner | Stream |
|---|---|---|---|
0x42 |
0x4204/0x4205 |
ATRAC3DecoderFilter_SH4A.dll |
0 |
0x43 |
0x4304–0x4307 |
H264/MPEG4/WMV DecoderFilter_VPU5HD.dll |
2 |
0x45 |
0x4504–0x4507 |
VideoRenderer_VIO6C.dll |
2 |
Video in the emulator, and the hardware gate
The whole AA video path renders inside QEMU by high-level emulation; it does not
require the real car to be seen. On the ARM board model, murano_video_hle.c
recognizes the guest H264DecoderFilter_VPU5HD.dll entry PCs (module 0xefda,
PCs 0x8028/0x833c/0x85ec) and maps them to CPUCOM commands
0x4304/0x4305/0x4307. Those dispatch into murano_h264_hle.c, which decodes
the H.264 stream with host FFmpeg (libavcodec, av_parser_parse2, swscale to
AV_PIX_FMT_NV12) and writes NV12 frames into the guest's registered output pools.
murano_vio_hle.c stands in for the VIO6C compositor, and murano_gles_hle.c
presents through EGL/GLESv2 on host Mesa llvmpipe. This high-level emulation was a
development-time convenience: it let AA projection be seen in QEMU without the car.
It is not a runtime default and does not run on the unit — on real hardware the
video path goes through the injected SH task 0x43 and the physical VPU5HD. An
off-switch exists only to exercise a real SH-PRG that implements task 0x43. The
modeled command sizes:
| Cmd | Meaning | Request | Reply |
|---|---|---|---|
0x4304 |
open | 0x250 |
0x68 |
0x4305 |
decode | 0x88 |
0xe4 |
0x4306 |
copy | 0x10 |
4 |
0x4307 |
close | 4 |
0x50 |
The SH-4A model was reused to develop the unlock itself, not only for the boot
handshake: the task-0x43 SH-program swap (sh_ad870.img) and the VPU cold-replay
were driven against qemu-system-sh4 -M murano. In those runs (hg870-coherent-20260908,
hg705-coherent-20260908) both the hybrid and the genuine donor pass the real
parking ACK and a whole-image readback, both enter task 0x43, and both stall
polling bit 0 at 0xfd0d0008. That poll is the VPU5HD hardware-ready gate, and
QEMU models no VPU5HD MMIO, so neither image can complete it in emulation. The
identical stall on the genuine donor is what proves the graft's ordering and init
path are structurally correct: the fault is the unmodeled device, not the
relink.
Real decode therefore had to be confirmed on hardware. The first on-car projection came on 2026-09-13: on the second boot, Android Auto connected, displayed, and accepted input. The first boot still crashed Bluetooth — a race in the encoder-boundary hook that a same-day build closed. What QEMU deliberately fakes — the actual VPU5HD silicon decode — is exactly and only what the car was needed to validate.
6. Audio Routing into the Bose System
Getting Android Auto's video onto the screen was a discrete problem with a discrete fix. Getting its audio out of the speakers was a longer fight against the stock system's own routing policy. Audio had never once been audible on the unit before this work; the first sound came out during the September routing sessions, and by 2026-09-19 it was playing reliably, holding its route against the stock Bluetooth stack, and handing that route back to Bluetooth cleanly when Android Auto stopped.
The final architecture lives in runtime/aapcm.c. It is worth stating up front what
the problem was not: it was not a missing "source grant." Android Auto's own PCM
write was fine from the start. What stood between that write and audible sound was
the DSP route selector, a downstream DSP front-mute, and a sample-rate mismatch —
three separate things, each with its own fix.
PCM straight to the wave device
The stock media graph reaches the speakers through a DirectShow chain — a filter, a
pin, an AudioRenderer. The working path uses none of it. The DirectShow
filter/pin/enumerator surface was removed; PCM reaches the driver through
consume() → wave_sink_write(), with no COM involved. The sink opens and writes the
endpoint with waveOutOpen/waveOutWrite directly, exactly the way the stock
Speech guidance path (CGuidanceOut) does. Feeding the driver the way the OS's own
low-latency source does removed a whole layer of policy that AA's PCM would
otherwise have had to satisfy.
The endpoint itself is resolved by type, not by a fixed device number. The SRC4
wave device exposes several sub-endpoints, and the public wave indices are assigned
in interface-arrival order, so a hardcoded index is unsafe. resolve_src4_device()
enumerates waveOutGetNumDevs, queries each device with waveOutMessage(dev, 0x19, &type), and prefers type 2 — the same endpoint the stock AudioRenderer
resolves — falling back to any SRC4 type in 1..4. The device_id = 1 in the source
is only a placeholder for the resolver.
Reading the route, not guessing it
The DSP mux in front of the Bose amplifier is a single selector. Physical register
0xffd90004, bits [23:20], holds the CMD1 route nibble: case 2 is the injected
ARM SRC4 leg (Android Auto), case 4 is the companion SH's SRC2 leg (Bluetooth).
The route can be read directly. route_map() maps the register page via
VirtualCopy(PAGE_PHYSICAL) and the OAL physical-mapping broker
(KernelIoControl 0x01013c6c); route_read() returns the nibble. This physical
read is the signal the routing needed: rather than blindly re-issuing route commands
on a timer, the code can see exactly when it has lost the route and reassert only
then.
The route-steal fight
Once PCM was flowing to the right endpoint, audio played and then dropped out
chronically. The cause is a fight over the selector. The stock PhoneBTA re-asserts
CMD1 case 4 on ordinary Bluetooth events — nineteen event-driven paths converge on a
single waveOutMessage(dev, 0x1c, 1) — and every time it fires it moves the DSP mux
back onto the Bluetooth leg. The injected producer's writes keep returning success
the whole time, so nothing upstream notices; the sound just stops.
The fix is a producing-gated watchdog. It polls the route every 100 ms, and when it
sees the selector sitting on case 4, it calls route_reassert(): an in-place
read-modify-write that writes only the selector nibble back to case 2
((old & 0xff0f0fff) | 0x00201000) and then clears the shared SSI4 DVC mute under
the DSP's update bracket. This is deliberately not the stock 0x1c(0) restore,
which parks the front mute at full scale and reloads the 44.1 kHz coefficient set —
a heavier, audible operation. Reasserting just the nibble is silent and gapless.
The DSP front-mute — the last bug
Holding case 2 was necessary but, on its own, still silent. A stock downstream path
(tomato 0xef910c54) mutes the DSP front output (0d106d) without touching the
route selector at all, so the mux could be correct while the front stayed muted.
This was the last bug before playback became reliable.
The fix is a second periodic action, also gated on producing: every 250 ms the sink
re-issues a no-select front unmute — MUT1: 0x801b2004 with line id 7 — which
re-notifies TOMATO (output 52 → DSP front 0d106d unmute). It carries no
0x801b2010 line-select and no CEN source request, so it unmutes the front without
re-asserting Bluetooth or tripping the on-screen source-changed popup. No stock
module with a matching mute timer was ever located, so the 250 ms re-issue is
empirical; it holds the front open regardless of what re-muted it.
The producing lease
All of this interference must stop the instant Android Auto stops playing, or the
port would fight Bluetooth for a route it no longer needs. That is what the lease
does. A shared, memory-mapped freshness value (AA_ACTIVE_LEASE) is refreshed to
GetTickCount() on every wave_sink_write. "Producing" means the lease is younger
than AA_LEASE_MS = 1500 ms. The route reassert, the front-unmute, and the
suppression of PhoneBTA's case-4 steal are all gated on producing. When AA stops
feeding PCM, the lease goes stale about 1.5 s after the last record and every one of
these actions ceases on its own.
The reassert is further discriminated by bt_granted_no_call(): it steals the route
back to case 2 only when CEN1: 0x800e2020 shows Bluetooth is the granted main
source (byte 0 = 0xae) with no call in progress (byte 2 = 0xff). A live TEL/HFP
call, an interrupt, a source change, or an unreadable status all mean yield — the
stock lifecycle owns the route then, and pulling it out from under a live phone call
is exactly what must not happen.
Handing the route back to Bluetooth
Suppressing PhoneBTA's steal has a sting in the tail. The suppression is a detour on
PhoneBTA's own wave calls (wom_detour, injected into PhoneBTA.exe by aaboot
via CeLoadLibraryInProcess): while the lease is fresh, the detour swallows
PhoneBTA's case-4 selection (msg == 0x1c && p1 == 1) and returns success. Because
it returns success, PhoneBTA believes case 4 is already selected and stops asking
for it — so when Android Auto disconnects, Bluetooth is left stranded on the now-dead
case-2 leg and plays nothing.
handback_bt() closes that gap. On the producing→idle transition, and again on
CMD_FINAL, it runs the stock case-4 select exactly once
(wave_message(device_id, 0x1c, 1)) so Bluetooth's SRC2 leg reowns SSI4. By then
the lease is stale, so the detour passes the call through instead of swallowing it.
This case-4 select is the same route recipe Bluetooth normally issues for itself;
this is why, after the front-unmute card made playback reliable, the one remaining
complaint — Bluetooth silent after an AA session — closed with a handback card built
the same night.
CMD_FINAL is the only command that retires the device: it pauses, closes (retrying
if the driver refuses the close, never abandoning it), and calls handback_bt().
CMD_STOP, which fires on session renegotiation — including immediately after a
phone connects — only resets the feed; closing there was what had exposed an
ALLOCATED reopen race, so it no longer closes.
Pitch and the WAVEHDR leak
Two more fixes make up the reliable end state.
Android Auto delivers 48000 Hz / 16-bit / stereo, but the SRC4 pin actually clocks
44100 Hz — measured directly, 48000 frames drained in about 1093 ms, a 160/147
ratio — so feeding 48 kHz played roughly 8.8% flat. The sink declares the true
output rate as 44100 and resamples on the way in with libsamplerate 0.2.2
(SRC_SINC_FASTEST). Because the CE target has no hosted C library, the resampler
is linked against a handful of freestanding shims (malloc/calloc/free/fabs/
ceil/lrint/__rt_sdiv); __rt_sdiv had to return a 64-bit scalar (quotient in
r0, remainder in r1) or the unit hard-froze the instant audio started.
The other fix ended a distinct failure: audio played for 10–20 seconds and then died
permanently until a full power cycle. A memset of the WAVEHDR on every record was
clearing WHDR_PREPARED, so waveOutPrepareHeader was being called again for every
buffer — about 100 times a second — leaking a driver allocation each time and
exhausting the wave driver in seconds. The fix prepares each header once and sets
WHDR_DONE to release the slot while leaving WHDR_PREPARED intact. The leak had
been masked before by closing and reopening the device on every track change, which
freed the leaked headers; removing that close is what exposed it.
The debug-log proxy was a red herring
Early work suspected the aalog.dll debug-log proxy, which re-points every AA
module's debug output into a file on the SD card and opens/writes/closes it per line.
It is a genuine performance drag — disabling it removed periodic glitches and cut
finger-drag lag, on the order of 200 KB of SD writes per 30 seconds — but it never
caused the dropouts. The chronic cut-outs were the stolen route; the permanent death
was the WAVEHDR leak. Its cost is covered in Chapter 8.
With the route held on case 2, the DSP front held unmuted, the stream resampled to 44.1 kHz, the WAVEHDR leak closed, and the route handed back to Bluetooth when AA stops, Android Auto audio plays reliably through the Bose system. The problem was solved on 2026-09-19.
7. Provenance of the Android Auto Components
None of the Android Auto code in this project was written from scratch. It was lifted from a later Nissan firmware and grafted onto the clean 2016 base. This chapter is about where each piece came from and how it was merged.
MY16 base, MY18 grafts
The clean donor unit is model year 2016: G116NNNI.560, part 470-5628-11,
market GNAMK-20-102-100. The build tag encodes <family><model-year><market>NI.<build>,
so 16 is 2016. Android Auto did not exist on this platform in MY16; it arrives in
the model-year 2018 firmware, whose AA-bearing records are
G118/G218/G318NNNI.161 and .250. The MY16 base predates Android Auto; the
feature exists only in the MY18 records.
The approach keeps the clean MY16 base and grafts only the narrow MY18 AA pieces
onto it. The tool that does this, murano_skin.py assemble-aa, performs a targeted
closure merge rather than a wholesale image swap. This is the inverse of the
prior-art approach discussed in Chapter 9 — a 2018-era image made to run on 2016
hardware, wholesale — where here it is a 2016 image with small, audited MY18 pieces
grafted in.
The G1 / G2 / G3 split
The stock firmware splits the ARM software across three ROM regions, and the AA content is distributed across all three:
- G1 — the ARM kernel ROM — holds only registry hives, including the AOAP
device binding in
default.hv. - G2 — the ARM HMI ROM (MTLD/MTCP-compressed) — holds the AA screens and the
USB accessory drivers
usbaoap.dlland the*CC.dllcompanion modules. - G3 — the uncompressed projection ROM — holds
AndroidAuto.exeitself, plusCarPlay.exe,AirPlay.dll, andlibeay32/ssleay32.
AndroidAuto.exe reaches the G2 USB driver through the normal Windows CE
stream-driver ABI (AOA1:), not through any proprietary cross-ROM IPC. The
per-record delta from MY16 to MY18 in G2 adds AndroidAutoCC.dll,
AndroidAutoTouchCC.dll, SystemEX3.exe, qr.exe, usbaoap.dll, and
usbaoap_audio.dll, and removes CLHupCC.dll/CLHupCCWrapper.dll. Of those,
SystemEX3.exe and qr.exe are generic post-MY16 additions, not AA. The entire
USB/AOAP delta that matters is exactly two modules: usbaoap.dll and
usbaoap_audio.dll.
All of it is ARM
Every Android Auto component is ARM code — PE usCPUType 0x01c2. The SH-4A
companion has zero AA, AOAP, AAP, or CarPlay references and no USB host stack at
all; USB host and AOAP are ARM-exclusive (ehci.dll, ohci2.dll, k.usbd.dll,
usbhostfuncsw.dll). The one and only piece of the port that touches the SH-4A is
the injected H.264 codec service from Chapter 5 — and even that is donor firmware
bytes, not authored SH code. There is no AA logic on the SH side to graft.
The grafted pieces, exactly
Four categories of MY18 content were brought over onto the clean base:
- The AA skin closure. The MY18 AA skin closure comes from donor selector
0x8750(Substance.8750.skn) and is merged into the clean base skin. The merge inserts theAAP_ANDROIDAUTO_VIEWbranch and the two AA CLSIDs into the UI so the Android Auto tile exists and is reachable. - The AA G3 modules, deployed as SD files. The core AA modules are rebuilt
from the frozen
images/aa_g3_button.romand deployed as files under\SystemSD\AA\—aaboot.dll,AndroidAuto.exe,AndroidAutoCC.dll, and the renamed OpenSSL librarieslibeay32→AALIBEAY.dllandssleay32→AASSLEAY.dll(renamed to avoid colliding with stock modules of the same name). This is why the port is SD-only: the G3 pieces ride as filesystem content, not as a reflashed ROM. - The AOAP registry binding and AA COM registrations. The AOAP device binding
goes into the clean
default.hv:HKLM\Drivers\USB\ClientDrivers\AOAP = {Dll: usbaoap.dll, Prefix: AOA, Index: 1, MaxBulkTransferSize: 0x4000};Aoap_Audio = {usbaoap_audio.dll, Prefix: WAV, Stages: 2, Buffers: 6}; andLoadClients\6353_11520..11525binds all six AOAP PIDs (0x18D1:0x2D00..0x2D05). The clean MY16 hive has zero AA/AOAP keys; the MY18G118NNNI.161hive has 19. The two AA COM/CLSID registrations are brought over wholesale — they are present or absent as a set, never partial. - The USB accessory driver.
usbaoap.dll(57,344 bytes, VBase0xeeed0000) is a textbook AOAP accessory driver: EP0 control plus one bulk-IN/bulk-OUT pair, exportsAOA_Init/Open/Read/Write/Seek/IOControl/Close/Deinit/PowerUp/PowerDownplusUSBDeviceAttach/USBInstallDriver, with a bulk-IN ring sizedBulkInBufNum(0x14) × MaxBulkTransferSize(0x4000). The AOAP accessory-identity triple —manufacturer = "Android",model = "Android Auto",version 1.0— is present verbatim (UTF-16LE) insideAndroidAuto.exe. Its siblingusbaoap_audio.dllimplements the deprecated AOAv2 USB-audio path (WAVprefix, off the AA media route, deprecated in Android 8.0) and was repeatedly flagged as droppable.
The AA registry keys and the AA modules travel together as one set: a build either brings the whole closure — bindings, modules, and CLSIDs — or none of it.
Where the donor binaries came from
The AA binaries did come off the Xanavi firmware image (872RU_20230625.img) — the
same image the SH4 emulator boots from — but Xanavi did not write them. They are
stock Nissan MY18 software that the image happens to carry: the image is a
multi-market OTA carrier bundle (a UINF build dated 2023-06-25, 99 records), and
the Android Auto capability lives in the MY18 record set it carries for other
markets. The actual donor for this port is that one record set — selector 0x8750:
G118NNNI.250 + G218NNNI.250 plus the G3 module set — lifted out of the bundle,
not the whole image. Chapter 9 covers the image itself and its provenance.
What was and was not built
The supporting toolchain was built for this project. The code was compiled with
clang-18 and lld-link-18; what was written is the surrounding pipeline: the import
library, the PE→Windows CE header shim, the skin/card assembler (murano_skin.py,
murano_aa_card_build.py), and the PE editing helpers — a merge and packaging
pipeline. The AA application, its driver, and its AOAP binding are the 2018-vehicle
donor binaries, relocated and renamed; generating the H.264 hybrid additionally
requires the proprietary stock and donor SH images as external inputs.
8. Tracing and Instrumentation
Working blind on a locked head unit is not viable, so a fair amount of the project was building the instruments to watch it run. Observation was built on three independent planes: guest-side proxy/shim DLLs that reroute a target executable's imports into logging code; a host-side transparent TCP proxy that hex-dumps the Android Auto byte stream; and QEMU-side hooks — environment-gated tracers plus a Windows CE kernel-aware thread/event dumper. Nothing here required source for the stock binaries.
IAT redirection: the shared mechanism
The guest-side tracing all rests on one technique: rewriting a PE's import address
table so selected slots resolve into a shim DLL, without changing a byte of the
target's code. tools/murano_pe.py redirect_imports() selects slots by ordinal,
by exact import name, or by a specific IAT slot RVA (for a module that imports the
same symbol twice), and re-points each matching slot at an export of a new module.
The slot addresses themselves are preserved, so the target's own code never changes.
Where a redirected slot is reached through a callback rather than a direct import, a
small interworking trampoline stands in — ldr ip,[pc,#4]; ldr ip,[ip]; bx ip
(E59FC004 E59CC000 E12FFF1C) — which jumps to the real target while preserving
r0–r3/lr, using only ip.
Everything below is built on top of that one primitive.
aalog.dll — the debug-output sink
aalog.dll (runtime/aa-log.c) captures debug output from every AA process. It
re-points NKDbgPrintfW (coredll ordinal #545 → aalog #1 AALogPrintfW),
OutputDebugStringW (#541 → #2 AALogOutputW), and DebugShellDll!DebugShellOutput
(→ #3 AALogOutputA, ANSI) across AndroidAuto.exe, aasystemexe.exe,
aausb.exe, and aaaudio.exe. Each line is prefixed with a GetTickCount tick and
the hosting executable's basename and appended to \SystemSD\AA_DBG.TXT, serialized
across all AA processes by a named mutex (LOCK_WAIT_MS 2000). The file is opened,
written, and closed per line, because the SDHC card answers the flush IOCTL with a
success no-op — only CloseHandle actually commits the FAT cache, and the lines
wanted most are the ones written just before a watchdog reboot. The DLL is
freestanding: no CRT, no entry point, every import a stock coredll ordinal.
That per-line open/write/close is exactly why aalog.dll costs what it does. As
noted in Chapter 6, disabling it removed the periodic audio glitches and cut
finger-drag lag — on the order of 200 KB of SD writes per 30 seconds of logging —
even though it was not the cause of the audio dropouts. It is a heavy instrument,
useful for post-mortem tracing and worth turning off in a shipping build.
aatrace.dll — the import-call tracer
aatrace.dll (runtime/aa-trace.c) is a generic import-call tracer for
AndroidAuto.exe. Every traced IAT slot is re-pointed at a 7-instruction Thumb-2
trampoline (one export per slot):
push {r0-r3,r12,lr} ; 24 B keeps AAPCS 8-byte SP alignment
movw r0,#slot
mov r1,sp
bl trace_enter
str r0,[sp,#16]
pop {r0-r3,r12,lr}
bx r12 ; tail-jump; target returns to the original caller
trace_enter(slot, regs) does one atomic head increment, a GetTickCount, a
KData read, and seven stores — nothing blocks — recording into a 32768-entry ring
(aatrace_ring[32768], 896 KiB, its own .ring BSS section). It resolves the real
target lazily via LoadLibraryW + GetProcAddressW and caches it; an unresolvable
slot records a flagged entry and jumps to a fail stub returning 0. The current
thread is read from the user-readable KData page (*(u32*)0xFFFFC824, the same
identity the QEMU traces key on). A flusher thread, raised to CeSetThreadPriority(100)
so a runaway elevated thread cannot starve it into silence, appends new ring records
to \SystemSD\AA_TRACE.BIN every 100 ms.
Each record is 28 bytes little-endian: {u32 tick; u32 thread; u16 slot; u16 seq; u32 r0,r1,r2,r3}. Flags are 0x8000 UNRESOLVED, 0x4000 STRING (a follow-up record
carries 8 WCHARs of the call's first LPCWSTR argument, for CreateFileW /
LoadLibraryW), and 0xFFFF LOST (a flusher marker for records overwritten before
they were flushed, count in thread); seq is the ring lap + 1.
tools/aatrace_decode.py turns the records into
tick thread slot module!symbol r0 r1 r2 r3, labelling slots from the card
manifest's slot table and naming bare ordinals from the stock G1 ROM exports.
The default traced coredll ordinals are {379,384,385,386,387,390,398,399} — the
native AA wave lifecycle, chosen so tracing does not change device selection. The
build has three modes: default routes AA imports through aatrace; --no-trace
keeps the debug log but omits aatrace and preserves vendor import calls;
--release discards redirected debug messages before any SD I/O and omits aatrace.
aa-register.c — the bootstrap and state journal
The SD-only bootstrap aaboot.dll (runtime/aa-register.c) is loaded directly by
the skin via ExtFunc 0x406 — no shell entry, COM registration, guest Python, or
persistent hive is needed. It journals boot progress to both \SystemSD\AA_STATE.TXT
(the visible FAT32 partition) and \SystemSD2\AA_STATE.TXT (a hidden exFAT twin on
the same card); every line goes to both, so whichever partition the host can read
wins.
Host-side: gal_proxy.py
tools/gal_proxy.py is a transparent TCP proxy that hex-dumps both directions of the
Android Auto (GAL) byte stream with timestamps. It sits between QEMU's usb-aoap
chardev and a real phone's head-unit server, reached over adb forward tcp:5277, so
the stream can be inspected without touching the guest. Two pump threads log
t tag len hex-preview for HU->PH and PH->HU (preview 96 B by default, run at
4096 in practice). A missing phone is tolerated: HU bytes that arrive before the
phone connects are dropped and logged, so the guest can still enumerate and bind the
AOAP driver without a phone attached. Live wiring in the September sessions was
QEMU usb-aoap chardev=aap → 127.0.0.1:5280 → gal_proxy → 127.0.0.1:5277 →
adb → the phone's head-unit server (early runs used 5278→5277; later runs ran
5280→5277 as a named managed process). Google's Desktop Head Unit speaks the same
protocol over TCP :5277, which confirmed the transport. This purpose-built proxy —
not mitmproxy, which is HTTP-oriented and cannot read the raw AOAP/AAP bulk stream —
was the actual man-in-the-middle instrument.
QEMU-side instrumentation
The custom board models carry environment-gated tracers so instrumentation can be
compiled in and switched on per run: MURANO_ARM_TRACE_DBGPRINT,
MURANO_ARM_TRACE_DISPLAY_MMIO, MURANO_ARM_TRACE_SCHED172,
MURANO_ARM_TRACE_PC_* (START/END/RANGES/LIMIT/SKIP/INTERVAL/PROCESS/VT_MIN/VT_MAX),
and MURANO_ARM_TRACE_FE70.
Rather than native kernel debug (KITL/kd, rejected as infeasible) or a guest ToolHelp
executable (blocked by having no on-target toolchain), the primary tool is a
WinCE-kernel-aware dumper that runs in the emulator: it stops both CPUs, MMU-walks
the fixed high VAs, and iterates ProcArray → pTh → pNextInProc to enumerate
processes and threads, keying on the WEC7 KData offsets. Alongside it run a
PC-profiler, a context-switch tracer, and an MMIO heatmap.
The most useful of these was an event-signal hook: instead of sampling who is
waiting on an event, it hooks the signal side, and when a watched event trips it
grabs the signaler's call stack and walks it back to the true input. Sampling tells
you who is blocked; hooking the producer tells you what actually produced the signal.
It was implemented as a non-halting TCG plugin and produced the
owner_wait_probe.py / holder_wait_probe.py probes used to untangle the audio and
attach sequencing.
9. Prior Art
Other people had been inside this platform before I opened Ghidra on the first ROM dump. Two bodies of work bear on the project, and they sit in different positions relative to what I set out to do:
- The
872RU_20230625.imgfirmware image. A bootable SD-card image of stock Clarion/Nissan head-unit firmware, redistributed through the Russian car-firmware modding scene. I used it two ways: as a known-good SH-4A boot ROM for the SH4 side of the emulator, and as the container from which the MY18 Android Auto donor records were taken. developerfromjokela/leafsdtools, an open-source (GPL-3.0) WinCE toolbox that runs on the head unit off a bootable SD card. It produced the read-only internal-flash / SRAM / VFlash dumps of my specific unit, and its project files named the historical Microsoft toolchain lineage for these units.
Neither delivered the feature; between them they supplied data, donor artifacts, a working SH4 ROM, and a toolchain pointer. The entrypoint, tooling, tracing, and emulator were built independently. This chapter states what each piece is and what it actually contributed.
9.1 The 872RU_20230625.img image
What it is
872RU_20230625.img is a roughly 9 GB SD-card image. Xanavi is a Russian
firmware-modding company that releases modified car firmwares; the image circulated
through that scene, not through an official Nissan or Clarion channel. I found it on
the internet. It is a hacked redistribution of Nissan firmware that Xanavi held no
rights to in the first place, so I do not redistribute the raw image here.
What Xanavi did was repackage and obfuscate a stock Nissan carrier — not author
Android Auto for this platform. The payload underneath is a stock multi-market OTA
carrier bundle (UINF build 2023-06-25, 99 records); its own market variant is
MY14-generation and has no Android Auto, and the Android Auto capability comes from
the stock MY18 record sets the bundle carries for other markets. I used this image
only because I could not find a clean, stock later-model-year image anywhere — not
because the modified image did anything the stock firmware doesn't. It was a
convenient container for stock MY18 records and a bootable SH-4A program, nothing
more.
Provenance work
The provenance pass started with the image's outer wrapper, which turned out to be
a reversible 8-byte XOR mask, not encryption — applying the mask collapsed the
repeated filler to zeroes, confirming a plain obfuscation layer rather than a clean
release. Under it the image is legible. The lineage traces to an earlier US-based,
English-only 8RU_20211221 release; per the modding-scene forum history the base
was an original 2019 U.S.-market Murano Z52 card, later swapped to a newer American
donor. A forensic detail confirms the bundle is not a clean stock release for any
single unit: the Navi strings bIsSelfRun and SVR_Navi present in the clean OTA
UPDATE.DEC.bin are absent here, which carries only MAPAL map data.
No clean, unmodified TP171040 program image was findable on any public archive.
That absence is part of why the emulator exists: ground truth had to be dumped and
reconstructed, not downloaded.
What it contributed
Two things, both upstream of the shipped feature.
First, a working SH-4A boot ROM. The image contains a valid SH-PRG NOR program,
and the SH4 side of the emulator boots from it directly, passed as -bios. The SH4
machine models CS0 as a Micron PC28F512M29 CFI NOR flash at base 0, and the bundle
sits there as the firmware the SH-4A core executes out of reset. The dual-QEMU
design needs genuine SH-PRG firmware to run the CPUCOM handshake against, and this
provided a known-good one before I had fully carved my own.
Second, the Android Auto donor records. The AA binaries I integrated were not
carved from the Russian-market variant; they came from the MY18 record set carried
inside this same stock bundle, selector 0x8750 (G118NNNI.250 + G218NNNI.250
and the 22-module G3 projection set). So the image served as both the SH4 BIOS
reference and the donor source — but the donor is factory MY18 Nissan software,
taken from a stock bundle, not a modder's patch.
What it did not provide was any explanation of behavior. A bundle that boots AA is an artifact, not a model of how AA attaches, negotiates the GAL video channel, drives the SH-PRG codec service, or reaches the audio path. Those had to be derived against the emulator and the tracing stack.
9.2 leafsdtools
What it is
developerfromjokela/leafsdtools (GitHub, GPL-3.0) is a "Toolbox for
modifying/updating/backing up Nissan LEAF Head Unit." Mechanically it is a
bootable-SD WinCE eVC++ application that runs on the head unit itself: put it on a
card, the unit boots it, and it presents a menu of low-level flash operations. It
targets two families, QY7XXX (CarWings) and QY8XXX (NissanConnect). The
Murano's QY8450NA is in the QY8XXX / NEW_NAV branch, which boots straight from
the SD slot with no QY7-style button sequence — the stock release image runs on
this chassis unmodified. Its feature list is the set of primitives you want for a
locked-down embedded target: back up and write internal flash, read/write SRAM,
read/write VFlash, lock/unlock the SD via slot A, and retrieve the SD PIN. Only the
read side was used.
What its dumps provided
The most valuable output was a read-only dump of the unit's internal flash. The
backup opens the internal flash device (FMD1:) and does a per-block cooked/logical
read, yielding a fixed 64 MiB cooked/logical image with no OOB/ECC — the WinCE
ObjectStore / BINFS plus the EEPROM/PROD configuration and the battery-backed
SRAM/VFlash. Three files came off the car: the 64 MiB internal-flash image, the
256 KiB battery-backed SRAM, and the 128 KiB VFlash.
These supplied the one thing that could not be fabricated: the unit's factory
configuration. The clean firmware base had a blank config; the dump carried the
real Model Code (QY8252NC / QY8450NA), Product ID, serial number, and SD-PIN.
The QEMU FMD1: model backs its reads from this exact file, mirroring the
leafsdtools logical-block layout including the geometry signature; no value in it is
invented. The dump did not contain the ABQ HMI content
(\SystemSD3\MIP\ABQ\HMI.zip + hup.py + the ...CC.dll), which lives on
partitions 2–4 of the removable SystemSD card, not in internal flash.
Note on scope: the stock updater/OTA path — including the leafsdtools SD-update mechanism — was researched and deliberately not used for flashing. It served only to obtain the read-only internal-flash/SRAM/VFlash dump. ROM/OTA flashing was rejected because the LoaderVerifier plus the NK1/NK2/NK3 failsafe chain make a bad write a brick risk. SD-only was the chosen deployment path (Chapter 4).
The toolchain lineage
The other contribution was toolchain identification. leafsdtools' .vcp/.vcw
project files name the build environment down to the platform GUID — eMbedded
Visual C++ 4.0 (eVC4) + SP4 + the Windows CE 5.0 Standard SDK (STANDARDSDK_500),
targeting Win32 (WCE ARMV4I) Release and linking the stock coredll, commctrl,
ceddk, and ddraw. This is the historical lineage the original head-unit tools
were built with. It is distinct from the toolchain used to build the Android Auto
runtime itself, which is a modern clang-18 / lld-link-18 pipeline plus the
supporting pieces I wrote: the import library, the PE→CE header rewriter, and the
card builder.
9.3 Provenance contrast
The clean base unit is model year 2016 (G116NNNI.560, part 470-5628-11,
market GNAMK-20-102-100); the Android Auto firmware is model year 2018
(G118/G218/G318NNNI.250). The build tag encodes model year and market as described
in Chapter 7.
The prior-art approach and mine are inverses:
- Prior art (the Xanavi bundle lineage): a later firmware image made to run on earlier hardware — a wholesale swap, distributed as a whole card. Owners get working CarPlay/AA but with reported regional-service, compass, radio, steering-wheel-control, and camera regressions and no clean route back to a factory card, because you cannot un-bake a donor swap.
- Mine: keep the clean MY16 base and merge only the MY18 AA closure onto it. The
murano_skin.py assemble-aastep merges the donor closure onto the clean base rather than replacing it wholesale. The grafts are narrow: the MY18 AA skin closure from donor selector0x8750(Substance.8750.skn) merged into the clean base skin (inserting theAAP_ANDROIDAUTO_VIEWbranch and the two AA CLSIDs), the seven core AA G3 modules rebuilt and deployed as SD files (aaboot.dll, donorAndroidAuto.exe,AndroidAutoCC.dll, and the renamedAALIBEAY.dll/AASSLEAY.dll), and the AA COM/CLSID registrations.
The donor is not the Russian-market image itself; it is the MY18 record set carried inside that stock bundle. The contrast is 2018-on-2016 wholesale versus 2016-with-narrow-2018-grafts, and both the base and the graft source are factory Nissan software.
9.4 Borrowed versus rebuilt
| Item | Source | Status |
|---|---|---|
| Internal-flash / SRAM / VFlash dumps, factory config (Model Code, serial, SD-PIN) | leafsdtools read features (read-only) | Borrowed (data) |
| SH-4A boot ROM for the emulator | 872RU_20230625.img CFI NOR (-bios) |
Borrowed (artifact) |
| MY18 Android Auto donor records (skin closure, G3 modules, CLSIDs) | selector 0x8750 inside the stock bundle |
Borrowed (donor) |
| eVC4 + SP4 + SDK500 toolchain identification | leafsdtools .vcp/.vcw files |
Borrowed (fact) |
SD-only skin → DLL entrypoint (aaboot.dll via skin ExtFunc) |
— | Rebuilt |
Tracing/logging stack (aalog.dll, aatrace.dll, aa-register.c) |
— | Rebuilt |
PE IAT-redirect tooling (murano_pe.py) |
— | Rebuilt |
Host TCP proxy (gal_proxy.py) |
— | Rebuilt |
| Dual-QEMU ARM + SH4 emulator and instrumentation | — | Rebuilt |
| AA runtime toolchain (clang-18 / lld-link-18 + import lib, PE→CE header, card builder) | — | Rebuilt |
The line is consistent: data, donor artifacts, and platform facts were borrowed; the entrypoint, the tooling, the tracing, and the emulator were built. The prior art established that Android Auto can run on this hardware and supplied the pieces to build against. Explaining how it runs — the boot chain, the codec service, the audio graph — is what the rest of this writeup covers, and it required the emulator and tracing rather than any existing artifact.
10. Development Chronology
This chapter is the dated sequence of the work, from the first firmware reverse-engineering session to reliable Android Auto audio. For each milestone it states what was blocking progress, the root cause, and the fix. Total elapsed time from the first session (2026-08-11) to the point audio was solved (~2026-09-19) is about five and a half weeks; the car had been on hand roughly six weeks.
10.1 August 11–18 — Firmware extraction and QEMU ARM bring-up
Blocking. Nothing would boot in the emulator. The available dump was NK-only: the OS kernel image without a first-stage OAL/bootloader, and without the boot splash assets.
Root cause and fixes, in order:
- Extracting the boot assets. The clean OTA
UPDATE.DATwas wrapped in a 27-round ARX ECB transform with a null key, recovered fromPRGUpdate.exe. Decrypting it yieldedDrawOi.exeand theOIDEFALT.imgsplash asset. (These also exist in the ROM OS carve —OIDEFALTinnk_nand_g116.bin— so the OTA was not the only source, but it was the one used here.) - Boot from the reset vector, not an entrypoint. Early attempts jumped directly
to a WinCE/GWES entrypoint. On this platform the canonical boot resets at physical
NOR address 0; the BL16 vector branches to
0x1000, self-copies to0x17c00000, builds its own page tables, reads the persisted NORVEUP, and copies the genuine G1 XIP updater. Skipping that path leaves the state the boot establishes uninitialized. QEMU was made to run the bootloader from the reset vector. - Missing memory aperture. The actionable fault under the exception noise was an
invalid read at
0x5C000000. The fix was widening the RAM-like aperture so the access mapped. - The NULL OEMAddressTable abort. The NK-only dump had no first-stage OAL to
populate
KDataStruct, so the OEMAddressTable pointer was zero. A boot helper dereferenced that NULL, took a prefetch abort, and wedged. The authentic OEMAddressTable was present in the image (tagged with magic0x87654321); pointing the boot at it cleared the abort.
10.2 August 22 — First boot-animation frame in QEMU
Blocking. With the ARM side booting, the next goal was display output. Only the opening boot animation had to render — the DU (display unit) scanout path.
Result. The first boot-animation frame rendered in QEMU; the AUI stack logged its first successful association on the same run. This confirmed the display driver and scanout path were live end to end.
10.3 August 28–30 — Dual-CPU bring-up and the menu
Blocking. The system would not progress past the boot animation to the interactive menu. The two-CPU model was part of the emulator plan from the start; what remained was bringing the second core up and getting the two to complete the boot handshake, which the boot animation did not exercise.
Root cause. The head unit is two processors: an ARM (Renesas R-Car M1A, Cortex-A9) running WinCE, and an SH4 (SH7750R / SH-PRG) running ITRON, cooperating over an on-chip mailbox/doorbell. The interactive bring-up depends on the inter-CPU handshake, so both cores have to run and exchange the right sequence.
Fixes:
- Two-QEMU design.
qemu-system-armandqemu-system-sh4run as separate processes, coupled overmmap'd shared-memory files and UNIX datagram sockets modeling the HPB mailbox. The SH4 core is held in reset until the ARM writes the release key0xa55a0001into the shared doorbell — a value that only appears if the bootloader that writes it is actually running. - Warm-reset / card-insert / LCD-micom handshake. Second-boot and menu bring-up needed the warm-reset, card-insert, and LCD-micom handshakes modeled correctly.
- llvmpipe over-read segfault. A Mesa/llvmpipe
memcpysegfault right afterglDrawArrayswas fixed by uploading one scanline perglTexSubImage2Dand bounding every source read in-bounds.
Result. Under a clean tree with -icount shift=3,sleep=off, the boot reached
the HOME screen and the full Nissan menu rendered in the emulator.
10.4 September 1–2 — GPU GL HLE and the map screens
Blocking. The menu's 2D chrome painted once the display path and a first slice of the GL HLE were wired, but the map, navigation, and previous-destination screens stayed blank. Those screens are drawn by the guest's PowerVR SGX GL stack, which is not emulated.
Root cause. The map and 3D screens exercise far more of the EGL/GLESv2 surface than the menu does. Until the guest's GL calls were actually being serviced, those screens had nothing to draw against.
Fix. The PowerVR→llvmpipe GPU HLE (Chapter 2) intercepts every GL DLL-export entry PC — 176 EGL and GLESv2 exports — and services them on host Mesa llvmpipe, so the guest's own GL calls run on the host without the SGX driver. This GL layer, a separate strand of work from the dual-CPU menu bring-up, is what brought the map and 3D screens up.
Result. With the GL HLE in place the map rendered in the emulator; the first map/navi screen captures date to 2026-09-02.
Video — booting the menu and rendering maps in QEMU:
10.5 September 3 — Android Auto feasibility
Blocking. With the menu rendering, the question was what it would take to bring Android Auto up on a clean image. The approach was to learn the integration from the firmware that already carried it (Chapter 9), not to reinvent it.
Work. The AA feasibility push began, in parallel with OSM→Nissan map-card conversion work. The research phase was kept separate from implementation: transport (AOAP/GAL/AAP), the USB accessory binding, and the CPUCOM inter-CPU link were documented before any code was written. The CPUCOM startup handshake between the ARM and the SH4 was already documented from the boot bring-up, which AA depends on.
10.6 September 6–12 — Codec service and AA in QEMU
Blocking. Android Auto is real-time H.264 video plus audio over AOAP. The SH-PRG side owns the codec tasks, and standing up an H.264 decode service without a stock task to host it was the open problem.
Root cause and analysis (Sep 6–7). The SH firmware role and the AA codec-service
task map were analyzed: task 0x41 is the stock AAC-encoder
(AACEncoderFilter_SH4A.dll); task 0x43 is the H.264/VPU decode service
(H264DecoderFilter_VPU5HD.dll). There is no idle slot to inject the decoder into.
Fix — thread-borrow and reconstruction. The unlock borrows the running stock
AAC-encoder task 0x41 at its call boundary. A temporary encoder-boundary hook
snapshots the full span the encoder will overwrite — 388 bytes plus registers —
injects the VPU H.264 decode service as task 0x43 on a private stack, then restores
and byte-verifies all 388 bytes and the registers and resumes the stock task-41
dispatcher. The full-span snapshot is taken before the AAC handler builds the live
frame, so nothing the encoder later writes is lost. (runtime/aa-live-codec.c.)
AA in QEMU (Sep 12). To see projection without the car, the AA video path was
high-level-emulated in QEMU during development: murano_video_hle.c hooks the guest
H264DecoderFilter_VPU5HD.dll entry PCs and routes the CPUCOM task-0x43 commands to
murano_h264_hle.c, which decodes with host FFmpeg (libavcodec) to NV12 into guest
pools; murano_vio_hle.c emulates the VIO6C compositor and murano_gles_hle.c
drives EGL/GLESv2 on host Mesa llvmpipe. This is a development-time path only — it
does not run on real hardware. With it, the live codec revisions of September 12
were exercised in native QEMU: the full MENU rendered, the Android Auto tile was
tapped, AA enumerated the synthetic usb-aoap device (presenting as a phone in
accessory mode, 0x18D1:0x2D01, so usbaoap.dll binds), bridged its bulk endpoints
through gal_proxy.py to a real phone's head-unit server over
adb forward tcp:5277, and authenticated. The runs fail-closed at VPU-hardware
readiness, which QEMU deliberately does not model — the high-level emulation fakes
the decode, so validating the actual VPU5HD hardware decode was the one thing that
still required the physical unit.
10.7 ~September 13 — Android Auto on the real hardware
Blocking. On the real MY16 unit, the moment AndroidAuto.exe called
SetVideoFocusModeNative — the CC video-focus handler that reaches into the MY16
display stack — the unit reset within about 300 ms. This happened every time
projection tried to take the screen. The MY16 display stack never shipped with the
feature, and the video-focus transition was fatal on it.
Fix. A subsystem restart around the video-focus transition brought the relevant subsystem back up in a state where the transition no longer reset the unit. With that in place, the September 13 build produced the first hardware projection: on the second boot, Android Auto connected, displayed, and accepted input. An encoder-hook race that crashed Bluetooth on the first boot was fixed in the same-day follow-on build.
Video — first capture of Android Auto working:
A property of the SD-only deployment made real-hardware iteration practical: anything written to the running system is volatile, so a power cycle returns the unit to the image on the card. That kept experiments on the physical unit recoverable.
10.8 September 16–19 — Audio routing
Blocking. Android Auto produced PCM, but no sound reached the Bose amplifier; when sound did appear it was wrong-pitched and dropped out, and one build made it stop permanently after 10–20 seconds until a reboot.
The problem was not a missing source grant. AA's own wave write was fine — PCM
reaches the driver directly through waveOutOpen/waveOutWrite, the way the stock
Speech guidance path does, with the SRC4 endpoint resolved by device type rather
than a fixed device number. What first sound actually needed was the DSP route held
on the injected leg, the DSP front unmuted, and the stream resampled to the real
output rate.
Root causes and fixes:
- The route-steal fight. The DSP mux selector lives in a physical register
(
0xffd90004, bits [23:20]): value 2 is the injected ARM SRC4 leg, value 4 is the companion's SRC2 leg that Bluetooth uses. StockPhoneBTAre-asserts case 4 on ordinary Bluetooth events, stealing the mux off the injected leg while the wave writes still return success. A watchdog reads the selector directly (via an OAL physical mapping) every 100 ms and, when it sees the route stolen to case 4, reasserts case 2 with an in-place read-modify-write of just the selector nibble — not a destructive close/reopen. A PhoneBTA IAT detour additionally drops PhoneBTA's own case-4 selection while AA is active. The reassert is guarded so it only fires while Bluetooth is the granted main source with no active call; a TEL/HFP call or a source change yields the route to the stock lifecycle instead of stealing it back. - The DSP front-mute. A stock downstream path mutes the DSP front output without touching the route selector, so holding the route on case 2 alone did not restore sound. The fix re-issues a no-select front-unmute (line id 7) every 250 ms while AA is producing, which re-notifies the mixer to unmute the DSP front without re-asserting a source or triggering the on-screen popup.
- The producing lease. All of this interference is gated on a shared freshness lease refreshed on every PCM write. "Producing" means the lease is younger than ~1500 ms; the moment AA stops feeding PCM the lease goes stale and every reassert, unmute, and PhoneBTA suppression stops on its own.
- Handing the route back to Bluetooth. Because the detour suppresses PhoneBTA's
case-4 selection, when AA leaves, Bluetooth would otherwise be stuck silent on the
dead injected leg.
handback_bt()runs the stock case-4 select once so the Bluetooth SRC2 leg reowns the output; the detour passes it through because the lease is now stale. It runs on the producing→idle transition and on the final stop command. - The permanent 10–20 s death. This was a WAVEHDR re-prepare driver-resource leak:
a
memsetcleared theWHDR_PREPAREDflag, causingwaveOutPrepareHeaderto be re-issued about 100 times a second until the driver ran out of resources and audio stopped until reboot. Preparing each header once and releasing the slot without clearingWHDR_PREPAREDremoved the failure. - Pitch. The wrong pitch was a 48 kHz AA stream played back on a pin that actually clocks 44.1 kHz. The sink declares the real 44.1 kHz output rate and resamples on the way in with libsamplerate (r8brain was ruled out).
Result. Audio was solved on 2026-09-19. The final card holds the route on the injected leg against PhoneBTA's steal, keeps the DSP front unmuted while AA is producing, resamples 48 kHz to 44.1 kHz, no longer leaks WAVEHDR allocations, and hands the route cleanly back to Bluetooth when AA stops — reliable projection audio with the Bluetooth path intact after disconnect. As of 2026-09-20 that is the shipping state; Android Auto runs on the MY16 Murano with working video, input, and audio.
Video — final audio working, over the menu:
11. Reverse-Engineering the Map Format
The navigation maps are not an afterthought on this platform — they are most of what
the SD card holds and most of what the unit draws. The map/OS partition carries a full
ZENRIN/Clarion map database (the MAPAL001, REFER001/002, RDSTM001, HOUSE001,
OBJCT001, and COORD001 families; MAPAL001 alone is 119 files and about 3.8 GB),
and the head unit renders it live. Making the emulator show a real map (Chapter 10.4)
meant decoding that database, and the same decode opened a second, more ambitious door:
authoring custom maps from OpenStreetMap data.
Decoding the format
The map geometry was reverse-engineered directly from the SH4 Navi.exe renderer
rather than guessed. The native polyline decoder follows a specific chain of routines
(FUN_00420e74 for the spatial-cell record walk, FUN_004b5e2c for link/polyline
extraction, FUN_004b646c to unpack a level-1 mesh ID into tile X/Y) with a handful of
fixed bit masks — a 13-bit record word count, a 12-bit point count, 11-bit local X/Y —
and a point walk whose per-vertex extension words are gated by flag bits in the Y
coordinate. Road geometry lives in 00fa-tagged streams and area geometry in 0019
streams inside files like MAPAL001/B38R0D0R.DAT.
The geographic contract turned out to be the friendly case: North American MAPAL is
plain WGS84 (MapSurvey enum 1), with exact MeshPnt/MapPnt integer arithmetic
and no datum conversion needed for WGS84 input. Writable level-1 latitude is
[-85⅓, +85⅓) and longitude (-180, +180]. Around the geometry sit the other
families: REFER001's ADDRE address trie (66 regions, a solved row/bounds directory),
HOUSE001 (484 files, ~97,630 zlib members of house-number ranges), and OBJCT001
(574 landmark 3D models with legacy PowerVR PVRTC textures). By the end the target side
was, in the field inventory's own accounting, effectively fully decoded — 400 of 401
field/behavior rows proven.
Authoring maps from OpenStreetMap
Decoding is only half of a custom-map pipeline; the other half is source policy —
deciding which OpenStreetMap feature becomes which authentic Nissan selector, index, or
template. That is where the real work sat, and it is captured in a crosswalk
(osm_to_mapal_field_map.md) and a set of taxonomy and codec tools: road/access/speed
classification, area and place taxonomies, a Vincenty-WGS84 distance and speed-to-DTR
(metres / deciseconds) metric policy, ADDRE side/parity/range address encoding, and
the DTR routing-word codecs. The production converter,
analysis/tools/update_nissan_map_image.py, writes a real card by editing the map tree
in place on the FAT32 partition while preserving everything outside it.
The converter is deliberately fail-closed: every OpenStreetMap feature it cannot map
to an authentic selector is counted and refused rather than approximated, so a card
either contains faithful Nissan data or an explicit, coded rejection. A statewide
Colorado run made the state of things concrete — it came back BLOCKED_FEATURE_GAPS
with roughly 2.9 million counted source-policy gaps across 77 blocking codes. In other
words: the binary format is understood well enough to write, and the remaining distance
to a complete statewide custom map is source-classification policy, not unknown bytes.
That was enough to render authentic and converted map geometry in the emulator; a
finished, gap-free statewide card is future work.
12. Doom, Gated to a Stopped Car
The same SD-file execution path that loads Android Auto will load anything else built for the target, so the unit also runs native Doom — playable only while the vehicle is stopped, and hidden the instant it moves. It uses none of the Android Auto stack: no SH companion changes, no AOAP, no firmware flashing. It is an ordinary CE-ARM program loaded from the card, drawn over the HMI, driven by the steering-wheel buttons, and governed by the car's own speed signal.
The engine
The engine is the existing Chocolate Doom 1.3.0 Windows CE port, adapted rather
than rewritten. It cross-builds with the same clang-18 / lld-link-18 toolchain and
PE→CE header pass used for the AA runtime (Chapter 4), against the stock coredll
exports (GDI, window/message, and CRT). WADs load from a dedicated
directory on the map partition — retail or custom IWAD/PWADs — falling back to the
shareware DOOM1.WAD when no usable base IWAD is present. Rendered frames are handed
to the HMI through a shared frame ring (runtime/doom_frame.h) and composited over
the normal display; the controller shows and hides Doom by the same present/relinquish
hooks the UI uses, so stock navigation is never torn down underneath it.
Getting the buttons
Input comes from the steering wheel, not a touch keypad. The wheel and hard-key vkeys
were recovered from the kepdrv.dll steering/matrix key tables and confirmed on the
unit via a DOOMKEYS log: the non-volume wheel buttons post their vkeys as
WM_KEYDOWN to AUIAPP, while volume is consumed inside kepdrv itself
(AudioSetVolumeKey) and never reaches the app. The wheel buttons
(0xc5/0xc6/0xcb/0xcc/0xbf) and the two head-unit hard keys (0x0d/0x1c)
map to movement and actions; 0xbf is the show/hide toggle (DOOM_VK_TOGGLE), and it
only does anything while the car is eligible. A touch tap on the game pauses it and
opens a small menu with Exit and Hide until next stop; "hide until next stop"
suppresses the automatic return for the whole current stop, not just for another five
seconds.
The speed gate
The interesting part is the gate. The stock CarSpeed object (read through objdll
via OBJ_CarSpeed_readSpeed / OBJ_CarSpeed_getObjID) publishes a native speed sample
whose raw magnitude is in deci-km/h (DoomNativeSpeed.tenth_kmh). The policy treats
anything above a zero-unit epsilon as motion:
#define DOOM_VEHICLE_RAW_EPSILON 0u /* deci-km/h */
moving = magnitude(sample.speed.tenth_kmh) > DOOM_VEHICLE_RAW_EPSILON;
so any nonzero wheel-pulse speed counts as moving. The original idea of allowing play up to about two miles per hour was dropped: the gate is "genuinely stopped," not "slow." The raw wheel-pulse speed is used deliberately rather than the filtered milli-km/h field, because the raw value drops to zero promptly at a real stop while the filtered value low-pass-decays for several seconds and would only add dead time.
Two rules follow from that classification. Any valid movement reading pauses gameplay
and hides Doom immediately, returning to stock navigation. Showing or resuming requires
five continuous seconds of confirmed zero speed (DOOM_VEHICLE_STATIONARY_MS = 5000), after which an armed session auto-resumes behind a visible countdown; any
movement during that qualification resets the interval. The stationary check fails
closed: unknown, invalid, stale, or restarted speed input immediately revokes
eligibility, and a newly valid source has to re-qualify from scratch. Reverse-gear and
priority-blocked states also block it. The activation predicate is the whole policy in
one line:
int doom_policy_activation_allowed(const DoomPolicy *p) {
return p->eligible && p->stationary && !p->blocked && p->observing;
}
Staying out of the way
The whole feature is deliberately parasitic on the running system: it observes speed, borrows the wheel keys while eligible, draws over the HMI, and gives everything back the moment the car rolls — a novelty that cannot fight the vehicle for the screen or the controls while you are driving.