FiiO X1 firmware patcher: everything but the EQ
Contents
Two of the things I changed in the FiiO X1's firmware showed up on the device. The one I cared about most, the equalizer, made it onto the screen exactly once, one slot off, and never again.
The X1 is a $99 music player whose firmware, written by HiBy, runs on an Ingenic JZ4760B MIPS chip and drives a TI PCM5142 DAC. Part 9 found switches the factory left off: gapless playback, a USB DAC mode. The obvious next step was a tool that takes the official update file, x1.fw, changes it, and puts it back together so the player's own bootloader will flash it. This is that tool, and the list of what it could and could not do.
The container#
x1.fw is a small filesystem. The format is called IHFS: header magic 0x49484653, the bytes S, F, H, I on disk, which read as "IHFS" when loaded as a little-endian uint32. Four sectors of header, 256 sectors of file table, data from sector 260 on, and a 32-bit checksum at the very end. The patcher's constants, taken from Rockbox's packtools.cpp:
IHFS_MAGIC = 0x49484653 # 'IHFS'
IHFS_EOH = 0x55AA55AA
SECTOR_SIZE = 512
HDR_SECTORS = 4 # header = 4 sectors = 2048 bytes
FILE_TABLE_ENTRIES = 2048
FILE_ENTRY_SIZE = 64 # 56-byte name + 4-byte offset + 4-byte size
FILE_TABLE_SECTORS = (FILE_TABLE_ENTRIES * FILE_ENTRY_SIZE) // SECTOR_SIZE # 256
DATA_START_SECTOR = HDR_SECTORS + FILE_TABLE_SECTORS # 260
The file table has 2,048 slots: a null-padded 56-byte filename, a sector offset, a size. This firmware uses 2,024 of them, for the OS binary (sys.bin), fonts, boot animation JPEGs, theme PNGs, INI files and string tables. The other 24 are zeros.
The checksum deserves a paragraph. packtools.cpp calls it jz4760_crc. Its lookup table holds 256 16-bit values, the kind you'd find in a CRC-16. My first port masked the result to 16 bits, like a CRC-16, and the patcher's very first run complained:
WARNING: CRC mismatch! stored=0xf21bfe5a calc=0xfe5a
Right answer, bottom half only. The C code keeps the accumulator in a uint32_t, and the Python port has to do the same:
def jz4760_crc(crc, data):
for b in data:
crc = (CRC_KEY[b ^ ((crc & 0xFF00) >> 8)] ^ (crc << 8)) & 0xFFFFFFFF
return crc
That crc << 8 pushes bits into the upper half of a 32-bit word, while the table lookup only ever looks at the low 16. The result behaves like neither CRC-16 nor CRC-32. Call it what you want; the bootloader checks it on every update.
The first version of the patcher came to about 380 lines of Python. Parse the header and the file table, extract files, modify them, repack with sector alignment, recompute the checksum, append it. The container is the easy part.
The easy wins#
system_attribute/default.ini inside x1.fw is UTF-16LE XML, and every device default lives there. The patcher flips the switches from Part 9 and turns off the reboot animation:
python3 patch_firmware.py -i x1.fw -o x1_patched.fw \
--no-animation --gapless --usb-dac
Config patched, checksum recalculated, output written. To flash: copy x1_patched.fw onto an SD card as x1.fw, hold SHORTCUTS, power on.
The first try stopped at waiting for sdcard, which by now I take personally. Two lessons came out of it. The bootloader, X-Boot (the SPL), would not run the update while the card also held my backup firmware images; with those moved off it, it started flashing, with a folder of music still on the card. And the bootloader compares the image's date field against what's installed, so the patcher grew a --bump-date flag that writes the current time into the header's 12-byte date field.
On the device: gapless playback is present in the settings. The font change below works. The boot animation still plays on a cold boot, because reboot_animation_switch only governs the reboot path. And USB DAC mode I could not verify at all. The config value is flipped and the firmware should enumerate as "FiiO USB Audio Class 2.0 DAC", but I had no test for it that day, and "should" is not a result.
20 megabytes of glyphs#
The largest file in the firmware is fonts/msyh.ttf, Microsoft YaHei: 21.2 megabytes and 29,359 glyphs, 29,080 of them mapped to characters.
The glyf table, the actual glyph outlines, is 96.8% of the file, and 27,491 of the glyphs are CJK ideographs. The font is there because the X1 ships with Simplified Chinese as its default language. It carries 331 Latin glyphs, and an English menu uses those.
Replace msyh.ttf with Hack Nerd Font, a 2.7MB monospace programming font, and the patched firmware drops from 46 megabytes to 27. The font renders on the 320×240 LCD.
python3 patch_firmware.py -i x1.fw -o x1_patched.fw \
--no-animation --gapless --usb-dac \
--replace-font "fonts\msyh.ttf=/path/to/HackNerdFont-Regular.ttf"
About 18.5 megabytes saved, and no CJK text any more, which is fine until someone switches the menu to Chinese.
There's a boot animation too: 62 JPEG frames, 2.4 megabytes. The patcher can replace each frame with a 1-byte stub (--strip-animations). Whether the JPEG decoder copes with a 1-byte file was never tested on its own; the only builds that carried the stubs also carried the EQ changes below, and those had bigger problems.
The coefficient table#
The PCM5142 has a miniDSP with seven biquad filters per channel. The firmware programs them over a three-wire serial bus it bit-bangs on GPIO pins, in 21-byte blocks: one register-offset byte, then five coefficients (N0, N1, N2, D1, D2), each as 3 data bytes plus 1 reserved, in Q23 signed fixed point.
The lookup table at 0x803DEBE8 in sys_decrypted.bin, behind a 504-byte preamble at 0x803DE9F0 (eight 63-byte entries, one per sample rate), holds eight copies, one per sample rate from 32kHz to 192kHz, each with 13 gain levels for 7 bands. 13 × 7 × 21 = 1,911 bytes per sample rate. In pcm5142_set_eq the compiler builds both multipliers, ×147 for a level and ×1,911 for a table, out of shifts, adds and subtracts rather than a multiply instruction.
The PCM5142 coefficient convention halves N1 and D1 and negates D1 and D2 relative to the textbook biquad, which gives:
H(z) = (N0 + 2*N1*z^-1 + N2*z^-2) / (2^23 - 2*D1*z^-1 - D2*z^-2)
The Audio EQ Cookbook gives the standard formulas for peaking, low-shelf and high-shelf filters. Convert to that convention, quantize to Q23, pack into 21-byte blocks. I wrote a generator that does exactly that, in two modes: a drop-in 13-level table with ±12 dB range and 2 dB steps, and an expanded 25-level table with 1 dB steps.
The 25-level table needs code changes. The per-level stride stays 147, but the flat level moves from 6 to 12, its offset from 882 to 1,764, and the table size from 1,911 to 3,675 bytes, which means rewriting the shift-add sequence. The bigger table also didn't fit: growing it in place would have overwritten the 1-bit bitmap font the crash screen uses. So the table moved to free space at 0x803FA540, and the code got a new pointer.
Flash it. It boots, and then it won't open a music file.
Back to the 13-level drop-in, no code patches. It doesn't get past the boot screen.
A minimal build, config and font only, with sys.bin untouched: boots, plays music. So the problem is my sys.bin, and when I compared what my patcher extracted against what packtools extracted, they disagreed. My working copies of sys.bin and sys_decrypted.bin had been modified by the 25-level experiment, and every build since had been re-encrypting a binary I had already broken. I had been debugging my own leftovers.
Rebuilt from a clean decrypt of the original x1.fw, the 13-level table still doesn't boot. A build that changes one byte of sys.bin and nothing else boots fine. So modified firmware runs, and writing the 13-level table is what stops the boot.
That earns this series its fourth rule. Rule four: a patch is done when the device does the thing, not when the bytes are right. By the bytes, this one was perfect. Valid Q23 values, the layout I believed was the original, an XOR round trip that decrypts to exactly what I wrote. By the device, it is a black screen. Something about that table write breaks initialization, and my working guess was a constraint the miniDSP enforces that the Cookbook math doesn't know about.
The cache#
Step back. The coefficient table is the hard problem. Try the easy one: preset gains.
FiiO's manual lists ten EQ settings, from Off to Custom, with Blues among them. The firmware has nine slots, named by nine strings from style_normal, the slot behind Off, to style_custom, and none of them is Blues. The band gains behind them sit in a table at 0x803E7400, as float32 values: no Q23, no biquads, just decibels for the firmware to feed into its own coefficient lookup.
I patched in proper curves from published EQ presets (VLC, Winamp and others), mapped to the X1's ten bands, and the first try reached the device one slot off: Rock came up flat, and Classical carried the curve I had written for Rock. My index, not the firmware's: I had put my Custom where OFF lives. After fixing that, the patched binary differs from the original in 116 bytes, all inside the preset table. The XOR re-encryption is correct. The checksum is correct.
Flash it. Boot. EQ settings. Rock.
Still flat, the same presets the off-by-one build had left on the screen.
My custom EQ and my theme choice had survived the flashes too, which pointed at the theory I built next: the firmware keeps EQ presets in a cache on the NAND's userdata partition, writes the defaults there once, and from then on reads the cached copy instead of the values baked into the binary.
Factory reset: Rock is still flat. Flash the original firmware, factory reset, then flash the patched one, hoping the round trip clears whatever is cached: Rock still reads all zeros.
The eq.ini shipped inside x1.fw is 4,096 bytes of zeros, an empty template that the firmware fills in at runtime. If the cache is real, it lives somewhere in the FTL-managed userdata partition, which the update mechanism can't reach. I couldn't reach it either, not without the debug shell or a custom NAND tool.
The binary is correct. The device doesn't care.
What the patcher built#
The patcher works for everything except sound.
Config patches apply cleanly: gapless shows up in the settings. The font swap saves about 18.5 megabytes and renders fine in English. The IHFS container is understood, the checksum matches, the XOR encryption round-trips.
The PCM5142's EQ is behind two walls. Writing the coefficient table stops the boot, for a reason I haven't found. And the preset gains reached the device once, in the wrong slots, and not again once the slots were right, for a reason I have a theory about and no proof of.
I can change what the device does. I can't change how it sounds.
Next would be comparing coefficient changes one band at a time against the original table, to find what in that write stops the boot, and getting into the NAND, through the sa_debug.sa shell from Part 9 or direct sector access, to find the preset cache and clear it.
Eight parts to get the firmware out of the device, one to read it. One patcher to put modified firmware back in. Zero filter coefficients changed so far. By rule four, none of the EQ work is done. By the same rule, neither is my cache theory: I haven't seen the cache, only the symptom it would explain.
Filed under: reverse-engineering, mips, embedded, fiio, hardware
Read next#
- The X1's boot chain, read at the right base address — the next part: whether the bootloader checks anything at all before running modified firmware.
- Ten bands: how the X1's EQ really works — where this EQ story is settled, including what happened to the cache theory.
Sources#
- TI PCM5142 Datasheet (SLAS759B) — biquad filter registers (Table 21), miniDSP architecture, Q23 coefficient format
- TI SBAA414 — PCM6xx0 Programmable Biquad Filter Configuration and Applications — the same N1/D1 divide-by-2 and D1/D2 negation convention, written for TI's PCM6xx0 ADCs (32-bit coefficients), with computation examples
- Audio EQ Cookbook (Robert Bristow-Johnson) — canonical biquad formulas for peaking, low-shelf, high-shelf filters
- Rockbox JZ4760 Tools — IHFS/packtools CRC implementation, firmware unpack/repack
- FiiO X1 User Manual — official EQ preset names, firmware update procedure
- FiiO X1 review (Headfonics, 2014-10-07) — launch price: "The FiiO X1 on the other hand at $99"