A safe route into the factory menu, and reading a gamepad over USB

NES on a dashboard, and mirroring the screen at 58 Hz proved that a native game could run on the MIB2's QNX stack. Starting it still required holding MENU, entering Testmode, finding the Green Engineering Menu, then using the Toolbox launcher. I wanted an ordinary factory tile to reach the same route. I also needed a controller that the game could read rather than one that merely appeared in a USB inventory.

This post covers both jobs on 15th and 16th September. The target is the Volkswagen MIB2 Standard in the Scirocco: an i.MX6 running QNX 6.5 behind its 800 by 480 factory display. The native launcher remains card-backed and hash-checked. The menu work changed a factory data file under an explicit, reversible campaign. The input work ran as a bounded native process through the unit's USB host stack.

A plausible target that froze the HMI

The factory main menu is a compiled state machine. Its tiles emit events, and the statemachines.res table chooses the state each event enters. Setup led to a factory state with the same visual properties as the destination I wanted, so I retargeted one table word after building a hash-gated write campaign and a byte-exact rollback beside it. The table entry looked unusually safe on paper: both ends were ordinary state identifiers, the source event already belonged to the main menu, and a before-and-after digest would make any unintended file change immediately visible once the unit came back through the bridge.

The transfer itself did exactly what it was meant to do. The campaign verified the payload in volatile memory, compared the live file before copying, made the single replacement under a temporary read-write mount, synced it, read the live hash back, then restored the root to read-only. All 26 commands returned zero. The first tap on Setup locked the HMI. An ignition cycle and a second tap did it again.

That result was useful because it separated a bad route from a bad transfer. The unit was running the exact file I had produced. The target itself violated a structural rule: a Triggereds_Target may name only a state inside the same sub-statemachine. The Setup edit crossed that boundary. It could enter neither the factory path nor a safe recovery route.

The failure also made the rollback important in a very literal way. Its first two records flashed the P4 and accepted the original 244,441-byte payload, then lost the WLAN association before opening a Telnet socket. No MIB command ran, and the bad table remained in place. The link reported authentication expiry on both attempts.

I changed only the pre-contact policy. The revised campaign could retry an association up to eight times inside its existing 45-second budget, and it still treated a disconnect as terminal once a socket existed. That distinction matters on a write route: association retries consume no MIB action, whereas a second connection beneath a half-finished transfer could turn one bounded campaign into an ambiguous write, so the socket boundary remained a hard stop. On the next run, the association succeeded on attempt three. The restored file's hash matched the pinned factory hash after the copy and sync, and the root returned to read-only. Setup was expected to work again after the ignition cycle.

Finding the factory route already there

The correction came from decoding the state machine rather than guessing at another tile. Testmode was already alive in the background while the main menu was visible. Its resting state advanced only when model-datapool property 9854 equalled 33. MainActivity state 2080 already wrote that value and submitted the event that made Testmode active.

The useful part was where state 2080 lived. It was a sibling of the Images tile's original target, under the same parent in the same sub-statemachine. The factory itself already had a transition from the main-menu state to 2080. That made Images a bounded replacement: target 2075 became 2080 at offset 223808. The stored word changed by one byte, and no bytecode, JXE image or new event was needed.

The campaign again checked each boundary. It read the uploaded replacement, the live factory table, a card backup, and the live file after sync. The owner then cycled the ignition and pressed Images. The tile opened the Testmode menu. It also opened the Green Engineering Menu, started the Toolbox launcher and returned cleanly to the main menu. The picture viewer is the deliberate cost of the route; Images no longer opens it.

Menu-route and controller milestones from the retained live runs. The route panel records campaign outcomes; the controller panel counts received input reports.

