FiiO X1 EQ table: seven register bytes my generator never read

My project notes for the FiiO X1 have a gotchas section, and for a while one line in it was wrong in a way that sounded careful. It said that the EQ coefficients I had generated in Q23, a 24-bit fixed-point format, broke the boot, so the PCM5142, the player's DAC chip, probably had constraints beyond the math of the Audio EQ Cookbook, the standard recipe for these filters.

It reads like a finding. It was a shrug with a datasheet attached. I distrust a root cause the way I distrust a datasheet, by how much it explains without having been tested, and this one explained everything and had been tested against nothing.

The X1 is a portable music player from 2014: an Ingenic JZ4760 MIPS chip runs HiBy's firmware, and a TI PCM5142 DAC turns the samples into sound. The PCM5142 has a small built-in DSP, and the firmware runs its EQ screen through seven biquad filters on it: for each filter it sends five coefficients, picked from a lookup table inside sys.bin, the firmware binary in the official update file (offsets in this post are in its decrypted copy). In Part 10 I overwrote that table with coefficients from my own generator, which computed them with the Cookbook's formulas. None of those builds worked: the 13-level tables stopped at the boot screen, and a 25-level one booted and then wouldn't open a music file. Part 12 went around the table by patching the preset gains above it. This part goes back into the table and decodes the one that already boots.

The format suspect: Q23 was fine#

Q23 is the number format the generator wrote: a signed 24-bit value read as raw / 2^23, so every coefficient lies between -1 and just under +1. If the PCM5142 wanted something else, every generated value would have been misread.

The firmware's own table starts at file offset 0x3DD9F0: a 504-byte preamble, then, from 0x3DDBE8, eight sample-rate tables of 1,911 bytes, one each for 32, 44.1, 48, 64, 88.2, 96, 176.4 and 192 kHz. Each table holds 13 gain levels, each level 7 bands, each band 21 bytes. On the day, my scripts treated it as one 17,199-byte block of nine tables for 8 to 48 kHz; the section on where the table starts is about how that went. Every band record has the same shape:

[0]      register-offset byte (PCM5142 CRAM target)
[1..3]   N0  (3 bytes, big-endian, signed Q23)   [4]  reserved 0x00
[5..7]   N1                                       [8]  reserved 0x00
[9..11]  N2                                       [12] reserved 0x00
[13..15] D1                                       [16] reserved 0x00
[17..19] D2                                       [20] reserved 0x00

The PCM5142 datasheet does describe a "4.20 format", and a search through it turned that up, but the sentence is about mixer gain coefficients, not biquads. As suspects go it lasted about three minutes, on weaker evidence than it deserved: I checked that every coefficient in the block decodes between -1 and +1, which any 24-bit value read as Q23 does by construction. The firmware has better evidence of its own. The first preamble entry is three pass-through records: N0 is 0x7FFFFF and the other four coefficients are zero. In Q23 that is a gain of just under 1, what a filter that does nothing carries. In 4.20, where 1.0 is 0x100000, the same bytes would be a gain of about 8.

Seven bytes: the register offsets#

The first byte of each record is the one I had skipped. It is the address inside the DAC's coefficient RAM where the record's five values go, and my generator never read it from the firmware. It wrote one fixed pattern for the seven bands of every level, starting at 0x3DD9F0:

08 1c 30 44 58 6c 08

Read from the base the firmware's own code points at (the next section is about which base that is), every gain level of all eight tables carries a different one:

44 58 6c 08 1c 30 44

Laid side by side, those two lines differ in all seven positions, but that comparison puts my table on my base and the stock table on the firmware's. On the day I had compared both on my base, where stock tables 1 to 7 read 1c 30 44 44 58 6c 08, and counted three of seven. Neither is what the firmware sees: it doesn't read my table from my base. It reads its main table 504 bytes in, which is 24 records, and 24 records is three bands past a whole number of levels. So every level it takes from my file starts three bands into my pattern:

44 58 6c 08 08 1c 30

