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

  1. Overview and Hardware
  2. The Dual-QEMU Emulator
  3. Firmware Storage: ROM, Tooling, and SD-Card Forensics
  4. The SD Entrypoint: A Skin-Loaded DLL
  5. The SH4 H.264 Unlock
  6. Audio Routing into the Bose System
  7. Provenance of the Android Auto Components
  8. Tracing and Instrumentation
  9. Prior Art
  10. Development Chronology
  11. Reverse-Engineering the Map Format
  12. 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:

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 ExtFuncLoadLibraryW → 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:

1.5 Chapter map


2. The Dual-QEMU Emulator

The head unit is re-hosted as two cooperating QEMU 8.2.2 processes, not one VM:

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:

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 eglSwapBuffersglReadPixels(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:

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:

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.

3.3 Integrity mechanisms and how they are satisfied

The firmware runs several integrity checks. Each was identified and either rebuilt correctly or avoided:

  1. Raw-record SUM4 (G1/G3): a zero-seeded, wrapping LE-u32 sum of the record payload, stored at record +0x34 (marker SUM4 at +0x30) — not a CRC32. murano_xip_rom.py recomputes it when authoring a record; the card builder sidesteps it entirely by never authoring the G3 record.
  2. MIKE hive index-block/free-slot integrity: murano_hive.py rebuilds 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.
  3. AUITK skin footer (multiskin.dll): the 32-byte footer is part-number, NUL, u32, "SKIN", "SK16NNNI.560". multiskin compares 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-only bumps the footer revision .560.561 (a single byte), forcing the whole-file route and avoiding the truncation and the checksum thread.
  4. VSRAM SRST bank checksum: a modulo-2^32 sum of the preceding LE-u32 words, stored at bank +0x26bf0 (excluding the trailing SRED magic). patch_position.py recomputes it after editing (§3.4).
  5. 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!AABootstrapLoadLibraryW 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:

  1. Loads coredll.dll, opens state journals on both \SystemSD\AA_STATE.TXT and \SystemSD2\AA_STATE.TXT.
  2. Pins aaboot.dll, aalog.dll, aacommon.dll, and probes AndroidAutoCC.dll / AndroidAutoTouchCC.dll — all held resident for the life of the process, because the skin briefly loads the DLL through its ExtFunc and the pointer must stay valid after return.
  3. Writes the COM registry values, then starts an arrival worker and returns to the UI.
  4. In the worker: writes the USB (AOAP) registry rows, removes the Dummy_Class catch-all, requests device-arrival notifications for the AOAP and stream GUIDs, drains already-attached devices, and CreateProcessWs 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:

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:

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 0x43040x4307). 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.pyh264bench.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 createregisterstart. 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:

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:

  1. 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 the AAP_ANDROIDAUTO_VIEW branch and the two AA CLSIDs into the UI so the Android Auto tile exists and is reachable.
  2. The AA G3 modules, deployed as SD files. The core AA modules are rebuilt from the frozen images/aa_g3_button.rom and deployed as files under \SystemSD\AA\aaboot.dll, AndroidAuto.exe, AndroidAutoCC.dll, and the renamed OpenSSL libraries libeay32AALIBEAY.dll and ssleay32AASSLEAY.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.
  3. 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}; and LoadClients\6353_11520..11525 binds all six AOAP PIDs (0x18D1:0x2D00..0x2D05). The clean MY16 hive has zero AA/AOAP keys; the MY18 G118NNNI.161 hive has 19. The two AA COM/CLSID registrations are brought over wholesale — they are present or absent as a set, never partial.
  4. The USB accessory driver. usbaoap.dll (57,344 bytes, VBase 0xeeed0000) is a textbook AOAP accessory driver: EP0 control plus one bulk-IN/bulk-OUT pair, exports AOA_Init/Open/Read/Write/Seek/IOControl/Close/Deinit/PowerUp/PowerDown plus USBDeviceAttach/USBInstallDriver, with a bulk-IN ring sized BulkInBufNum(0x14) × MaxBulkTransferSize(0x4000). The AOAP accessory-identity triple — manufacturer = "Android", model = "Android Auto", version 1.0 — is present verbatim (UTF-16LE) inside AndroidAuto.exe. Its sibling usbaoap_audio.dll implements the deprecated AOAv2 USB-audio path (WAV prefix, 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 r0r3/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=aap127.0.0.1:5280gal_proxy127.0.0.1:5277adb → 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:

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:

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:

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:

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.

The stock Nissan menu rendering in QEMU for the first time.
The stock Nissan menu rendering in QEMU for the first time.

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:

watch on YouTube

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.

Android Auto connecting inside the emulator.
Android Auto connecting inside the emulator.

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.

Android Auto projecting on the real head unit.
Android Auto projecting on the real head unit.

Video — first capture of Android Auto working:

watch on YouTube

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:

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:

watch on YouTube


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.

Doom running on the head unit.
Doom running on the head unit.