FiiO X1 EQ presets: floats in .data, not a NAND cache
Contents
Where does a music player keep its equalizer presets? On the FiiO X1 I had a confident answer, a cache on the flash chip that the firmware writes once and trusts forever, and the answer had cost me an evening of flashing.
The X1 is a $99 player on an Ingenic JZ4760B MIPS chip, running firmware that HiBy wrote on the µC/OS-II real-time kernel. In Part 10 I built a patcher that rewrites sys.bin, the firmware binary inside the official update file x1.fw, and the one change I wanted most showed up once, one slot off, and never again after I fixed the slot: I patched the preset gains, flashed, opened the EQ screen, and Rock still read all zeros. Meanwhile my custom EQ setting and my theme choice had survived the flash. So I wrote down a theory: the presets live in a cache on the NAND's userdata partition, and the values in the binary only matter the first time. This part reads the firmware's EQ code in Ghidra, a disassembler and decompiler, instead of guessing at it.
The eq.ini that nothing reads#
I distrust a file the way I distrust a datasheet: by what its name promises. A file called eq.ini on the userdata partition promises that something reads it. My cache theory rested on that promise.
The firmware holds two strings with that name: a bare eq.ini, used by the settings screen code, and y:\eq.ini, a path on the internal drive. The path has exactly one reference, from the function at 0x800453c0. Here is Ghidra's decompile of it, unedited:
void FUN_800453c0(void)
{
int iVar1;
undefined4 local_10 [2];
iVar1 = thunk_FUN_800162fc(u_y__eq_ini_8033af70,&DAT_8032862c);
if (iVar1 != 0) {
local_10[0] = 0xff;
thunk_FUN_80015948(local_10,1);
thunk_FUN_80015948(&DAT_803e7400,1,0x168,iVar1);
thunk_FUN_80016794(iVar1);
return;
}
FUN_80032800(s_lg_helper_equalizer_8033af84,s_EQ_write_file__8033af98,1,0);
return;
}
FUN_800162fc is the firmware's file open, and the mode string at 0x8032862c is "wb". The function opens y:\eq.ini for writing, writes one 0xff byte, then 0x168 bytes, 360, from 0x803E7400, and closes it. Its only caller is the routine that saves all settings. Nothing in the firmware opens y:\eq.ini with "rb". From finding the string to seeing the "wb", it took about seven minutes.
The file is a diary nobody reads. The firmware writes the current EQ state into it whenever settings are saved and never looks at it again.
Where the preset gains live#
0x803E7400 is the active preset buffer: 360 bytes, nine presets of 40 bytes each, in the order normal, rock, classic, jazz, pop, dance, voice, metal, custom. Right after it, at 0x803E7568, sits a second 360-byte copy, the factory defaults. The reset routine is one copy:
undefined4 FUN_80045780(void)
{
undefined4 uVar1;
uVar1 = FUN_8004e980();
FUN_802e2f00(&DAT_803e7400,&DAT_803e7568,0x168);
FUN_800456a0(uVar1);
return 0;
}
Get the selected preset index, copy 360 bytes of defaults over the active buffer, apply the preset. Both buffers are in the binary's .data section, so every boot starts with whatever values sys.bin carries. What persists across boots is the index of the selected preset, kept in the settings structure at offset 0x270 and saved to y:\user_attr.ini, along with the rest of the settings.
That explains the evidence that started the theory. My custom EQ and my theme survived the flash because they are settings, and settings live in user_attr.ini. The preset values were never cached anywhere. The cache was my invention, built on a correct observation about a different file.
Each preset is ten IEEE 754 floats, decibels, little-endian. The conversion code rejects anything outside -6.0 to +6.0 with ***wrong eq.
The pipeline to the DAC#
Twenty functions got names in that session. The path from a preset to the hardware, with the names from my notes:
| Address | Name | Role |
|---|---|---|
0x8004e980 | get_eq_preset_index | selected preset from the settings structure |
0x800456a0 | lg_set_equalizer | takes the preset's 40 bytes from 0x803E7400 + index * 0x28 |
0x80021b14 | eq_apply_to_dac | converts, then hands over to the DAC driver |
0x80021834 | eq_float_to_coeff_index | dB to a table index, 0 to 12, where 6 is flat |
0x80020c30 | pcm5142_set_eq | writes 21-byte coefficient blocks to the PCM5142 DAC |
0x800453c0 | eq_write_file | the write-only y:\eq.ini above |
0x80045780 | lg_reset_all_eq_presets | the 360-byte copy above |
The PCM5142 is TI's DAC with a programmable miniDSP, and this firmware drives it as seven biquad filters. The firmware doesn't compute these filters itself. It turns each gain into an index and looks up precomputed coefficients in the table at 0x803DEBE8, behind a 504-byte preamble, eight copies of 13 levels by 7 bands by 21 bytes, one copy per sample rate from 32 kHz to 192 kHz.
That table is what Part 10 had overwritten with coefficients from the Audio EQ Cookbook, and none of those builds worked: the 13-level tables stopped at the boot screen, and the 25-level one booted and then wouldn't open a music file. Reading the pipeline showed I had been editing the layer below the one the firmware expects people to touch: the presets are plain floats, and the firmware does the conversion itself. Why the 13-level builds stopped at the boot screen is a separate question; Part 15 narrows it to two suspects, neither tested.
Seven bands or ten#
My first decode of the preset buffer read each 40-byte block as seven band gains plus three metadata floats, because the firmware drives seven biquads. The firmware's own system_attribute/default.ini, inside x1.fw, disagrees about the count:
<eq_db_hz_31>0.0</eq_db_hz_31> //db value
<eq_db_hz_62>0.0</eq_db_hz_62>
<eq_db_hz_125>0.0</eq_db_hz_125>
<eq_db_hz_250>0.0</eq_db_hz_250>
<eq_db_hz_500>0.0</eq_db_hz_500>
<eq_db_hz_1k>0.0</eq_db_hz_1k>
<eq_db_hz_2k>0.0</eq_db_hz_2k>
<eq_db_hz_4k>0.0</eq_db_hz_4k>
<eq_db_hz_8k>0.0</eq_db_hz_8k>
<eq_db_hz_16k>0.0</eq_db_hz_16k>
Ten keys, 31 Hz to 16 kHz in octaves, and ten floats per preset, so I read the blocks as ten bands in that order. The stock values can't confirm it: in all nine presets the last three floats are zero, which is what seven bands and three spare floats would look like too. My first decode had found non-zero numbers there, but it ran on hex I had copied out of Ghidra's memory listing by hand, and the copy was wrong; the listing itself has zeros. How the firmware maps ten gains onto seven biquads, if it does, I haven't traced; pcm5142_set_eq loops over seven bands.
What FiiO shipped in nine slots#
Read as ten bands, the stock presets are:
| Preset | Non-zero bands (dB) |
|---|---|
| Normal | none |
| Rock | 62 Hz +3, 125 Hz -1, 250 Hz -2, 1 kHz +4, 2 kHz +4 |
| Classic | 31 Hz +6, 62 Hz +5, 1 kHz +2, 2 kHz +2 |
| Jazz | 62 Hz +3, 125 Hz +4, 250 Hz +4, 500 Hz +1, 1 kHz +3, 2 kHz +4 |
| Pop | 31 Hz +2, 125 Hz -4, 250 Hz -4, 500 Hz -1, 1 kHz +2, 2 kHz +3 |
| Dance | 31 Hz +3, 62 Hz +2, 125 Hz -2, 250 Hz -2, 1 kHz +4, 2 kHz +4 |
| Voice | 31 Hz -1, 62 Hz +3, 1 kHz -4, 2 kHz -6 |
| Metal | 250 Hz +1, 500 Hz +1, 1 kHz +3 |
| Custom | none |
Voice cuts 2 kHz by 6 dB, as far as the range allows. Metal is +1 dB at 250 and 500 Hz and +3 dB at 1 kHz, a midrange bump with nothing under it, which is a bold reading of metal. Read this way, no preset goes above 2 kHz, and two of the nine are flat.
Nine new presets, one build#
With the layout known, the patcher change was small. Each preset packs into 40 bytes with one call, after a range check:
def pack_eq_preset(bands):
"""Pack 10 band gains into 40-byte preset (10 floats)."""
if len(bands) != EQ_NUM_BANDS:
raise ValueError(f"Expected {EQ_NUM_BANDS} bands, got {len(bands)}")
for i, g in enumerate(bands):
if not (-6.0 <= g <= 6.0):
raise ValueError(f"Band {i} ({EQ_BAND_LABELS[i]}) gain {g} out of range [-6.0, +6.0]")
return struct.pack('<10f', *bands)
and the patcher writes the result into both buffers, active and defaults, at file offsets 0x3E6400 and 0x3E6568 in the decrypted sys.bin. On top of that went thirteen genre curves, researched for headphone listening and named on the command line. This is the deathcore entry:
'deathcore': {
'bands': [3.0, 4.0, 2.0, -2.0, -3.0, -3.0, -1.0, 3.0, 4.0, 3.0],
'desc': 'Deathcore — tight low end, deep mid scoop, cutting guitars',
},
Nine slots, thirteen curves: I filled every slot, two of them with values typed in by hand, a warm "live" curve for Normal and a trance curve for Dance. The build that went onto the card:
python3 scripts/patch_firmware.py -i x1.fw -o /tmp/x1_nofont.fw \
--eq-preset 'normal=2.0,3.0,2.0,0.0,-1.0,0.0,1.0,2.0,1.0,0.0' \
--eq-genre deathcore:rock \
--eq-genre metalcore:classic \
--eq-genre darkwave:jazz \
--eq-genre synthwave:pop \
--eq-preset 'dance=3.0,4.0,3.0,-1.0,-2.0,0.0,1.0,3.0,4.0,5.0' \
--eq-genre electronic:voice \
--eq-genre hardcore:metal \
--eq-genre dnb:custom \
--gapless --usb-dac --bump-date 2>&1
--bump-date writes the current time into the image's date field, because the bootloader won't flash an update dated older than what's installed.
The flash, and the font that failed first#
The output is named x1_nofont.fw for a reason. The build before it also swapped the 20 MB Chinese system font for Barlow, a 147 KB Latin-only font, and the player answered with system error. Part 10's swap to Hack Nerd Font, 2.6 MB, had worked. My guess is that the firmware needs glyphs Barlow doesn't have, CJK among them, but I didn't test which.
Without the font change: x1_nofont.fw copied onto the SD card as x1.fw, the only file on the card, SHORTCUTS held at power-on, update, reboot. After a factory reset, the EQ settings showed the new curves under the old names. Rock is deathcore now. Metal is hardcore, which at least means it finally has some bass.
That's what I verified: the values on the EQ screen. I didn't run a listening test or a measurement that night.
The build that worked differed from Part 10's failed preset builds in several ways at once: values all inside ±6 dB (Part 10's Dance had gone to +10), the nine-slot ten-band layout instead of one guessed before I'd read the code, both buffers patched. Which difference mattered, I never isolated. Changing everything at once is a fine way to get a working build and a poor way to learn anything from it, and I chose the working build.
The drive letters#
One more thing came out of the same reading. The firmware's VFS uses DOS-style drive letters, mounted in firmware_main_init at 0x80001df0: y: is internal_disk, the writable userdata partition with user_attr.ini and the write-only eq.ini; z: is resource_disk, the read-only IHFS system partition the update writes, with the fonts, default.ini and the UI resources. Everything on z: comes from x1.fw. Everything on y: is runtime state. The preset values were on neither drive. They were in the binary all along, where the patcher had been writing them.
So my theory had a file name, a partition and a mechanism, and lacked only the code that reads the file. I'll go on distrusting file names. This one promised a reader and delivered a diary.
Filed under: reverse-engineering, mips, embedded, fiio, hardware, audio
Read next#
- A 12 KB Rockbox bootloader for factory.x1 — the next part: nine fixed slots is where patching stops scaling.
- FiiO X1 EQ table: seven register bytes my generator never read — what the coefficient table really holds, and the two untested suspects for Part 10's failed boots.
- The firmware patcher — the cache theory, and the evening it cost.
Sources#
- TI PCM5142 product page — the DAC and its programmable miniDSP
- TI E2E: PCM5142 biquad EQ calculation — biquad coefficient format discussion
- Rockbox JZ4760 tools — packtools: XOR scramble, IHFS container
- Audio EQ Cookbook (Robert Bristow-Johnson) — the biquad formulas behind Part 10's coefficients
- Headphonesty: EQ settings by genre — one of the sources for the genre curves
- FiiO X1 review (Headfonics, 2014-10-07) — launch price, $99