Against stock, bands 0 to 3 get the right register byte in all 416 of their records in tables 0 to 7, and bands 4 to 6 get the wrong one in all 312 of theirs. In the preamble, six of the eight entries carry the wrong registers; entries 0 and 7 come out right by accident. The coefficients are worse off than the addresses: every record the firmware loads holds values my generator computed for a different slot, the band three along, three or four levels higher, and in the top levels of each table the next table's. A right register byte with the wrong band's numbers behind it is still a wrong filter.

Three wrong addresses per level and shifted numbers in every record: that is a large difference between my builds and the stock firmware, and not the only one; the next section turns up another. It is a suspect, not a cause, because no build with corrected offsets has run on the player yet. What a coefficient written to the wrong address does inside the DSP, I can't see from the firmware. It doesn't stall a bus, at least: the DAC sits on a three-wire serial line that the firmware bit-bangs on GPIO pins, and nothing on that line acknowledges anything.

Where the table starts: the decompiler was right#

The code that consumes the table is pcm5142_set_eq at 0x80020c30. In Ghidra, the disassembler and decompiler I read the firmware with, it walks the table in clear strides: 0x777 bytes per sample rate (1,911), 0x93 per gain level (147), 0x15 per record (21). It writes each record to DAC pages 0x2c/0x2d and again to 0x3e/0x3f, and its default level is 6, the flat one.

It also put the main table at 0x803DEBE8, 504 bytes past 0x803DE9F0, with those 504 bytes as a separate preamble indexed by sample rate. On the day, I didn't believe it. My check was the reserved bytes: if a parse lines up with the records, every fourth coefficient byte is 0x00. From 0x803DE9F0, tables 0 to 7 had no nonzero reserved bytes; from 0x803DEBE8, the count came back as 209. I wrote down that the bytes rejected the decompiler and moved on.

The two numbers counted different things.

NoteCorrection, 2026-11-01

The 209 counted all nine tables; the zero counted tables 0 to 7. Counted the same way, both anchors give zero nonzero reserved bytes in tables 0 to 7, and every violation (138 from 0x803DE9F0, 209 from 0x803DEBE8) sits in table 8, a region neither reading treats as real. Because 504 is 24 × 21, a whole number of records, both anchors stay aligned, so the reserved-byte check cannot pick between them. Other checks can. From 0x803DEBE8, each of tables 0 to 7 uses one register pattern on all 13 levels. From 0x803DE9F0, table 0 uses five, and the first 504 bytes split into eight 63-byte entries of three records each, all with registers 08 1c 30 and three identical coefficient sets: the per-sample-rate preamble the decompile describes. The register comparison above uses 0x803DEBE8.

Index 8 isn't a table at all. pcm5142_set_eq gets its table index from FUN_800207e8, which returns 0 to 7 for 32, 44.1, 48, 64, 88.2, 96, 176.4 and 192 kHz and -1 for any other rate; it never returns 8.

That leaves my generator wrong on three counts, the third found only while rechecking this post. It started its table 504 bytes early, writing over the preamble, which put every record 24 slots off and three register bytes per level wrong. It designed its nine tables for 8 to 48 kHz, so the firmware plays coefficients computed mostly for 8 kHz at 32 kHz, and mostly for 44.1 kHz at 192 kHz. And the patcher writes the whole 17,199-byte file at 0x3DD9F0, while the eight real tables end at 0x3E17A0, so the 13-level drop-in also ran 1,407 bytes past them and changed 1,156 of those.

Those 1,407 bytes belong to something else. The first word points at the LCD panel's descriptor, a structure that names the panel, TX05D125VM0AAA_ILI9342, and holds the numbers 320 and 240; the second points at a structure next to it. My file replaced the two words with 44 78 b0 de 00 92 83 e5. The code loads them at 14 places, 12 of them for the second word, and my search found no instruction that writes either. An EQ patch that rewrites the screen's address book is scope creep of a kind I had not budgeted for. Nothing has run with only that fixed, so it is a second suspect, not the cause. It differs from the first in one way: a bad coefficient goes down a wire that answers nothing, while these words are read by the processor itself.