The figure condenses two different kinds of evidence. The left panel records campaign outcomes, not timing data. The right panel counts received reports. Together they show why the final two-tap route had to follow the failed edit rather than precede it. A tall bar there does not measure game responsiveness or display latency: it records completed HID input reports after protocol initialisation, which was the missing condition for a game to receive a fresh button state at all and a deliberately narrower claim than proving every controller feature, every reconnect case or any particular game feel.

USB had to become input

The controller is an 8BitDo SN30 Pro through a Mayflash MAGIC-NS Lite in Switch Pro mode. The MIB2's external USB-A port enumerated it as a Nintendo Pro Controller, both directly and behind a powered hub. That made the hardware visible. It did not supply a QNX input event to the game.

The first temptation was to read the touchscreen path instead. Two Screen-event probes returned zero events. The key panel was a proxied J5e service over TSD IPC, so it was not a generic touch source that a fullscreen QNX window could subscribe to. The controller route had to use libusbdi against the OTG host manager.

Run 10 connected and attached to the device and read its strings, then found no descriptors because the walker started at the wrong node. Run 13 walked the HID interface, read the 203-byte report descriptor, found the interrupt endpoints and opened a pipe. It also overturned another assumption: usbd_io with a NULL callback queued an asynchronous request and returned. The reader had to poll the URB status and abort only inside a fixed bound; treating the return from usbd_io as completed input would have re-armed a request still owned by the USB stack, explaining why the early reader could look busy without ever delivering a controller byte to the game.

Run 14 produced the first 64-byte report. Its idle transfers carried CMP_ERR alongside timeout or aborted status bits. Counting every CMP_ERR as an error had turned normal idle behaviour into a failure. Treating timeout and abort as idle exposed the actual protocol problem instead: a Switch Pro Controller that had gone quiet needed its greeting before it would stream report 0x30.

Run 15 accepted that greeting, acknowledged the next command and delivered 809 reports in 20 seconds at 60 Hz with zero errors. The public Pro Controller layout decoded buttons from bytes 3 through 5 and four 12-bit stick axes. The proven probe sequence went into the game frontend as a dedicated reader thread, with a small shared snapshot for the libretro input callbacks.

The warm-pad trap

The first playable build inherited the probe's short greeting. It worked with a warm controller and failed with a cold one. Run 17 began with report 0x81 0x01, never entered 0x30 mode, stalled its IN endpoint and re-enumerated. Clearing the stall and using a longer Switch initialisation removed that loop, yet run 18 answered every initialisation step without streaming any input.

The fix was reply-driven initialisation modelled on hid-nintendo: wait for each matching reply, set mode 0x30, retry the mode-set command once, and keep the OUT pipe open. I removed the baud command because this adapter resets when it sees it. The device-info request was unnecessary. The reader still read the strings and HID descriptor first, preserving the probe's known-good ordering.

Run 19 is the result that closed the input gate. SMB3 received 5,878 input reports in 91 seconds, with three idle timeouts, zero aborts, zero errors and no reconnect. The owner played the game and held HOME to quit; the launcher exited zero. The log recorded 5,359 polls, 5,284 fresh reports and 75 stale polls, with the stale path releasing buttons rather than leaving an old press stuck. That release rule is a small part of the result with a large practical effect: if a controller disappears during a wireless hiccup or cable disturbance, the game sees no held direction, fire button or HOME command until a fresh report arrives, and the input bridge cannot manufacture a continuing action from its last valid packet.

SMB3 had already established a full minute of attract mode from the factory menu. This run established live input and the return path around it. Stick centring still needs a quiet-controller capture. Audio also remains unresolved: the native game runs with audio disabled while the factory route is investigated separately.

One related boundary did close that day. A stable no_std Rust library for armv7a-none-eabi linked into the freestanding QNX program, ran on the unit, returned the pinned integer result and selected the amber frame colour. That settles the compiler and ABI question for a later Rusty Nail port. It does not provide its input, audio, storage or rendering adapters.

The next work moved from one game and one controller to the broader emulator library, while keeping the newly proven Images route and HOME return intact.