The decompiler had read the code correctly, and the bytes had agreed with it all along. The thing that lied was my count, which is not the lesson I had planned to write about decompilers.

The poles were never fixed#

A biquad's two denominator coefficients, D1 and D2, set its poles: the frequency it resonates at and how sharply. The record's transfer function is:

H(z) = (N0 + 2*N1*z^-1 + N2*z^-2) / (2^23 - 2*D1*z^-1 - D2*z^-2)

On the day I took the firmware's design to be fixed-pole: one denominator per band, with only the numerator changing as the gain level changes. The Cookbook does it differently, moving the poles with the gain, and that looked like a second reason my coefficients had failed. Read from 0x803DEBE8, each of the eight tables has seven distinct denominators per band across its 13 levels. The firmware's poles move too. That theory goes in the same drawer as the 4.20 format.

eq_lut_codec.py: what the round trip proves#

The replacement for my generator was eq_lut_codec.py, which decodes the table into records and encodes them back. Its round trip over the region, from the run that day:

LUT region: 0x3DD9F0 .. 0x3E1D1F (17199 bytes)
round-trip reencode(decode(fw)) == fw[LUT]: PASS
reserved-byte==0 (tables 0-7): PASS (0 nonzero)
  table 8 (48kHz, degenerate, exempt): 138 nonzero reserved bytes

Byte-exact: the decoder loses nothing. What it doesn't prove is the layout, because it is anchored at 0x3DD9F0, the base the correction above retires. Any 21-byte grid round-trips; that is what a grid does. The codec needs re-anchoring at the preamble and the main table before its decoded levels mean anything.

The other test that day was an identity repack: unpack the official x1.fw, repack it unchanged, compare. The body files came back byte-identical, sys.bin with MD5 b6a9551984e2805372649a40aef7afc1, and only the header date and the CRC trailer differed, because patch_firmware.py --bump-date writes a newer date so the bootloader accepts the update. That x1_identity.fw tests the unpack, repack and flash pipeline without changing a byte of firmware. It hasn't been flashed.

The overshoot gate#

The codec can also write new tables. Its regenerate() takes one pole set per band from level 12 and rewrites only the numerators, in what its docstring calls the fixed-pole (Mitra-Regalia) form, with G = 10^(dB/20):

b0 = (1.0 + G) / 2.0 + (1.0 - G) / 2.0 * a2
b1 = a1
b2 = (1.0 - G) / 2.0 + (1.0 + G) / 2.0 * a2

The cut half of the sweep came out clean and monotonic. The boost half did not: three low bands in what the codec labelled the 44.1 kHz table have poles near 14, 35 and 87 Hz at that rate with a radius of about 0.999, and a peak boost on a resonator that sharp is a spike. With 1 dB per level, the patch run measured this:

  max |response| = 47.3 dB (requested max 6.0 dB) -> OVERSHOOT - DO NOT FLASH

The patch command now exits nonzero when that line says overshoot, so no such table gets built. Nothing was flashed. And those poles came from the misanchored decode, under the codec's guessed sample rates, so the next version of this gate has fresher numbers to be angry about.

What died#

The format theory survived because it was the interesting problem: fixed-point scaling, undocumented DSP limits, datasheet small print. The register byte is addressing. It is the byte you skip to get to the coefficients, and as the firmware reads my table it was wrong on three bands of every level. Under it, the start of my table was wrong, its end ran into the display's pointers, the sample rates were wrong, and so was the proof I built to settle where the table starts.

Two suspects are left for the 13-level builds that stopped at the boot screen: a table shifted 24 records on a wrong base, and 1,407 bytes of overrun. Neither has to apply to the 25-level build, which moved its table to free space behind a new code pointer, booted, and then wouldn't open a music file. Neither suspect has run on anything, and by my own standard that makes them the two least trustworthy things in this post. Two flashes separate them: my old table cut off at 0x3E17A0, which removes only the overrun, and a corrected table that stops there too.

Filed under: reverse-engineering, mips, embedded, fiio, hardware, audio, dsp

Sources#