🇷🇺 На русском: PROTOCOL.ru.md
Goal: write a Linux-native program for reading/writing eMMC through the XGecu T48 universal programmer in ISP (in-circuit) mode. This document collects everything we recovered by static analysis of the first-party software.
Sources of truth:
xgecu/Xgpro.exe— first-party software (PE32, 32-bit x86, WinUSB).xgecu/algorithm/*.alg— FPGA bitstreams + algorithm parameters per chip family.xgecu/InfoIC2Plus.dll— chip database (dumped via thedumpictool inminipro).minipro0.7.4 (GPL-3.0), filesrc/t48.c— the open-source reverse-engineered implementation of the T48 transport for classic-chip operations.
None of the proprietary files above are shipped in this repository.
The interested reader is expected to have a legitimate copy of Xgpro.
From the bundled xgecu/drv/Xgprowinusb.inf:
- VID/PID:
0xA466 / 0x0A53 - Class: WinUSB (vendor-specific bulk)
- DeviceInterfaceGUID:
{E7E8BA13-2A81-446E-A11E-72398FBDA82F} - Manufacturer: "Haikou Xingong Electronic Co,Ltd"
On Linux the device is opened directly through libusb
(pyusb / libusb-1.0); no kernel driver is required.
From analysis of Xgpro.exe (call sites of WinUsb_WritePipe /
WinUsb_ReadPipe):
| EP | Direction | Carries | Packet size |
|---|---|---|---|
| EP1 | OUT / IN | commands, config, status | 8 or 16 bytes (fixed structures) |
| EP2 | OUT | bulk eMMC data (write side) | N × 512 bytes (sector-aligned) |
| EP2 | IN | bulk read-back (classic READ_CODE + eMMC) — see §32 |
64 B / N×512 B |
Call-site counts in the binary: WinUsb_WritePipe = 36 distinct call
sites, WinUsb_ReadPipe = 70, WinUsb_Initialize = 1.
From minipro/src/t48.c. Opcodes 0x02..0x3F:
#define T48_NAND_INIT 0x02
#define T48_BEGIN_TRANS 0x03 // 64-byte init packet: protocol_id, voltages, clock, sizes
#define T48_END_TRANS 0x04
#define T48_READID 0x05
#define T48_READ_USER 0x06
#define T48_WRITE_USER 0x07
#define T48_READ_CFG 0x08
#define T48_WRITE_CFG 0x09
#define T48_WRITE_USER_DATA 0x0A
#define T48_READ_USER_DATA 0x0B
#define T48_WRITE_CODE 0x0C
#define T48_READ_CODE 0x0D
#define T48_ERASE 0x0E
#define T48_TEST_RAM 0x0F
#define T48_READ_DATA 0x10
#define T48_WRITE_DATA 0x11
#define T48_WRITE_LOCK 0x14
#define T48_READ_LOCK 0x15
#define T48_READ_CALIBRATION 0x16
#define T48_PROTECT_OFF 0x18
#define T48_PROTECT_ON 0x19
#define T48_SET_VCC_VOLTAGE 0x1B
#define T48_SET_VPP_VOLTAGE 0x1C
#define T48_READ_JEDEC 0x1D
#define T48_WRITE_JEDEC 0x1E
#define T48_LOGIC_IC_TEST_VECTOR 0x28
#define T48_RESET_PIN_DRIVERS 0x2D
#define T48_SET_VCC_PIN 0x2E
#define T48_SET_VPP_PIN 0x2F
#define T48_SET_GND_PIN 0x30
#define T48_SET_PULLUPS 0x31
#define T48_SET_PULLDOWNS 0x32
#define T48_MEASURE_VOLTAGES 0x33
#define T48_READ_PINS 0x35
#define T48_SET_OUT 0x36
#define T48_AUTODETECT 0x37
#define T48_UNLOCK_TSOP48 0x38
#define T48_REQUEST_STATUS 0x39
#define T48_BOOTLOADER_WRITE 0x3B
#define T48_BOOTLOADER_ERASE 0x3C
#define T48_SWITCH 0x3D
#define T48_RESET 0x3FThe first byte of every EP1 command packet is the top-opcode. For some top-opcodes the second byte is a sub-opcode that selects a specific operation within that class.
EP1 command packet = [byte 0: top-opcode] [byte 1..7: sub-opcode/args]
| Top-opcode | Origin | Purpose | byte[1..7] |
|---|---|---|---|
0x00 |
Xgpro (live §31) | device identify — build date + serial + version | all-zero 8-byte req → 63-byte reply |
0x02 |
minipro (classic) | NAND_INIT |
— |
0x03 |
minipro (classic) | BEGIN_TRANSACTION (protocol_id, voltages, clock, sizes) |
64-byte packet |
0x04 |
minipro | END_TRANSACTION |
— |
0x05 |
minipro + Xgpro | READID — identify chip (used in eMMC init too) |
2-byte param, recv 32 bytes |
0x06 |
minipro + Xgpro | READ_USER — config zone (recv 24 in eMMC init) |
recv 24 bytes |
0x07..0x1F |
minipro | classic-chip operations | — |
0x08 |
Xgpro NEW (eMMC) | "long-recv" envelope | sub-opcode (0x48 = read 512 B) + length + addr |
0x14 |
Xgpro NEW (eMMC) | bulk-write setup before EP2 OUT | sub-opcode + count + block_addr |
0x21 |
Xgpro NEW (eMMC) | init / algorithm select for eMMC | 1-byte parameter from settings |
0x27 |
Xgpro NEW (eMMC) | eMMC sub-command dispatcher | sub-opcode + 32-bit argument (see below) |
0x39 |
minipro | REQUEST_STATUS |
— |
0x3F |
minipro | RESET |
— |
| Sub-op | Semantics | arg32 |
|---|---|---|
0x46 |
CMD6 SWITCH | BE-encoded JEDEC: [Access][Index][Value][CmdSet] |
0x4C |
CMD12 STOP + CMD13 STATUS | 0 |
0x4D |
commit / finalize FPGA (password / RPMB write) | — |
0x50 |
512-byte data transfer (CMD24 / OTP / RPMB write) | 0x200 |
0x57 |
CMD23 SET_BLOCK_COUNT | block count (usually 1) |
0x5C |
TBD (1 call site) | — |
0x5D |
Read WGP table (Write-Group Protection) | — |
| Sub-op | Semantics |
|---|---|
0x48 |
CMD8 SEND_EXT_CSD / CMD17 READ_SINGLE_BLOCK (recv 512 B) |
The 7 sub-opcodes under 0x27 cluster above 0x40 — they are exactly
the part of the device protocol that minipro does not implement,
and they handle every eMMC operation that involves talking to the chip
controller (versus the FPGA itself).
Reverse of function 0x4af370 in Xgpro.exe (the eMMC init sub-step
called after BEGIN_TRANSACTION from the caller):
Step 1: top-opcode 0x21 + 1-byte parameter [from 0x7485d0] → recv 8 bytes
(probably "select algorithm/variant" for eMMC)
Step 2: top-opcode 0x05 (READID) + 2-byte parameter [0x7a39cc] → recv 32 bytes
(chip ID / OCR / CID)
Step 3: top-opcode 0x06 (READ_USER) → recv 24 bytes
(config or CSD)
Step 4: top-opcode 0x27, sub 0x46 (CMD6 SetBits HS_TIMING=0x01) → switch to HS-200
Step 5: top-opcode 0x27, sub 0x46 (CMD6 …) → another ECSD setup
The BEGIN_TRANSACTION (opcode 0x03) with protocol_id = 0x31
(IC2_ALG_EMMC) and the right variant (e.g. 0x4100 for ISP 1-bit)
is sent before 0x4af370 from the calling function. The 64-byte
layout of the BEGIN_TRANS packet is documented in minipro/src/t48.c.
There are four distinct EP1 packet shapes used for eMMC operations, plus the optional EP2 OUT bulk payload.
struct EP1_Cmd_A { // send + short recv
uint8_t top_opcode; // = 0x27
uint8_t sub_opcode; // 0x46/0x4C/0x4D/0x50/0x57/0x5C/0x5D
uint16_t pad; // = 0x0000
uint32_t arg; // 4-byte LE argument
};Use: control commands (CMD6 SWITCH, CMD13 STATUS,
CMD23 SET_BLOCK_COUNT, commit/finalize, etc.)
struct EP1_Cmd_B { // send + larger recv
uint8_t top_opcode; // = 0x08
uint8_t sub_opcode; // 0x48 = read 512-byte block
uint16_t length; // expected reply length (e.g. 0x0200 = 512)
uint32_t arg; // address / parameter
};Use: requests with variable reply (CMD8 SEND_EXT_CSD reads 512
bytes via this path).
EP1 OUT: 16-byte setup, top-opcode = 0x14
EP2 OUT: N × 512 bytes of payload (JEDEC RPMB frames or plain CMD25 blocks)
Use: CMD25 WRITE_MULTIPLE_BLOCK, RPMB writes.
struct EP1_BulkRead_Setup { // 16 bytes
uint32_t magic_and_op; // = 0x02000015 (LE: bytes [15 00 00 02])
// top-opcode 0x02, sub-byte 0x15(?)
uint32_t reserved; // = 0
uint16_t count; // number of 512-byte blocks
uint16_t block_size; // = 0x0200
uint16_t padding; // = 0
};
// → send 16 bytes on EP1 OUT (pipe 1)
// → recv (count * 512 + 16) bytes on EP1 IN (pipe 0x81): 16-byte header + bulk dataUse: CMD18 READ_MULTIPLE_BLOCK. Note that bulk read data comes
back on EP1 IN (not EP2 IN).
The 32-bit arg for sub-opcode 0x46, stored in little-endian by the
wrapper, reads as the JEDEC eMMC CMD6 argument in big-endian:
arg in LE memory BE byte view JEDEC CMD6 SWITCH argument
───────────────── ───────────────── ─────────────────────────────
[B3 B2 B1 B0]
Access | Index | Value | CmdSet
Access ∈ {0x01 Set-Bits, 0x02 Clear-Bits, 0x03 Write-Byte},
Index ∈ ECSD field index (0–255), Value is the byte to write.
Commands decoded from Xgpro.exe:
arg (LE) |
BE bytes | JEDEC ECSD field | Semantics |
|---|---|---|---|
0x01B30300 |
01 B3 03 00 |
[179] PARTITION_CONFIG, Set-Bits, Value 0x03 |
Switch to RPMB |
0x02B30700 |
02 B3 07 00 |
[179] PARTITION_CONFIG, Clear-Bits, Value 0x07 |
Switch back to USER |
0x01AF0100 |
01 AF 01 00 |
[175] HS_TIMING, Set-Bits, Value 0x01 |
Switch to HS-200 (init step) |
0x02FFFF00 |
02 FF FF 00 |
? | early-init reset (TBD) |
So CMD6 SWITCH to any partition (BOOT1/BOOT2/RPMB/USER/GPP1–4) is
simply sending a Format A packet with sub_opcode = 0x46 and an arg
that holds the BE-encoded JEDEC SWITCH argument.
PARTITION_ACCESS field (bits [2:0] of ECSD index 179):
| Value | Partition |
|---|---|
0b000 |
USER (default) |
0b001 |
BOOT1 |
0b010 |
BOOT2 |
0b011 |
RPMB |
0b100..0b111 |
GPP1..GPP4 |
Inside 0x492670, the 512-byte buffer that is sent on EP2 is built as
a JEDEC-compatible RPMB frame, with exact field offsets:
| Offset | Size | Purpose | Matches JEDEC RPMB |
|---|---|---|---|
0x000..0x0C4 |
196 | Stuff bytes | ✓ stuff bytes |
0x0C4..0x0E4 |
32 | Key/MAC (selected from internal table 0x79A690 or 0x7C8048) |
✓ Authentication Key / MAC |
0x0E4..0x1E4 |
256 | Data | ✓ Data |
0x1E4..0x1F4 |
16 | Random Nonce (rand(), optional) |
✓ Nonce |
0x1F4..0x1F8 |
4 BE | Write Counter | ✓ Write Counter |
0x1F8..0x1FA |
2 | Address | ✓ Address |
0x1FA..0x1FC |
2 | Block Count | ✓ Block Count |
0x1FC..0x1FE |
2 | Result | ✓ Result |
0x1FE..0x200 |
2 | Request / Response | ✓ Request/Response |
Transmission sequence:
- EP1 OUT: 16-byte setup packet with top-opcode
0x14(length0x10). - EP2 OUT (
WinUsb_WritePipe(pipe=2)): N × 512 bytes = RPMB frames.
Flags arg28 / arg2C of the wrapper toggle nonce and key inclusion, so
the same function also serves plain CMD25 writes (without the RPMB
key/MAC/nonce wrapping).
// 16-byte setup, magic 0x02 / sub-byte 0x15:
struct EP1_BulkRead_Setup {
uint32_t magic_and_op; // = 0x02000015
uint32_t reserved; // = 0
uint16_t count; // number of 512-byte blocks
uint16_t block_size; // = 0x0200
uint16_t padding; // = 0
};
// → 16 bytes on EP1 OUT (pipe 1)
// → (count * 512 + 16) bytes on EP1 IN (pipe 0x81): 16-byte header + bulk dataNotable: EP2 IN does not appear to be used by Xgpro for eMMC. All bulk reads come back through EP1 IN.
// 0x4dc380 — sub_send(handle, buf, len)
// → WinUsb_WritePipe(h, PipeID=1, buf, len, &transferred, NULL)
// EP1 OUT (pipe ID 1)
// 0x4dc300 — sub_recv(handle, buf, len)
// → WinUsb_ReadPipe(h, PipeID=0x81, buf, len, &transferred, NULL)
// EP1 IN (pipe ID 0x81)For bulk writes the wrapper calls WinUsb_WritePipe with PipeID=2
directly (EP2 OUT).
Layer 3 — semantic eMMC commands:
0x4af370 eMMC init sub-step (calls 0x492f30 twice with sub-op 0x46)
0x4acee0 CMD12 + CMD13 (sub-op 0x4C)
0x4ad240 CMD13 alt path (sub-ops 0x57, 0x50)
0x49d910 CMD18/25 BGA-socket path
0x4a98f0 CMD18/25 ISP path (← our case! uses 0x492670)
0x4a8110 bulk-read with watchdog
Layer 2 — USB-command builders:
0x492f30 8-byte EP1 command, top-opcode 0x27 (Format A, 101 call sites)
0x492670 bulk: 16-byte EP1 setup + N×512 on EP2 (Format C, RPMB-aware)
0x492590 bulk-read: 16-byte EP1 setup + EP1 IN read (Format D)
0x492900 8-byte EP1 command, top-opcode 0x08 (Format B, 0x48 = read 512 B)
0x4dc070 composite EP1 transaction wrapper (10 WritePipe call sites)
Layer 1 — raw USB:
0x4dc380 sub_send → WinUsb_WritePipe(EP1)
0x4dc300 sub_recv → WinUsb_ReadPipe(EP1 IN)
0x633e6c stub for WinUsb_WritePipe (called with PipeID 1 or 2)
0x633e66 stub for WinUsb_ReadPipe
| JEDEC CMD | Function | Wrapper(s) | top/sub-op | arg32 |
|---|---|---|---|---|
| init (CMD0/1/?) | 0x4af370 | 0x492f30 |
0x27/0x46 | 0x02FFFF00 then 0x01AF0100 (two packets) |
| CMD12 + CMD13 | 0x4acee0 | 0x492f30 |
0x27/0x4C | 0 |
| CMD13 (alt) | 0x4ad240 | 0x492f30 |
0x27/0x57, 0x27/0x50 | 1, then variable |
| CMD8 EXT_CSD | 0x4a1130 | 0x492900 |
0x08/0x48 | length=0x0200, arg=0 |
| CMD18 ISP | 0x4a98f0 | 0x492590 + 0x492670 |
0x02/0x15 | block_count |
| CMD25 ISP write | 0x4a98f0 | 0x492670 |
0x14/… | + EP2 OUT payload (RPMB frame layout) |
| init handshake | 0x4af370 | raw 0x4dc380/0x4dc300 |
0x21, 0x05, 0x06 | recv 8/32/24 bytes respectively |
The Xgpro algorithm files (xgecu/algorithm/*.alg) contain compressed
FPGA bitstreams. The format is fully reverse-engineered and the
tools/extract_alg.py script in this repository round-trips every
shipped .alg correctly.
0x000 char[] algorithm-family name, ASCII, \0-terminated
(e.g. "EMMC211210", "EMMC18", "SPI-18", "AT45DB")
0x000..0x220 — header padded with zeros (some families
keep sparse parameters here)
0x220 u32 LE decompressed bitstream size = 340604
(constant for every shipped .alg — implies
a single FPGA, ≈ Xilinx Spartan-6 LX9 class)
0x224 u32 LE CRC32 of the compressed data
= (zlib.crc32(comp) XOR 0xFFFFFFFF)
0x228..end … compressed bitstream, zero-RLE over 16-bit words:
read u16 val;
if val != 0: emit val
else: read u16 len; emit `len` zero-words
The decompressed payload starts with the standard FPGA bitstream
preamble: 16 × 0xFF dummy bytes followed by the sync word
AA 99 55 66 (canonical Xilinx Spartan-6 sync).
The chip database (InfoIC2Plus.dll) stores per-chip variants like
0x5300, 0x4100, etc. The high byte of variant is the
algorithm number that gets substituted into the algorithm filename:
variant high byte |
Name pattern | .alg file |
Mode |
|---|---|---|---|
0x53 |
_8Bit @BGA153 |
EMMC_53_{18,33}.alg |
BGA socket, 8-bit bus |
0x54 |
_4Bit @BGA153 |
EMMC_54_{18,33}.alg |
BGA socket, 4-bit bus |
0x51 |
_1Bit @BGA153 |
EMMC_51_{18,33}.alg |
BGA socket, 1-bit bus |
0x44 |
(ISP) _4Bit |
EMMC_44_{18,33}.alg |
ISP 4-bit |
0x41 |
(ISP) _1Bit |
EMMC_41_{18,33}.alg |
ISP 1-bit |
The _18 / _33 suffix is VCCQ = 1.8 V / 3.3 V.
InfoIC2Plus.dll ships with Xgpro and contains the per-chip
parameters consumed by BEGIN_TRANSACTION. The dumpic/dump-infoic2plus-dll.c
utility in minipro extracts it to JSON.
For the Xgpro version we examined, the database contains:
- 173 manufacturers, 34 352 chip entries in total.
- 4 796 eMMC entries (chip type
7), all withprotocol_id = 0x31.
BOOT1 last page : buffer 0x10000-0x13FFF (16 KB), device 0x1FC50000-0x1FC53FFF
BOOT2 last page : buffer 0x30000-0x33FFF (16 KB), device 0x1FC70000-0x1FC73FFF
The device-side addresses depend on the actual eMMC capacity; the buffer-side addresses are what Xgpro uses internally.
0. begin_transaction (top-op 0x03) with protocol_id = 0x31 and
variant for the target chip (e.g. 0x4100 for ISP 1-bit)
1. eMMC init: CMD0 → CMD1 (OCR) → CMD2 (CID) → CMD3 → CMD7
Implemented in Xgpro through a sequence of 0x21/0x05/0x06 raw
commands + CMD6 SetBits HS_TIMING=1
2. EP1 cmd Format B: top-op=0x08, sub-op=0x48, length=0x200, arg=0
→ recv 512-byte EXT_CSD
1. CMD6 SWITCH: sub-op=0x46, arg = encode(SetBits, 0xB3=PARTITION_CONFIG, Value=0x01)
2. Iterate over blocks: EP1 cmd Format B with sub-op=0x48, length=0x200,
arg=block_address, OR bulk-read via 0x492590
3. CMD6 SWITCH back: sub-op=0x46, arg = encode(ClearBits, 0xB3, 0x07)
1. CMD18 setup via 0x492590 (16-byte EP1 OUT setup) with N = block count
2. Receive N × 512 bytes on EP1 IN (with 16-byte response header)
3. Optionally CMD12 STOP (sub-op 0x4C)
- Exact semantics of every sub-opcode (USB capture will validate the proposed mapping).
- Final word on the 16-byte setup-packet payload for Format D
(the
0x02000015magic is confirmed; the trailing fields are inferred). - Reply framing on EP1 — observed reply sizes are 8 / 24 / 32 /
count*512+16; the field layouts are not yet decoded. - How Xgpro learns the FPGA is ready to deliver bulk data on EP1
IN after
CMD18(the trigger / handshake). - Adapter-authentication pass-through: if the PC software ever relays the auth bytes between the genuine eMMC-ISP adapter and the T48 firmware (we expect not — see §18).
- Full opcode table in the
0x40..0x60range (there may be unused sub-opcodes that Xgpro doesn't exercise for eMMC).
The genuine "XGecu EMMC-ISP VER 1.00" adapter contains a secure authentication IC (likely an Atmel/Microchip ATSHA204A or similar). Per the manufacturer's product page the chip is anti-clone protection; the adapter "cannot be DIY" and is locked to the T48 model only (not the older TL866 family, and not the T56).
The relevant fact for this project: the adapter authenticates to the T48's firmware, not to the PC software. Our PC code therefore does not need to break the crypto — it just issues the documented USB commands, and the T48 firmware itself runs the challenge/response with the adapter using a key shared between them (likely sealed inside both parts of the system).
If a future USB capture turns out to show the PC relaying auth bytes, that would be plain pass-through (no key knowledge needed on the host).
✅ minipro 0.7.4 built and confirmed to run
✅ Chip database dumped (4 796 eMMC entries)
✅ .alg format fully reverse-engineered and verified (unpacker
round-trips every file)
✅ USB identity, endpoint map, wrapper hierarchy established
✅ Format A 8-byte EP1 packet documented
✅ eMMC sub-opcode list extracted, 6 of 7 mapped to JEDEC semantics
✅ CMD6 SWITCH argument encoding fully decoded
✅ RPMB frame layout (Format C internals) fully decoded
✅ Ghidra-verified wire-format of all four packet formats (§19 below)
⏳ Final byte-level validation of every opcode (needs a USB capture
from a real T48)
After importing Xgpro.exe into Ghidra 12 and decompiling the key wrappers
the previous sections had inferred from capstone-level assembly, the following
details are now confirmed verbatim from the C-like decompilation. Numbers
in parentheses are Ghidra's auto-named function symbols (e.g. FUN_004dc380).
void raw_send(handle, buf, len) {
WinUsb_WritePipe(handle, /*PipeID=*/1, buf, len, &transferred, NULL);
}Always pipe 1. Confirms our EP1_OUT = 0x01.
int raw_recv(handle, buf, len) {
int slot = handle_to_slot(handle); // 4 handles supported
int chip_type = chip_type_table[slot * 0xEC];
int actual_len = len;
if (chip_type == 6) // NAND
actual_len = len + 1; // NAND needs an extra byte
int rc = WinUsb_ReadPipe(handle, /*PipeID=*/0x81, buf, actual_len, ...);
return rc ? transferred : -1;
}Always pipe 0x81 (EP1 IN). For NAND the host requests len + 1 bytes.
void cmd_A(handle, byte sub_op, dword arg, char* reply_buf) {
uint8_t pkt[8];
pkt[0] = 0x27;
pkt[1] = sub_op;
*(u16*)&pkt[2] = 0;
*(u32*)&pkt[4] = arg;
if (raw_send(handle, pkt, 8) != 0) { // EP1 OUT
if (raw_recv(handle, reply_buf, 8) != -1) { // EP1 IN, 8 bytes
if (reply_buf[1] != 0) { /* success */ }
}
}
}Confirmed: 8-byte command, 8-byte reply on EP1 IN. reply_buf[1] != 0
appears to be the success indicator.
void cmd_B(handle, byte sub_op, ushort length, dword arg, char* reply_buf) {
uint8_t pkt[8];
pkt[0] = 0x08;
pkt[1] = sub_op;
*(u16*)&pkt[2] = length;
*(u32*)&pkt[4] = arg;
if (raw_send(handle, pkt, 8) != 0) { // EP1 OUT
int slot = handle_to_slot(handle);
int chip_type = chip_type_table[slot * 0xEC];
if (chip_type != 7 && chip_type != 8) { // not eMMC, not VGA
raw_recv(handle, reply_buf, length + 8); // EP1 IN
} else {
WinUsb_ReadPipe(handle, /*PipeID=*/0x82, // EP2 IN!
reply_buf, length + 8, ...);
}
}
}Critical correction: for eMMC the Format B response arrives on EP2 IN
(pipe 0x82), not EP1 IN. The total reply size is length + 8. Our
Python prototype has been updated accordingly.
The 16-byte EP1 setup packet is:
struct {
uint8_t top_op; // = 0x14
uint8_t sub_op; // = param_8
uint16_t pad; // = 0
uint32_t pad2; // = 0
uint16_t count; // = param_6 (number of 512-byte sectors)
uint16_t block_size; // = 0x0200
uint16_t pad3; // = 0
};The 512-byte payload buffer (passed in param_2) is patched in-place to embed
the JEDEC-RPMB-style trailer in its last 12 bytes plus optional 32-byte key
and 16-byte nonce:
| Offset | Size | Field | Value |
|---|---|---|---|
0x0C4..0x0E4 |
32 | Key/MAC | from table DAT_0079A690 (param_10=1) or DAT_007C8048 (param_10=2) |
0x1E4..0x1F4 |
16 | Nonce | rand() × 16 if param_9 != 0 |
0x1F4..0x1F8 |
4 BE | Write counter | param_4 big-endian |
0x1F8..0x1FA |
2 | param_3 | byte 0x1F9 = lo, byte 0x1F8 = hi |
0x1FA..0x1FC |
2 | param_5 | byte 0x1FB = lo, byte 0x1FA = hi |
0x1FC..0x1FE |
2 | zero | 0 |
0x1FE..0x200 |
2 | param_7 | byte 0x1FF = lo, byte 0x1FE = hi |
Transmission per chip-type:
if (chip_type == 7) { // eMMC
raw_send(handle, &setup, 16); // EP1 OUT
WinUsb_WritePipe(handle, /*pipe=*/2, payload, count * 512, ...); // EP2 OUT
}
else if (chip_type == 8) { // VGA
raw_send(handle, &setup, 16); // EP1 OUT
WinUsb_WritePipe(handle, /*pipe=*/5, payload, count * 512, ...); // EP5 OUT (!)
}
else {
// Combined 16-byte setup + 512-byte payload sent together on EP1:
raw_send(handle, /*pointer*/, 0x210); // = 528 bytes on EP1 OUT
}void bulk_read_setup(handle, void* recv_buf, ushort count) {
uint8_t pkt[16];
*(u32*)&pkt[0] = 0x02000015; // magic / top-opcode + sub-byte
*(u32*)&pkt[4] = 0;
*(u16*)&pkt[8] = count;
*(u16*)&pkt[10] = 0x0200;
*(u16*)&pkt[12] = 0;
*(u16*)&pkt[14] = 0;
if (raw_send(handle, pkt, 16) != 0) { // EP1 OUT
int chip_type = ...;
if (chip_type == 7 || chip_type == 8) // eMMC / VGA
FUN_004dbd50(handle, recv_buf, count * 512 + 16); // (TBD endpoint)
else
raw_recv(handle, recv_buf, count * 512 + 16); // EP1 IN
}
}For eMMC bulk reads the wrapper calls a separate helper FUN_004dbd50 —
this one likely reads from a different pipe (EP2 IN is the natural candidate,
given the Format B precedent). Decompiling FUN_004dbd50 is the next step.
This wrapper handles transfers that don't fit a single endpoint. Per chip-type:
if (chip_type == 6) { // NAND
WinUsb_WritePipe(handle, 1, buf, 8, ...);
WinUsb_WritePipe(handle, 1, buf + 8, len - 7, ...); // split on EP1
}
else if (chip_type == 7) { // eMMC
WinUsb_WritePipe(handle, 1, buf, 8, ...); // setup on EP1
WinUsb_WritePipe(handle, 2, buf + 8, len - 8, ...); // payload on EP2
}
else if (len > 0x40) { // large transfer, asynchronous
// Setup + two parallel payloads using OVERLAPPED + WaitForSingleObject:
WinUsb_WritePipe(handle, 1, buf, 8, ...);
WinUsb_WritePipe(handle, 2, buf + 8, half_a, ...); // EP2
WinUsb_WritePipe(handle, 3, buf + 8 + a, half_b, ...); // EP3 (!)
}So Xgpro can stripe one logical command across EP1 / EP2 / EP3 simultaneously
for big payloads; the host waits on WaitForSingleObject for both EP2 and EP3
to drain. Not relevant for the eMMC ISP path we target, but worth knowing.
// Step 1 — top-opcode 0x21:
buf[0] = 0x21;
buf[1] = DAT_007485d0; // 1-byte parameter (set at config time)
raw_send(handle, buf, 8);
raw_recv(handle, reply, 8);
// Step 2 — top-opcode 0x05 (READID):
buf[0] = 0x05;
*(u16*)&buf[2] = DAT_007a39cc; // 2-byte parameter
*(u32*)&buf[4] = 0;
raw_send(handle, buf, 8);
raw_recv(handle, reply, 0x20); // 32 bytes ← chip ID / OCR / CID payload
// Step 3 — top-opcode 0x06 (READ_USER):
buf[0] = 6;
raw_send(handle, buf, 8);
raw_recv(handle, reply, 0x18); // 24 bytes ← config / CSD payloadThe bytes of the reply at offset 0..1 carry a status code: the function
takes the success branch when reply[1] != 0, which matches the Format A
success convention.
The user-facing "Read ECSD" code (entry at FUN_004a1130) issues three
back-to-back Format B reads, all with the same parameters:
FUN_00492900(handle, /*sub_op=*/0x48, /*length=*/0x200, /*arg=*/0, recv_buf);
FUN_00492900(handle, /*sub_op=*/0x48, /*length=*/0x200, /*arg=*/0, recv_buf);
FUN_00492900(handle, /*sub_op=*/0x48, /*length=*/0x200, /*arg=*/0, recv_buf);This is likely a triple-read for verification (compare three copies of EXT_CSD; if they all match the reader is happy). It's a defensive practice on noisy long lines, common in production-grade eMMC tooling.
The same read_ECSD function unambiguously decodes the bytes returned for
each of the three init opcodes. The reply buffer is read at fixed offsets:
8-byte reply to opcode 0x21 (INIT_EMMC)
──────────────────────────────────────
offset 1 : byte status (0 = success)
offset 4..8: u32 OCR register (JEDEC eMMC, raw)
bit 30 = high-capacity flag, bits [23:8] should be 0xFF8080 for 1.8 V
32-byte reply to opcode 0x05 (READID for CID)
─────────────────────────────────────────────
offset 1 : byte status (0 = OK)
offset 2..4: u16 ?
offset 4..8: u32 ?
offset 8..0x18: 16 bytes = CID register (JEDEC eMMC, 128-bit unique ID)
24-byte reply to opcode 0x06 (READ_USER for CSD)
────────────────────────────────────────────────
offset 1 : byte status (0 = OK)
offset 8..0x18: 16 bytes = CSD register (JEDEC eMMC, 128-bit Card-Specific Data)
Both CID and CSD are copied into the global buffer DAT_007a4034:
CID at offset 0, CSD at offset 0x10. Higher-level code reads from these
offsets directly. For chip-types other than eMMC the bytes are stored
in reverse byte order, but for eMMC (chip_type 7) they go in verbatim.
void bulk_read_emmc(handle, buf, size) {
int slot = handle_to_slot(handle);
char chip_type = chip_type_table[slot * 0xEC];
if (chip_type == 6) { // NAND
raw_recv(handle, buf, size); // EP1 IN
return;
}
if (chip_type == 7 || chip_type == 8) { // eMMC or VGA
WinUsb_ReadPipe(handle, /*pipe=*/0x82, buf, size, ...); // EP2 IN
return;
}
// Other chip types — possibly split into parallel reads:
if (size >= 0x41) {
size /= 2;
WinUsb_ReadPipe(handle, /*pipe=*/0x82, buf, size, ...); // EP2 IN
WinUsb_ReadPipe(handle, /*pipe=*/0x83, buf + size, size, ...); // EP3 IN (!)
WaitForSingleObject(event, 5000);
} else {
WinUsb_ReadPipe(handle, /*pipe=*/0x82, buf, size, ...); // EP2 IN
}
}So eMMC bulk reads always come back on EP2 IN (pipe 0x82), and for non-eMMC big reads Xgpro can stripe across EP2 IN + EP3 IN in parallel.
int set_pipe_policies(handle, ULONG timeout_ms /* stack arg */) {
WinUsb_SetPipePolicy(handle, 0x81, /*PIPE_TRANSFER_TIMEOUT=*/3, 4, &timeout_ms);
WinUsb_SetPipePolicy(handle, 0x82, 3, 4, &timeout_ms);
WinUsb_SetPipePolicy(handle, 0x83, 3, 4, &timeout_ms);
return 1;
}Sets the per-pipe transfer timeout on all three IN pipes (0x81/0x82/0x83).
Confirms that EP3 IN really exists in the device descriptor.
The high-level eMMC dispatcher FUN_004c9110 (≈ 1700 decompiled lines)
contains four calls to FUN_004af370 (the init sub-step) and only one
raw FUN_004dc380 call — no top-opcode 0x03 anywhere in the eMMC
read/write/RPMB flows. This means:
- For eMMC, our own software does not need to send a 64-byte
BEGIN_TRANSACTIONpacket at all. - The role that
BEGIN_TRANSACTIONplays for classic chips (selecting the per-chip algorithm and configuring voltages) is fulfilled in eMMC by opcode0x21plus the 1-byte parameter loaded fromDAT_007485d0(which Xgpro sets earlier based on the user'svariantchoice). - Practical consequence: the start-up sequence in the prototype simplifies
to
connect()→ opcode0x21(+ param) → opcode0x05→ opcode0x06→ CMD6 SWITCH HS-200, with noBEGIN_TRANSACTIONstep.
| Pipe ID | Direction | Role |
|---|---|---|
0x01 |
EP1 OUT | All commands; combined 528-byte transfer for non-eMMC |
0x02 |
EP2 OUT | Bulk-write payload for eMMC |
0x03 |
EP3 OUT | Parallel half of large non-eMMC writes |
0x05 |
EP5 OUT | VGA-only bulk-write |
0x81 |
EP1 IN | All short responses (8/24/32 bytes) |
0x82 |
EP2 IN | Format B responses for eMMC; bulk-read for eMMC + VGA |
0x83 |
EP3 IN | Parallel half of large non-eMMC reads |
For the eMMC ISP path only EP1 OUT/IN and EP2 OUT/IN are needed.
FUN_004bb4d0 (download_algo) implements an FPGA bitstream upload
protocol used by the VGA path (chip_type 8), and possibly by other
"download from PC" cases. The packet structure is three sub-stages:
| Packet (LE bytes) | Meaning |
|---|---|
26 00 00 20 ...... |
Init download (size in param_4) |
26 01 ss ss ...... |
Stream chunk (0x1F8 bytes each) |
26 02 ss ss ...... |
Wait for DONE flag, get error code in reply byte 2 |
This opcode is not used by the eMMC paths (eMMC algorithms are stored
on the programmer board and selected with opcode 0x21).
In §19.13 I claimed BEGIN_TRANSACTION (top-opcode 0x03) is never sent
for the eMMC path. That conclusion was wrong: I had only inspected the
top-level dispatcher FUN_004c9110. The 64-byte BEGIN_TRANSACTION
packet is sent one level down, inside FUN_00444bc0 (pin_detect_pass),
which is called from the dispatcher early in every eMMC operation.
So the correct picture for an eMMC session is:
1. user-level "select chip" UI:
FUN_004edaa0 copies the chip-DB record (Ic_100) into globals
DAT_007a39xx (protocol_id, variant, voltages, sizes, …)
2. operation start (Read / Write / Verify / etc.):
FUN_00444bc0 (pin_detect_pass) is entered first:
a. assemble the 64-byte BEGIN_TRANSACTION packet from globals
b. raw_send(handle, packet, 0x40) ← EP1 OUT
c. raw_send(handle, [0x39,0,0,0,0,0,0,0], 8) ← REQUEST_STATUS
d. raw_recv(handle, status_buf, 0x20)
e. if (status_buf[12] & 0x01) { ← OVC tripped
display "OverCurrent Protection !"
raw_send(handle, [0x04,0x01,0,…], 8) ← END_TRANS
MessageBeep; abort.
}
3. continue with the actual eMMC flow (opcode 0x21 init, …)
The bytes are assembled in FUN_00444bc0 from the global block
DAT_007a39xx, which FUN_004edaa0 had populated from the chip's
Ic_100 database record. The mapping (recovered from the
decompilation; offsets in the database record taken from the dumper
in minipro/dumpic/dump-infoic2plus-dll.c):
| Pkt offset | Source (DAT_007a…) |
Source in Ic_100 |
Field meaning |
|---|---|---|---|
0x00 |
(literal 0x03) |
— | top-opcode BEGIN_TRANS |
0x01 |
DAT_007a3978 |
protocol_id (off 0) |
0x31 for eMMC |
0x02 |
DAT_007a39a8 |
variant low (off 0x34) |
algorithm variant low byte |
0x03 |
DAT_007a3ba6 |
(extra flags) | per-mode flags |
0x04..6 |
DAT_007a39ac (u16) |
(off 0x3c) | data_memory_size |
0x06 |
DAT_007a39b4 |
(off 0x44) | pin_map / chip_info |
0x07 |
DAT_007a397b |
(off 4) | extra flags |
0x08..a |
DAT_007a39ac (u16) |
data_memory_size | (also at 0x04 — Xgpro quirk) |
0x0a..c |
DAT_007a39c0 (u16) |
(off 0x54) | data_memory2_size |
0x0c..e |
DAT_007a39c4 (u16) |
(off 0x58) | page_size |
0x0e..10 |
DAT_007a39b0 (u16) |
(off 0x40) | pulse_delay |
0x10..14 |
DAT_007a397c (u32) |
code_memory_size (0x38) |
code_memory_size |
0x14..18 |
mixed (per chip_type) | voltage encoding | raw_voltages / variant |
0x18..1c |
DAT_00904e88 (u32) |
(UI / mode globals) | mode flags |
0x1c..20 |
DAT_00904e8c (u32) |
mode flags | |
0x20..24 |
DAT_00904e90 (u32) |
mode flags | |
0x24..28 |
DAT_00904e94 |
or DAT_0080187b (eMMC ver. 0x05) |
algorithm sub-mode |
0x28..2c |
DAT_007a39d4 (u32) |
||
0x2c..30 |
DAT_007a39b6 (u32) |
||
0x30..32 |
DAT_007a3ba4 (u16) |
||
0x32..34 |
DAT_007a39be (u16) |
||
0x34..38 |
DAT_007a39d0 (i32) |
||
0x38..3c |
DAT_007a39d8 (u32) |
||
0x3c..3f |
0 | padding | |
0x3f |
DAT_007a39a9 |
The mapping isn't 100% literal because FUN_00444bc0 has many if
branches by DAT_007a3978 (chip_type) that swap some fields. The
table above is the eMMC (0x31) branch. Practical implication
for our prototype: every field above is reachable from the dumped
emmc_chips_t48.json, so the packet can be assembled offline for any
of the 4 796 chips.
For eMMC, the voltage is baked into the BEGIN_TRANSACTION packet — the
programmer doesn't take a separate SET_VCC_VOLTAGE command. The
relevant Xgpro UI strings show three discrete VCCQ choices, all
combined with a fixed VCC = 3.0 V:
"VCC=3.0V VCCQ=1.2V"
"VCC=3.0V VCCQ=1.8V"
"VCC=3.0V VCCQ=3.0V"
Plus a fine-trim:
"VCCQ + 0.0V" / "VCCQ + 0.1V" / "VCCQ + 0.2V" / "VCCQ + 0.3V"
These choices feed into the variant of the chip selected in the UI
(low byte of variant → pkt byte 0x02). For example
variant = 0x4100 → ISP 1-bit / 1.8 V; variant = 0x4133 → ISP 1-bit / 3.3 V
(0x33 = '3', ASCII for the voltage suffix in our .alg file naming).
So switching VCCQ is a matter of picking the right chip variant in the
database before issuing BEGIN_TRANSACTION — no explicit voltage opcode
is needed for eMMC. (For classic chips the SET_VCC_VOLTAGE opcode
0x1B and SET_VPP_VOLTAGE 0x1C are used; minipro's
t48_set_vcc_voltage() is the reference.)
Match between minipro t48_get_ovc_status and Xgpro FUN_00444bc0:
| Step | Bytes (LE) | Meaning |
|---|---|---|
| 1 | 39 00 00 00 00 00 00 00 |
send REQUEST_STATUS (8 bytes EP1 OUT) |
| 2 | recv 32 bytes EP1 IN | status block |
| 3 | reply[12] & 0x01 |
overcurrent flag (1 = tripped, 0 = OK) |
Other useful fields in the 32-byte status reply (minipro mapping):
reply[0]— error code (last operation)reply[2..4]— counterc1(LE u16)reply[4..6]— counterc2(LE u16)reply[8..12]— verify-write address (LE u32)reply[12]— OVC byte
On OVC trip, Xgpro sends an END_TRANSACTION with byte 1 = 0x01:
04 01 00 00 00 00 00 00
and shows "OverCurrent Protection !" + a system beep.
Pin detection is part of the same FUN_00444bc0 flow — there is no
separate "pin test" opcode like minipro's T48_READ_PINS = 0x35. The
BEGIN_TRANSACTION packet itself contains pin-direction / pull-up /
pull-down information (it was assembled from chip-specific
pin_map/package_details), and the programmer reports the result
through the same status field set as OVC. The strings
"Pin Detected ERROR!", "Pin Detected Passed.",
"Bad PINs Connection." are formatted from the 32-byte status reply,
but the exact bits we still need to confirm with a USB capture.
For our purpose the practical algorithm is:
emmc.begin_session_with_ovc_check(packet64) # uses BEGIN_TRANS + REQUEST_STATUS
if result['ovc']:
abort("OverCurrent: bad connection or short")
# pin-status bits live elsewhere in the 32-byte reply; TBD bit positions
# but we already have the bytes — diffing OK-vs-bad-pin captures will fix
# the mapping.FUN_004dbd00 (§19.12) configures PIPE_TRANSFER_TIMEOUT on 0x81 / 0x82 / 0x83
before long operations (eMMC EXT_CSD read uses 5 000 ms timeout, normal
reads use 50 000 ms). Always set this before a long-running session.
I previously sketched "VCC = 3.0 V (fixed)" for eMMC. That's only how the Xgpro UI labels the three discrete VCCQ presets for eMMC operations — the underlying T48 hardware is far more capable.
| Rail | Steps | Range (V) | Step (typ.) | API in classic minipro |
|---|---|---|---|---|
| VCC | 64 | 1.74 .. 6.86 | ~0.08 V | t48_set_vcc_voltage(index 0..63) |
| VPP | 64 | 9.31 .. 25.16 | ~0.25 V | t48_set_vpp_voltage(index 0..63) |
| VCCIO | 5 | 2.35, 2.47, 2.93, 3.23, 3.45 | — | t48_set_vccio_voltage(index 0..4) |
| VUSB | — | (measure only) | — | reported by MEASURE_VOLTAGES |
The exact tables (VCC_MAP, VPP_MAP, VCCIO_MAP) are reproduced in
examples/t48_emmc.py.
SET_VCC voltage:
msg[0]=0x2E (T48_SET_VCC_PIN), msg[0x10]=J13/J14 enable bits,
msg[0x14]=0 (DAC hold), msg[0x16]=vcc_index (1..63)
→ send 48 bytes EP1 OUT.
SET_VPP voltage (programming voltage):
msg[0]=0x2F (T48_SET_VPP_PIN), msg[1]=0x01 (sub-cmd "set VPP"),
msg[8]=vpp_index (0..63)
→ send 48 bytes EP1 OUT.
SET_VCCIO voltage (5-step IO voltage):
msg[0]=0x2F, msg[1]=0x02 (sub-cmd "set VCCIO"), msg[8]=vccio_index (0..4)
→ send 48 bytes EP1 OUT.
MEASURE_VOLTAGES (opcode 0x33):
msg[0]=0x33, zero-padded to 16 bytes → send EP1 OUT (16)
recv 24 bytes EP1 IN, then:
vpp_volts = u16(reply[8]) * 0x0F78 / 0x1000 / 100
vusb_volts = u16(reply[12]) * 0xCCF6 / 0x27000 / 100
vcc_volts = (u16(reply[16]) * 0xB32E / 0x27000 - 0x14) / 100
vccio_volts = u16(reply[20]) * 0x0294 / 0x1000 / 100
This lets the host verify what the DAC is actually outputting, including before connecting a chip. Match against what was requested as a sanity check.
There are two distinct layers of "what voltage is allowed":
A. T48 hardware (the DACs themselves):
| Rail | Steps | Range | Resolution |
|---|---|---|---|
| VCC | 64 | 1.74 .. 6.86 V | ~80 mV |
| VPP | 64 | 9.31 .. 25.16 V | ~250 mV |
| VCCIO | 5 | {2.35, 2.47, 2.93, 3.23, 3.45} V | — |
The DACs are not arbitrary — they're 64-step lookups — but for VCC and VPP the resolution is fine enough to cover ~any sensible target voltage.
B. Xgpro UI / firmware logic (the presets actually offered):
For classic chips the UI menu offers a discrete set of about a dozen
values: 1.20 / 1.80 / 2.50 / 3.00 / 3.30 / 4.00 / 4.50 / 4.75 / 5.00 / 5.25 / 5.50 / 6.00 / 6.25 / 6.50 V, each combinable with a ±0.3 V
fine-trim in 0.1 V steps. So in practice for a classic chip you can
hit pretty much any voltage your datasheet asks for, in 100 mV steps.
For eMMC the UI is far more restrictive — only the three JEDEC IO classes plus the same fine-trim:
VCC = 3.0 V (fixed)
VCCQ ∈ {1.2 V, 1.8 V, 3.0 V} ← selected via chip's `variant` field
fine-trim: VCC and VCCQ each ±0.3 V in 0.1 V steps
This is not because the hardware can't do other values — it can.
It's because JEDEC defines eMMC IO voltages as discrete classes, and a
real eMMC controller is only specified to behave correctly inside one of
those three windows. Setting VCCQ = 2.5 V would put the chip's I/O
deck in an undefined zone — best case it just refuses commands, worst
case it latches up.
Can we expose arbitrary VCC/VCCQ in our own software?
For the classic-chip path: yes — set_vcc_voltage(0..63) and
set_vpp_voltage(0..63) already give 64-step control directly.
For the eMMC path: don't. The voltages are encoded inside the
64-byte BEGIN_TRANSACTION packet via the chip's variant. Picking
the wrong variant (e.g. the _33 suffix on a 1.8 V chip) destroys the
chip in milliseconds — see §22.6. The right knob to expose to the user
is the chip selection (with VCCQ derived from the chip's datasheet),
not a freeform voltage spinbox.
If you really need an off-class VCCQ — say, characterising chip
behaviour at 2.0 V — do it on a sacrificial part with
set_vcc_voltage() directly, never on the device you care about.
For eMMC sessions Xgpro does not invoke SET_VCC_VOLTAGE or
SET_VPP_VOLTAGE separately. The voltages get encoded into the 64-byte
BEGIN_TRANSACTION packet itself (see §20.2) by way of the chip's
variant field. The UI labels — VCC=3.0V VCCQ={1.2,1.8,3.0}V — are
just the three normal eMMC IO-voltage classes (HS-200 / HS-400 / legacy).
Behind those labels the T48 firmware sets up the VCC DAC and the IO-bank
power independently — fine-trim +0.0..+0.3 V is also exposed in the UI
for stability tuning.
For our own software, this means: for eMMC use begin_transaction(...),
not the discrete VCC/VPP setters. The discrete setters are for the
classic-chip path.
⚠️ This section is the most important one if you're building / testing something against a real eMMC. Setting the wrong VCCQ (e.g. driving 3.3 V into a 1.8 V eMMC) destroys the chip — and possibly the host SoC sharing the same rail — in milliseconds.
- First-power-up of the T48 itself: plug it into the PC with no
chip and no adapter in the ZIF socket. Run Xgpro →
Tools → System Self-checkonce. The selftest sequence (SELFTEST_SET_VCC,SELFTEST_SET_VPP,SELFTEST_SET_GND,SELFTEST_READ_IO) verifies every rail and every pin driver. Do not skip this on a new unit. - Sacrificial eMMC for prototyping: for early USB-protocol experiments, use a cheap dead-phone eMMC on a breakout board, not a working device. Trying things on a $2000 phone PCB is how people brick boot partitions and find out CMD25 wrote the wrong address.
Before issuing any USB command that could energise the target:
- Identify the chip by datasheet, not by guess. Get the manufacturer + part number, look up VCCQ tolerance (1.7–1.95 V for "1.8 V" parts; 2.7–3.6 V for "3.3 V" parts). A 1.8 V eMMC has no meaningful tolerance for 3.3 V.
- Pick the right chip variant in our database
(
emmc_chips_t48.json). For ISP work:_(ISP)_1Bitwith_18suffix → variant0x4100, VCCQ = 1.8 V_(ISP)_1Bitwith_33suffix → variant0x4133, VCCQ = 3.3 V_(ISP)_4Bitwith_18→ variant0x4400, VCCQ = 1.8 V_(ISP)_4Bitwith_33→ variant0x4433, VCCQ = 3.3 V The_18/_33suffix in the.algfile name is the suffix of the variant low byte (0x00= nothing → 1.8 V,0x33='3'→ 3.3 V).
- Wire ISP per the user-guide diagram. Minimum 6 wires:
GND× 2,CLK,CMD,DAT0,VCCQ. Two grounds, not one — the second ground stabilises the CLK return path. CLK series resistor on the board should be removed for high-frequency reliability. RST_nof the eMMC must idle high. Verify with a multimeter thatRST_nreads close toVCCQwith everything powered but idle. If it sits at 0, add a ~1 kΩ pull-up toVCCQper the user guide, otherwise the eMMC never exits reset and the programmer reportsEMMC Init ERROR(best case) or appears dead (worst case).- Stop the host SoC. If the eMMC is soldered to a phone/router/TV PCB, the host CPU will fight the T48 for the eMMC bus the moment power is applied. The user guide's recipe is to ground the host MCU's crystal to stop its clock. Verify the host stays in reset before continuing.
- Genuine adapter only. XGecu's EMMC-ISP VER 1.00 adapter has a
secure-element that the firmware authenticates with. Counterfeit
adapters cause
Adapter not matched, use:errors before any chip-side power is applied, which is also a safety feature.
The order matters. From cold:
- PC ← USB ← T48 (programmer connected to host, but Xgpro / our program not yet running any operation).
- Adapter into ZIF. ZIF lever down.
- Adapter probe → target board ISP points. Probe is not yet powered (the T48 firmware decides when to switch on its rails).
- Target board power-on. This brings
VCCQfrom the target side up to whatever the target board uses (often 1.8 V supplied by the target's own PMIC). Verify with a meter at the probe pins:VCCQ≈ what your chip variant declares. - Open the software / run the prototype. The first command our
program (or Xgpro) sends is the 64-byte
BEGIN_TRANSACTION:- The T48 firmware now configures its IO drivers for the chosen
variant, and immediately follows with
REQUEST_STATUS(opcode0x39) — recv 32 bytes — and checksreply[12] & 0x01. - If
OVCflag is set, the firmware has already de-energised the driver. Our software must then sendEND_TRANSACTIONwith byte 1 =0x01(the Xgpro convention) and abort the session. Do not retry without diagnosing why —External short or IC reverse or incorrect package!is the firmware's three best guesses.
- The T48 firmware now configures its IO drivers for the chosen
variant, and immediately follows with
- Only after a clean
BEGIN_TRANSACTIONdo we issue opcode0x21(eMMC init), then0x05(CID),0x06(CSD), then the CMD6 SWITCH to HS-200 — and only then any user-level read/write.
- Treat every reply as opt-in valid. Status byte at
reply[1](Format A / Format B short reply) is0for OK; non-zero is an error code — abort the operation, don't pretend it succeeded. - Cap operation time with the per-pipe timeout (see §19.12 / §20.6). For ECSD reads use ~5 s, for full-USER reads use minutes (Xgpro uses 50 s per chunk by default).
- Periodic OVC re-check. A clean
BEGIN_TRANSACTIONdoesn't guarantee the rails stay clean. After every CMD25 burst, after every partition switch, before every long bulk-read — sendREQUEST_STATUSand checkreply[12] & 0x01. If it flips to 1, bail out immediately.
1. STOP_AND_STATUS (Format A, sub-op 0x4C, arg=0) // CMD12 stop in-flight transfer
2. SWITCH back to USER access (op 0x46, arg 0x02B30700) // restore default partition view
3. END_TRANSACTION (top-op 0x04, byte1=0) // release programmer
4. Pull the probe off the target *before* powering the target down
5. Power down the target, then close the program / unplug USB
If anything in steps 1–3 fails, still do steps 4–5 in order. Powering down the target while the programmer is still driving signals into it is a common way to latch up the chip.
| Mistake | Result | Prevention |
|---|---|---|
Pick _33 variant for a 1.8 V chip |
Chip destroyed in ms | Verify VCCQ from datasheet before coding the variant. |
| Power the target before plugging the probe | Bus contention spike | Probe first, target power second. |
| Skip the RST_n pull-up | Init fails, sometimes latch-up | 1 kΩ to VCCQ, verified with meter. |
| Run with host SoC still clocking | CRC errors / latch-up | Ground the host MCU crystal pad. |
| Treat OVC as transient and retry | Hard short → damage | Abort, fix wiring, only then retry. |
| Write to RPMB without the right Auth-Key | RPMB permanently bricked | Don't enable RPMB write unless you have the key on file. |
| Disconnect USB mid-operation | eMMC in undefined state | End_TRANSACTION first, then unplug. |
| Use a counterfeit adapter that bypasses auth | All bets are off | Genuine XGecu EMMC-ISP VER 1.00 only. |
Validated against hardware in §31. The op-code sequence below is correct, but the live capture shows
READ_PINSreplies are 16 bytes (6-byte pin bitmask at offset 8), not 40 — see §31.2.
Xgpro's "System Self-check" function (FUN_004532e0) is the safest
possible activation pattern the firmware uses to validate itself.
We can reproduce it offline in our own software to prove the
programmer is healthy without any chip in the socket. The exact
sequence (op-codes and packet sizes from the decompilation):
PRE-CONDITION: no chip, no adapter in the ZIF socket.
1. send 8B [0x2D, 0, 0,…] ; RESET_PIN_DRIVERS
2. download "TestVcc.alg" FPGA bitstream ; via top-op 0x26
(Xgpro reads the .alg from disk and uses FUN_004bb4d0).
3. send 8B [0x2D, 0, 0,…] ; reset after bitstream load
4. VCC test loop:
a. send 32B starting with 0x2E ; SET_VCC_PIN — pick a pin
b. send 8B [0x35, 0,…] ; READ_PINS
c. recv 40B (0x28) — pin readings ; reply[8..48] = per-pin status
d. if any step != 0 → "SELFTEST_SET_VCC cmd error!"
5. VPP test loop:
a. send 32B starting with 0x2F ; SET_VPP_PIN
b. send 8B [0x35,…] ; READ_PINS
c. recv 40B
d. on failure → "SELFTEST_SET_VPP cmd error!"
6. download "TestGnd.alg" FPGA bitstream
7. send 8B [0x2D,…] ; reset
8. GND test loop:
a. send 32B starting with 0x30 ; SET_GND_PIN
b. send 8B [0x35,…]
c. recv 40B
d. on failure → "SELFTEST_SET_GND cmd error!"
9. SET_OUT test:
a. 8B 0x2D
b. 32B 0x36 ; SET_OUT — drive output pins
c. 8B 0x35
d. 40B reply
10. Combined VCC/GND test:
a. 8B 0x2D
b. 32B 0x2E (VCC pin set)
c. 40B 0x30 (GND pin set — note 40-byte form)
d. 8B 0x39 ; REQUEST_STATUS
The pin-drivers test bitstream (TestVcc.alg, TestGnd.alg) shorts
each driver to a known reference inside the FPGA so the host can
verify the actual rail voltages with MEASURE_VOLTAGES and the pin
states with READ_PINS. No chip is energised, so this is the only
USB activity that's strictly safe with nothing in the socket.
The prototype's begin_session_with_ovc_check() already enforces the
pre-flight OVC check. For long sessions, wrap operations like this:
emmc = T48Emmc(); emmc.connect()
try:
result = emmc.begin_session_with_ovc_check(packet64)
if not result['success']:
raise RuntimeError("OVC tripped: " + ovc_diagnosis(result['status']))
info = emmc.init_emmc(algo_param) # CMD0/1/2/3 init
if info['ocr'] is None or info['cid'] is None:
raise RuntimeError("eMMC did not respond cleanly to init")
# Periodic OVC re-check before every long op
for chunk_idx in range(...):
if emmc.check_ovc():
raise RuntimeError("OVC tripped mid-session")
data = emmc.bulk_read(N_SECTORS_PER_CHUNK)
# ...
finally:
try: emmc.stop_and_status()
except Exception: pass
try: emmc.restore_user_access()
except Exception: pass
try: emmc.end_transaction(0)
except Exception: pass
emmc.close()The try / except around every shutdown step ensures the programmer
gets released even if one cleanup step fails — important to avoid
leaving the eMMC bus driven while the host process exits.
FUN_00495060 (the OTP-CSD programming function) builds a 32-byte
raw packet that wraps any JEDEC eMMC CMDxx with its 16-byte data
payload:
struct EP1_RawCmd_0x0A { // 32 bytes total
uint32_t header; // = 0x000A0001 (top-op=0x0A, then 0x01 0x00 0x00)
uint32_t pad0; // = 0
uint32_t size; // = 0x00100000 (= 16 << 16, length of data section?)
uint32_t jedec_cmd; // = the JEDEC eMMC CMD number (e.g. 0x5B for CMD27)
uint8_t data[16]; // CMD argument / payload (e.g. 16 bytes raw CSD)
};
// → send 0x20 bytes EP1 OUT
// → recv 8 bytes EP1 IN (reply[1] = status)Observed use: jedec_cmd = 0x5B (CMD27 PROGRAM_CSD), but the same
wrapper presumably carries any other CMDxx that needs a 16-byte data
companion. Before this raw send, Xgpro warms up with two Format-A
calls: sub-op 0x57 arg=1 (SET_BLOCK_COUNT), then sub-op 0x50
arg=0x10 (data-prep). After the raw send + 8B reply, a final
sub-op 0x50 arg=0x200 commits the write.
If reply[1] of the 8-byte response is non-zero, the chip refused
the command — typically the OTP bit was already set
("WARNING: Set CSD(OTP bit) One Time Programming!").
Used by the self-check sequence to read back the entire 40-pin ZIF state in one shot:
send 8B [0x35, 0, 0, 0, 0, 0, 0, 0] ; EP1 OUT
recv 40B (0x28) ; EP1 IN
// reply[8..48] is the pin status (1 byte per pin, indices map to
// physical ZIF pin numbers — matches minipro's tl866iiplus_pin_test).minipro implements this for the TL866II+ but not for the T48 (the
T48 entry in minipro/src/t48.c leaves pin_test = NULL). The
op-code, packet sizes and reply layout all match, so adding T48
pin-test to minipro is a 30-line patch.
| Top-op | Source | Purpose |
|---|---|---|
0x02 |
minipro classic | NAND_INIT |
0x03 |
minipro / Xgpro | BEGIN_TRANSACTION (64-byte init — used for eMMC too) |
0x04 |
minipro / Xgpro | END_TRANSACTION (8-byte; byte 1 = OVC-abort flag) |
0x05 |
minipro / Xgpro | READID (also used in eMMC init for CID read) |
0x06 |
minipro / Xgpro | READ_USER (also CSD read in eMMC init) |
0x08 |
Xgpro NEW | Long-recv envelope (e.g. sub 0x48 → 512 B read) |
0x0A |
Xgpro NEW | Generic eMMC CMDxx wrapper (32-byte raw packet) |
0x14 |
Xgpro NEW | Bulk-write setup before EP2 OUT |
0x1B |
minipro classic | SET_VCC_VOLTAGE |
0x1C |
minipro classic | SET_VPP_VOLTAGE |
0x21 |
Xgpro NEW | eMMC init / select algorithm |
0x26 |
Xgpro NEW | FPGA bitstream download (incl. TestVcc.alg, TestGnd.alg) |
0x27 |
Xgpro NEW | eMMC sub-command dispatcher |
0x2D |
minipro classic | RESET_PIN_DRIVERS (used by self-check before each phase) |
0x2E |
minipro classic | SET_VCC_PIN (32-byte packet) |
0x2F |
minipro classic | SET_VPP_PIN (sub 1 = VPP voltage; sub 2 = VCCIO) |
0x30 |
minipro classic | SET_GND_PIN (32 or 40-byte packet) |
0x33 |
minipro classic | MEASURE_VOLTAGES |
0x35 |
minipro / Xgpro | READ_PINS (40-byte reply — pin map) |
0x36 |
minipro classic | SET_OUT (drive output pins for self-check) |
0x39 |
minipro / Xgpro | REQUEST_STATUS (32-byte reply incl. OVC@12) |
0x3F |
minipro classic | RESET |
FUN_004afd10 (RPMB read-write-counter + program-key) shows the
mapping between the req arg of FUN_00492670 (Format C bulk
write) and the JEDEC RPMB request codes:
FUN_00492670 req arg |
JEDEC RPMB request | Purpose |
|---|---|---|
1 |
0x0001 PROGRAM_KEY |
Write the 32-byte Authentication Key (one-shot) |
2 |
0x0002 READ_WC |
Read the write counter (one of the gating bits for any RPMB write) |
3 |
0x0003 AUTH_DATA_WRITE |
Authenticated data write (1 sector at a time) |
4 |
0x0004 AUTH_DATA_READ |
Authenticated data read |
5 |
0x0005 READ_RESULT |
Read the response register (status of the last write) |
Two hardcoded key tables live in static data:
| Symbol | Size | What it likely is |
|---|---|---|
DAT_0079A690 |
32 B | Default/factory RPMB authentication key #1 |
DAT_007C8048 |
32 B | Default/factory RPMB authentication key #2 |
FUN_00492670 arg param_10 picks which one (1 → first table,
2 → second table). With param_10 = 0 and param_9 = 0 no key
material is embedded, which corresponds to the READ_WC / READ_RESULT
cases above (those frames don't carry a key).
Operational warning: PROGRAM_KEY is one-shot per chip —
once programmed, the key can never be changed or read back. Running
this flow against a chip you don't own the key for permanently
disables RPMB write on that chip. Our prototype's build_rpmb_frame()
takes key_mac as an explicit parameter precisely so this can't
happen by accident.
1. raw_send Format C with req=1 (PROGRAM_KEY) ; sends the key
2. raw_send Format C with req=5 (READ_RESULT) ; sends "give me the response"
3. raw_recv Format D (count=1) ; reads the 512-byte response
4. parse response: byte 0x1FF & 7 — the JEDEC OPERATION_RESULT:
0 = OK
1 = General failure
2 = Authentication failure
3 = Counter failure
4 = Address failure
5 = Write failure
6 = Read failure
7 = Authentication Key not yet programmed
(The mapping of byte 0x1FF & 7 to the status code comes from the
JEDEC RPMB standard; Xgpro's check if ((bStack_12003 & 7) == 0)
matches "OK".)
Earlier (§19.4, §20.2) I had labelled sub-op 0x4D as "commit / finalize
FPGA". That was wrong. The fifth Ghidra pass found FUN_004929f0 —
the card-status reader called from the erase wait loop — and it sends:
uint8_t pkt[8] = {
0x27, // byte 0 — top-opcode (Format A)
0x4D, // byte 1 — sub-opcode ← CMD13 SEND_STATUS
0x01, 0x00, 0x00, 0x00, 0x01, 0x00, // count + arg
};
raw_send(handle, pkt, 8);
raw_recv(handle, reply, 8);So sub-op 0x4D is CMD13 SEND_STATUS — reads the eMMC card's
32-bit CARD STATUS register. The "Set Password" context that misled me
earlier was just code paths that happen to poll status after writing.
Updated Format A sub-op table:
| Sub-op | Semantics (corrected) |
|---|---|
0x46 |
CMD6 SWITCH |
0x4C |
CMD12 STOP_TRANSMISSION |
0x4D |
CMD13 SEND_STATUS (status read — corrected from "commit") |
0x50 |
Data transfer 512 B (CMD24 / OTP / RPMB-write data phase) |
0x57 |
CMD23 SET_BLOCK_COUNT |
0x5C |
TBD (1 call site) |
0x5D |
Read WP table |
FUN_004b32f0 is the central status decoder — every error string Xgpro
prints when something goes wrong is routed through this function.
Reverse-engineering its switch (param_1 & 0xFF) gives the full
Xgpro error-code map:
reply[0] |
Meaning |
|---|---|
0 |
OK |
1 |
Generic status reply (data in param_2) |
2 |
CRC error (command CRC) |
3 |
CMD1 respond error (init OCR mismatch) |
4 |
CMD1 no response |
5 |
CMDx no response (%.8X codes embedded) |
6 |
eMMC busy |
7 |
"No Data respond" (when bit 25 of param_2 is clear) |
8 |
DataBus CRC error → UI hint "Reduce CLK or switch VCCQ 1.8↔3.3 V" |
9 |
Write CRC error |
10 |
eMMC DAT0 busy (when bit 25 of param_2 is clear) |
0xE1 |
"EMMC stops responding 1" (firmware-level timeout) |
0xE2 |
"EMMC stops responding 2" |
0xE3 |
"EMMC stops responding 3" |
0xEE |
"EMMC stops responding 0" |
The companion param_2 is the JEDEC eMMC CARD STATUS register
(R1 response, 32 bits), with the relevant subfields:
| Bit(s) | Field | Meaning |
|---|---|---|
| 25 | READY_FOR_DATA |
1 = chip will accept the next data |
| 12..9 | CURRENT_STATE |
Card state machine value: |
| 0=IDLE, 1=READY, 2=IDENT, 3=STBY, 4=TRAN, 5=DATA, 6=RCV, 7=PRG, 8=DIS, 9=BTST, 10=SLP |
CURRENT_STATE == TRAN (4) means "ready for next command", which is
why the erase wait loop in §25.3 uses & 0x1E00 == 0x800 (= 4 << 9).
int max_iter = 10000; // hard timeout
int rc = read_card_status(handle, &status_buf); // Format A op 0x4D
while (rc != -1) {
uint32_t card_status = *(uint32_t*)&status_buf[4];
if ((card_status & 0x1E00) == 0x800) { // CURRENT_STATE == TRAN
// erase / write finished, card is ready
break;
}
rc = format_A_cmd(handle, /*sub_op=*/0x4C, /*arg=*/0, &status_buf); // CMD12
if (rc == -1) {
report(" -- CMD12"); // CMD12 itself failed
break;
}
if (--max_iter == 0) {
decode_status(status_buf, " -- Erase Timeout");
break;
}
rc = read_card_status(handle, &status_buf); // re-poll
}Takeaways:
- Use sub-op
0x4Dfor CMD13 SEND_STATUS (NOT 0x39 — that one isREQUEST_STATUSof the programmer itself, which carries OVC at byte 12). - Read the 32-bit eMMC CARD STATUS from
reply[4..8]. - Loop until
CURRENT_STATE == 4 (TRAN), sendCMD12between polls. - 10000 iterations ≈ enough for full-chip erase; abort with "Erase Timeout" otherwise.
byte 0: 0x27 top-opcode (Format A dispatcher)
byte 1: 0x4D sub-opcode = CMD13 SEND_STATUS
byte 2: 0x01 ? (always 1 in Xgpro — possibly "RCA index" or "argument bits 23..16")
byte 3: 0x00
byte 4: 0x00
byte 5: 0x00
byte 6: 0x01 ? (always 1 — possibly "fetch full 32-bit status")
byte 7: 0x00
Reply: 8 bytes, layout matches Format A:
byte 0: error code (see §25.2 table)
byte 1: ?
byte 4..8: 32-bit eMMC CARD STATUS register (LE)
FUN_0049e010 ("Erase Partition" function, 1353 decompiled lines) was
expected to contain a CMD35 → CMD36 → CMD38 sequence. It does not.
The function reads ECSD erase-related fields for display only:
ERASE_GROUP_DEF[175], ERASE_GRP_SIZE, ERASE_GRP_MULT,
SEC_ERASE_MULT, ERASE_TIMEOUT_MULT. The actual "erase" is just
CMD25 WRITE_MULTIPLE_BLOCK with a zero (or 0xFF) pattern.
So for our own software, write-before-erase isn't a separate step:
just program the desired pattern with bulk_write(). Xgpro's UI option
"Erase before programming" is effectively a zero-fill via CMD25.
FUN_00492aa0 (the helper that's called from the erase path) builds a
Format B command with sub-op 0x5E and length=4, and on failure prints
"Write device Error CMD30 request!". JEDEC eMMC CMD30 SEND_WRITE_PROT
returns a 32-bit write-protection bitmap for a given group. So:
| Format | Top-op | Sub-op | Length | Meaning |
|---|---|---|---|---|
| B | 0x08 |
0x5E |
4 B | CMD30 SEND_WRITE_PROT |
The arg holds the LBA of the group being checked, and the 4-byte response on EP2 IN is the JEDEC group-write-protection register.
FUN_004e18d0 (the eMMC pin-fault mask selector) keys off
DAT_007a39a9, which is the high byte of the chip's variant field
in the database, interpreted as an ASCII character:
| Letter | Hex | Bus mode | Adapter type | .alg family |
|---|---|---|---|---|
A |
0x41 | 1-bit | ISP | EMMC_41_… |
D |
0x44 | 4-bit | ISP | EMMC_44_… |
Q |
0x51 | 1-bit | BGA socket | EMMC_51_… |
S |
0x53 | 8-bit | BGA socket | EMMC_53_… |
T |
0x54 | 4-bit | BGA socket | EMMC_54_… |
These letters drive:
- the
.algfile naming (EMMC_<hex>_{18,33}.alg) - the expected-pin bitmask returned by
FUN_004e18d0(so the diagnostic code knows which pins should be live before complaining about which ones are missing)
The bitmasks themselves (32-bit values like 0x0AB8A3FE, 0x0AB8A1E0,
…) encode which of the T48's pin lines are routed to which eMMC ball
for that variant. Useful for our software when diagnosing "Bad Pin On
ISP" reports.
A chip selected from the database with variant high byte 'A' (0x41)
or 'D' (0x44) — i.e. variants 0x41xx / 0x44xx — runs the
ISP code path. Variant low byte still encodes voltage (0x00 =
1.8 V, 0x33 = 3.3 V), so the four canonical ISP variants are:
| variant | mode | algorithm file |
|---|---|---|
0x4100 |
ISP 1-bit, 1.8 V | EMMC_41_18.alg |
0x4133 |
ISP 1-bit, 3.3 V | EMMC_41_33.alg |
0x4400 |
ISP 4-bit, 1.8 V | EMMC_44_18.alg |
0x4433 |
ISP 4-bit, 3.3 V | EMMC_44_33.alg |
FUN_004e18d0 (§26.3) keys off the high byte: 'A' → 0x0AB8A018,
'D' → 0x0AB8A0DC — these are the expected pin bitmasks for each
ISP variant. They are what diagnostic code compares against the actual
status reply to decide which pin failed (vs. just reporting "Bad Pin
On ISP").
FUN_004583f0 (the function near the "Adapter not matched, use:" string)
talks to the adapter over a new top-opcode that wasn't in any earlier
section:
void adapter_cmd(handle, byte param_a, byte param_b) {
uint8_t pkt[8];
*(u16*)&pkt[0] = 0xFF2B; // byte 0 = 0x2B (top-op), byte 1 = 0xFF
raw_send(handle, pkt, 8); // 1st packet: query adapter
*(u16*)&pkt[0] = 0x022B; // byte 0 = 0x2B, byte 1 = 0x02
*(u16*)&pkt[2] = param_b; // bytes 2..4
*(u32*)&pkt[4] = param_a; // bytes 4..8
raw_send(handle, pkt, 8); // 2nd packet: configure adapter
}So top-opcode 0x2B is the adapter channel, with at least two
sub-codes:
pkt[1] |
Meaning (provisional) |
|---|---|
0xFF |
"query adapter" / start a transaction |
0x02 |
"set adapter parameters" (with 2- + 4-byte payload) |
This is separate from the secure-element authentication that the adapter ↔ T48 firmware runs between themselves — that one we do not see from the PC. What we do see is Xgpro sending adapter configuration down to the T48 firmware, which then forwards / applies it to the genuine adapter.
For our own software the practical implication is:
- We don't need to implement the secure-element challenge/response.
- We may need to send the same
0x2B/0xFF+0x2B/0x02pair Xgpro does before the first eMMC operation, so the firmware knows which adapter it's working with. - Capturing the exact
param_a/param_bfrom a USB session is the cleanest way to lock down their semantics.
FUN_004dba90 is the first thing Xgpro sends to a freshly-opened
handle:
void identify_programmer(handle) {
uint8_t empty_pkt[8] = {0};
WinUsb_WritePipe(handle, /*pipe=*/1, empty_pkt, 8, ...); // EP1 OUT — all zeros
uint8_t info[64];
int rc = raw_recv(handle, info, 0x40); // EP1 IN — 64 bytes
if (rc < 8) {
AfxMessageBox("Read device information error!");
return;
}
char device_type = info[10]; // byte 10 of the 64-byte reply
if (device_type == 0x05 ||
device_type == 0x06 ||
device_type == 0x07) {
// ok — recognised T48 model
}
}This is invaluable for our prototype: as the very first transaction
after connect(), send eight zero bytes and read back 64. The reply
byte at offset 10 is a device-type / model code (0x05, 0x06,
0x07 correspond to the three recognised model variants); other
fields in the 64-byte block carry firmware version, serial number etc.
The convention "send an all-zero command to get a banner reply" is common for vendor-specific WinUSB devices, but we now have it confirmed for the T48 specifically.
FUN_004a98f0 is the entry point for an ISP-mode read session.
Stripped to its essentials:
void isp_read(handle, ..., chip_params, ...) {
open_temp_file();
block_count = DAT_007475a4 >> 14; // total / 16 K
update_ui(...);
if (!FUN_004fc156(param_7, /*flag=*/0x8040, 0))
bail_out("partition switch failed");
uint32_t buf[128]; // 512-byte aligned
uint32_t current_addr = 0;
while (block_count-- > 0) {
// Format C bulk-write SETUP — but with req=4 (READ),
// generate_nonce=1, no key:
rc = FUN_00492670(handle, buf, current_addr,
/*p4=*/0, /*p5=*/0, /*count=*/1,
/*req=*/4, /*p8=*/0,
/*gen_nonce=*/1, /*key_src=*/0);
if (rc == -1) { error("ReadDevice Error : CMD25"); break; }
// Format D bulk-read — read 64 sectors (32 KB) on EP2 IN:
FUN_00492590(handle, output_buf, /*count=*/0x40);
save_to_file(...);
current_addr += 0x40;
}
}Notable points compared to the BGA-socket path (FUN_0049d910):
- The ISP path always sets
gen_nonce=1in the Format C setup. The random nonce is part of the frame even when no key is embedded. Likely the FPGA / firmware uses the nonce as a wiggle-bit reference for ISP signalling integrity. - The partition switch is wrapped in
FUN_004fc156with a 16-bit flag0x8040. The high byte0x80looks like a "use ISP path" gate; the low byte0x40matches our existing variant high byte for ISP 4-bit (and likely a sub-mask for the partition selector).
FUN_004ee610 returns a single 32-bit value computed as the CRC32
(polynomial table at DAT_006c3300) over four buffers in sequence:
uint32_t adapter_integrity_crc() {
uint32_t crc = 0xFFFFFFFF;
crc = crc32_update(crc, DAT_007a3c0c, DAT_007a397c); // code-memory buffer
crc = crc32_update(crc, DAT_007a4034, DAT_007a39ac); // CID + CSD bytes
crc = crc32_update(crc, &DAT_007c8048, DAT_007a39b0); // RPMB key table 2 (32 B)
crc = crc32_update(crc, &DAT_007a3c10, 0x100); // 256-byte config block
return ~crc;
}The fact that the RPMB key table is part of the hash strongly suggests this CRC is the host-side end of an adapter-identity check — the host needs to know its own copy of the RPMB key in order to produce a value the adapter expects. Without the right key, the CRC differs and the firmware can refuse to proceed.
Important for our software: we don't need to break this CRC.
The check happens between PC software (Xgpro) and the T48 firmware,
using a value that includes the chip's own data. Our software just
needs to make the same sequence of calls — FUN_00492f30 / 00492670 / 00492590 etc. — and the firmware drives the adapter-side challenge on
its own.
1. connect() → open libusb a466:0a53
2. identify_programmer() ; FUN_004dba90 — 8 zeros → 64-byte info
3. load_chip_params_from_db() ; FUN_004edaa0 equivalent (locally in our code)
4. adapter_cmd(0x2B, 0xFF, …) ; top-op 0x2B, sub 0xFF — query
5. adapter_cmd(0x2B, 0x02, …) ; top-op 0x2B, sub 0x02 — configure
6. begin_transaction(packet64) ; top-op 0x03 — 64-byte BEGIN_TRANS
7. request_status() → check OVC ; top-op 0x39 — reply[12] & 0x01
8. init_emmc() ; opcodes 0x21 / 0x05 / 0x06 in sequence
9. CMD6 SWITCH HS-200 etc. ; Format A sub-op 0x46
10. read loop ; Format C setup with gen_nonce=1 + Format D bulk read
11. CMD13 polling between bursts ; Format A sub-op 0x4D
12. CMD12 STOP at end ; Format A sub-op 0x4C
13. END_TRANSACTION ; top-op 0x04
14. close()
The ISP-specific steps are 4–5 (0x2B adapter channel) and the
gen_nonce=1 flag inside step 10. Everything else is shared with the
BGA-socket path.
Still TBD without further effort:
- Sub-op
0x5C(1 call site, no anchor strings, low priority). - Exact bit positions inside the 32-byte REQUEST_STATUS reply that
encode individual pin-fault flags —
FUN_004dc6d0and its eMMC cousinFUN_004e18d0only choose the expected mask; the actual per-pin reading still needs comparison with a USB capture. - The full 64-byte BEGIN_TRANSACTION map for the non-eMMC branches (we have eMMC bytes 0..0x18 plus 0x18..0x40 inferred; the classic-chip branches use different field slots).
These remaining items are best resolved against a USB capture from the real hardware once it arrives.
This file is a working document; some earlier conclusions were too confident given the evidence. The last pass over the still-uncertain functions before hardware arrival corrected three meaningful errors.
§27.2 earlier claimed 0x2B was an "adapter command channel" used to
configure the ISP adapter. The unique caller of FUN_004583f0
(FUN_004576a0) prints "Set Uart Ports error!" and "Starting
uart Printer error!" in its error paths, and immediately drops into
a UART loop after the 0x2B pair. So:
| Old claim | New reality |
|---|---|
Top-op 0x2B is the eMMC-ISP adapter command channel |
Top-op 0x2B is the UART / Serial-Printer command channel (the "TV Tools → Serial Printing" UI feature) |
Send 0x2B 0xFF + 0x2B 0x02 before BEGIN_TRANSACTION |
Do not send 0x2B at all for eMMC ISP — it has nothing to do with the eMMC code path |
| Required for adapter recognition | Adapter recognition is entirely between the adapter's secure element and the T48 firmware; the PC does not participate |
The practical consequence is actually a simplification — our
prototype's connect → begin_transaction → init_emmc → … flow is
correct as-is, with no 0x2B step needed.
Update (live capture, §33.1): the adapter does have a USB-visible channel after all — but it is top-opcode
0x24, not0x2B. Over0x24the PC reads the adapter's identity string ("XGecu Directly"); the crypto authentication still happens off the wire between the adapter and the T48 firmware, so the "PC does not participate in auth" point above stands. See §33 for the full eMMC-ISP capture.
§27.4 earlier read the call FUN_004fc156(param_7, 0x8040, 0) inside
the ISP read function as "partition switch". The body of
FUN_004fc156 is actually a textbook CreateFileA wrapper:
// param_3 & 0x3 → dwDesiredAccess (0x80000000 GENERIC_READ etc.)
// param_3 & 0x70 → dwShareMode
// param_3 & 0xF00 → dwCreationDisposition
// param_4 → lpSecurityAttributes
// hands off to CreateFileA / CreateFileWIn context (FUN_004a98f0 ISP read), the 0x8040 flag is the file
mode for the output dump file that captured eMMC sectors are
written to. So this call opens a file on the host PC, it does not
talk to the eMMC. The partition switch (CMD6 SWITCH PARTITION_CONFIG)
is sent earlier, via the normal cmd_A(SWITCH, …) we already
documented (§19.3 / §20.2).
The only caller of sub-op 0x5C is FUN_004b0ca0, whose body
accumulates eight per-position byte sums over a buffer in 4-iteration
loops:
int counters[8] = {0};
for (int i = 0; i < 4; i++) {
counters[0] += data[0];
counters[1] += data[1];
counters[2] += data[2];
counters[3] += data[3];
counters[4] += data[4];
counters[5] += data[5];
counters[6] += data[6];
counters[7] += data[7];
data += 8;
}
// then calls cmd_A(handle, 0x5C, …) carrying the eight countersEight per-bit-lane sums is the canonical pattern for bus integrity
testing — each counter tracks bit-flips on one lane of the 8-bit
data bus (or 4 lanes × 2 cycles). The most likely role of sub-op
0x5C is "tell FPGA to start / report a bus-test pattern". Its
single call site is reachable only from a diagnostic dialog that
doesn't run in the normal read/write path, so we can safely leave it
unimplemented in the first prototype.
After 28.1–28.3 the list of "uncertain" items is short:
- Exact bit positions inside the 32-byte REQUEST_STATUS reply —
FUN_004dc6d0(classic pin decoder) walks a per-variant pin table atDAT_006B57FC/DAT_006B6EE0and ORs flags into the output block via bit constants0x8,0x20,0x2A,0x380,0x228000. Mapping these back to physical T48 pin numbers needs one good-vs-bad-pin USB capture to nail down. - Voltage encoding bytes inside the 64-byte BEGIN_TRANSACTION
(offsets
0x14..0x18) — Xgpro pulls them from chip-DB globals but the exact byte order vs DAC index in the voltage tables we documented in §21.1 is one capture away from being closed. adapter_query/adapter_configure— removed from the prototype as a result of §28.1; nothing to validate.
These are honestly the only three things the prototype needs the first USB capture to settle. Everything else is byte-for-byte documented from the binary.
A final cross-check before declaring the static reverse "done" turned up two more meaningful errors plus a useful truth table for the RPMB / CMD18 wrapper.
Throughout earlier sections I referred to DAT_0080109b as the
"chip type" — picked up from comparisons like chip_type == 7 in
cmd_wrapper_0x08 and bulk_read_emmc. The function that sets
this byte (FUN_00436e90) tells a different story:
if (mode == 0) DAT_0080109b = 6; // T56 demo
if (mode == 1) DAT_0080109b = 7; // T48 demo
if (mode == 2) DAT_0080109b = 5; // TL866II Plus demo
// And reportedly == 8 for T76.So the byte is the programmer-family identifier:
| Value | Programmer |
|---|---|
5 |
TL866II Plus |
6 |
T56 |
7 |
T48 (TL866-3G) |
8 |
T76 |
This means every check that looked like a chip-side test is actually a programmer-family test:
| Earlier reading | Real meaning |
|---|---|
| Format B reply comes on EP2 IN "for eMMC" | EP2 IN routing happens on T48 / T76; on T56 it lands on EP1 IN |
| Bulk reads go to EP2 IN "for eMMC / VGA" | Same — it's the T48/T76 routing |
raw_recv adds +1 byte "for NAND" |
It adds +1 byte for T56 (programmer = 6), unrelated to chip type |
Practically: the existing prototype is still correct for T48 ISP
work — every condition that used to be "if chip_type 7 or 8" is
satisfied by us being on T48 (model 7). But if anyone ports this code
to T56 the EP routing changes and they need to follow the model == 6
branches instead.
I am leaving chip_type named as-is in §19–§22 for git-history
continuity, but every reader should mentally substitute
"programmer model" whenever they see the comparison constants
5 / 6 / 7 / 8.
There are exactly seven call sites of FUN_00492670 (Format C bulk
write) in Xgpro.exe. With pushed-arg indexing corrected
(pushes[-7] is req, pushes[-9] is gen_nonce, pushes[-10]
is key_src):
| Site VA | count |
req |
gen_nonce |
key_src |
JEDEC RPMB request | Use |
|---|---|---|---|---|---|---|
0x49388a |
1 | 5 |
(reg) | (reg) | READ_RESULT |
after password / OTP write — read response |
0x49da64 |
1 | 4 |
1 |
0 |
AUTH_DATA_READ |
BGA-mode CMD18 read (1 sector) |
0x4a9a64 |
1 | 4 |
1 |
0 |
AUTH_DATA_READ |
ISP-mode CMD18 read — identical to BGA |
0x4afd51 |
1 | 4 |
(reg) | (reg) | AUTH_DATA_READ |
RPMB-mode authenticated read |
0x4afdd5 |
1 | 1 |
(reg) | 2 |
PROGRAM_KEY |
One-shot key programming, uses table #2 |
0x4afe07 |
1 | 5 |
(reg) | (reg) | READ_RESULT |
after PROGRAM_KEY — read response |
0x4b041b |
1 | 2 |
1 |
(reg) | READ_WC |
Read write counter |
Two observations matter:
-
gen_nonce = 1is not ISP-specific. The BGA-socket CMD18 call (0x49da64) sets it just like the ISP call (0x4a9a64). The nonce is part of every normal CMD18 frame, not a marker of "ISP mode". My earlier §27.4 implied otherwise — that was wrong. -
There is exactly one
PROGRAM_KEYcall site (0x4afdd5), and it always useskey_src = 2(the second hardcoded key table atDAT_007C8048). Anyone reproducing the RPMB flow from our code must surface this gate behind an explicit user opt-in, becausePROGRAM_KEYis one-shot per chip — running it the wrong way permanently disables RPMB writes.
Combined with §28.2 (FUN_004fc156 is CreateFile, not partition
switch), the ISP-read flow inside FUN_004a98f0 is now:
open_dump_file(host_path); // FUN_004fc156 — just a file
while (block_count_left > 0) {
bulk_write_setup(/*req=*/4, /*gen_nonce=*/1, /*key=*/0); // CMD18 setup
bulk_read_data(64 sectors = 32 KB);
write_sectors_to_dump_file();
block_count_left -= …;
}There is no separate "ISP" handling in this function aside from
the entry conditions on variant high byte (§27.1, kept). The
session is byte-for-byte the same shape as a BGA read.
That removes another worry — our prototype's bulk_read() does not
need an "ISP mode" flag; it can build the same Format C / Format D
pair for any normal eMMC read regardless of how the chip is wired.
Note (clock): despite the
0x03descriptor saying "…clock…", the bus CLK is NOT encoded in this 64-byteBEGINpacket —BEGINis byte- identical at 8 MHz and 40 MHz. The clock is the initbyte[1]index (in0x21/0x05/0x06). ✅ Settled 2026-06-06 by a throughput sweep on hardware:byte[1]0x05=40 /0x04=30 /0x03=20 /0x00=8 MHz (monotonic, measured over 64 MB). An intermediate guess that the clock lived in theOP_3Eblob was wrong — withbyte[1]=0x00anyOP_3Ereads at ~8 MHz.OP_3Ecan stay constant. See ISP_SETTINGS §5.2 (and §5.3 for the per-chip read re-arm quirk).
Before hardware arrival the BEGIN_TRANSACTION builder was generated
from the inferred §20.2 table. A fresh radare2 trace of the actual
store instructions in FUN_00444bc0 (not the decompiler's inferred
field list) corrected several offsets. This pass is static-only and
should still be diffed against the first real capture, but it is now
instruction-level rather than guessed.
Reproduce with:
r2 -q -e bin.cache=true -e scr.color=0 -c 's 0x00444bc0; af; pdf' Xgpro.exe
- Command buffer base =
[ebp - 0x12018]=buf+0x00. - There are two build/send paths, both sending
len = 0x40:- Path A (
chip_type == 0x2dat the top branch0x444c16): builds a different layout from the0x0079a8xxglobals;push 0x40at0x444d22,call 0x4dc380at0x444d26. - Path B (everything else — the path §20.2 meant to describe):
push 0x40at0x44501a,lea eax,[ebp-0x12018]at0x445029,call 0x4dc380at0x445031.
- Path A (
- The 8-byte
REQUEST_STATUSfollow-up on Path B writesbyte[buf]=0x39thencall 0x4dc380at0x445079(len 8), and reads the 32-byte reply into[ebp-0x24020]viacall 0x4dc300at0x445088. (Note the read helper0x4dc300≠ the write helper0x4dc380.)
| Off | sz | default source | branch override |
|---|---|---|---|
| 0x00 | b | literal 0x03 |
— |
| 0x01 | b | DAT_007a3978 (chip_type / protocol_id) |
— |
| 0x02 | b | DAT_007a39a8 (variant low) |
— |
| 0x03 | b | DAT_007a3ba6 |
— |
| 0x04 | b | DAT_007a39bc (cfg byte dl) |
reworked into 0x15/0x16 in nibble paths |
| 0x05 | b | DAT_007a39bd |
overwritten in 0x31/0x2d/nibble paths |
| 0x06 | b | DAT_007a39b4 (pin_map) |
— |
| 0x07 | b | DAT_007a397b |
— |
| 0x08 | w | DAT_007a39ac (data_memory_size — stored once) |
— |
| 0x0a | w | DAT_007a39c0 (data_memory2_size) |
— |
| 0x0c | w | DAT_007a39c4 (page_size) |
0x31 path → DAT_007a39a9 zext |
| 0x0e | w | DAT_007a39b0 (pulse_delay) |
— |
| 0x10 | dw | DAT_007a397c (code_memory_size) |
— |
| 0x14 | b | DAT_007a3979 (unconditional) |
— |
| 0x15 | b | (none by default) | 0x31: ← DAT_007485c8; nibble path: dl & 0x0F |
| 0x16 | b | (none by default) | 0x31: ← 0; nibble path: dl & 0xF0 |
| 0x17 | b | never written (leftover) | — |
| 0x18 | dw | DAT_00904e88 |
0x31/0x2d/0x12/0x34 overwrite |
| 0x1c | dw | DAT_00904e8c |
0x31/0x2d/0x34 overwrite |
| 0x20 | dw | DAT_00904e90 |
0x31/0x34 overwrite |
| 0x24 | dw | DAT_00904e94 |
if chip_type==5 → DAT_0080187b |
| 0x28 | dw | DAT_007a39d4 |
0x2d → DAT_0079a8dc |
| 0x2c | dw | DAT_007a39b6 (u16 zero-extended) |
— |
| 0x30 | dw | DAT_007a39d0 (i32) |
— |
| 0x34 | w | DAT_007a3ba4 |
— |
| 0x36 | w | DAT_007a39be (byte → word) |
— |
| 0x38 | dw | DAT_007a39d8 |
— |
| 0x3c | b | literal 0 |
0x34 → byte[esi+0x696588] |
| 0x3d | b | literal 0 |
— |
| 0x3e | b | never written (leftover) | — |
| 0x3f | b | DAT_007a39a9 |
— |
It is not a u32 voltage word. It is three independent bytes plus one uninitialised byte:
0x14=DAT_007a3979— a per-chip DB byte, written unconditionally.0x15/0x16— written only inside thechip_typebranch ladder:- eMMC (
chip_type == 0x31):0x15 ← DAT_007485c8,0x16 ← 0.DAT_007485c8is a UI/mode global (the VCCQ selection), not the per-chip record. So the eMMC VCCQ byte is not derivable from the chip-DB record alone — it tracks the "VCC=3.0V VCCQ=1.8/3.0V" UI choice (§20.3). - generic chips: a nibble split of
DAT_007a39bc(0x15 = dl&0x0F,0x16 = dl&0xF0, with a0xF0-special case).
- eMMC (
0x17is never written — leftover stack content.
This is the long-standing "voltage byte order is one capture away"
item (§28.4). Statically it is now pinned to which byte and which
global; the only thing the capture still adds is the concrete value of
DAT_007485c8 for each of the three VCCQ UI settings.
data_memory_size(DAT_007a39ac) is stored once, at 0x08 — the §20.2 "0x04..06 dup" was a phantom.0x04isDAT_007a39bc(cfg).0x05(DAT_007a39bd) was missing from §20.2.0x2cis a u16 zero-extended, not a native u32.- The 0x30..38 block was mis-assigned:
DAT_007a39d0(i32) is at 0x30 (§20.2 said 0x34..38);DAT_007a3ba4is at 0x34 (§20.2 said 0x30);DAT_007a39be(byte→word) is at 0x36 (§20.2 said 0x32). 0x3c..3fis not all padding:0x3c=0,0x3d=0,0x3e=leftover,0x3f=DAT_007a39a9.
The prototype's build_begin_transaction() was updated to match this
map: it now fills only 0x00/0x01/0x02/0x08/0x0a/0x10 (the stable
per-chip-DB fields) and leaves 0x03..08 and 0x14..3f zero for the
capture to fill.
This is the first section confirmed against real device traffic (no longer static-only). Two USBPcap captures were taken on the day the T48 arrived (2026-05-29) under first-party Xgpro on Windows:
- a short connect / version capture (20 packets), and
- a full System Self-check capture (1422 packets, 9.1 s).
Device reported itself as: firmware version 00.01.39 (see §31.1 for
how that decodes), build-stamp 2024-08-15 17:21, model T48.
Enumeration was high-speed USB 2.0 (480 Mbit/s) with exactly four
bulk endpoints — 0x01/0x81 and 0x02/0x82, all
wMaxPacketSize = 512 — which directly confirms the §2 endpoint map
(EP1 short commands, EP2 bulk). Xgpro's own connect log agrees:
Device 1: T48 [TL866-3G] Ver: 00.01.39, USB POWER 04.89 V, USB2.0 HS 480 MHz (note Xgpro's marketing name TL866-3G, vs the USB-ID
database's "TL866II Plus").
Note on the firmware capture: it contains no firmware payload. A real flash makes the TL866/T48 re-enter a bootloader that re-enumerates (possibly under a different identity), so a USBPcap filter locked to
a466:0a53will not see the flash blocks. To capture an actual update, capture the whole root hub, not the device. That is exactly what happened here: the operator did update the firmware, the version went00.01.03→00.01.39(visible in the0x00reply, §31.1), but the flash blocks themselves landed on the bootloader device and are absent from thisa466:0a53-filtered capture — a concrete confirmation of the filtering caveat.
Not previously in the §3 table. At connect Xgpro sends an all-zero 8-byte packet on EP1 OUT and reads back a 63-byte info record on EP1 IN:
host → EP1 OUT (8): 00 00 00 00 00 00 00 00
dev → EP1 IN (63): 00 01 30 00 27 01 07 00 ; 8-byte header
"YYYY-MM-DDHH:MM" 00 ; firmware build timestamp, NUL-terminated
"<32-char device serial>" 00 ; per-unit serial (redacted here)
E7 05 00 00 01 00 00 ; trailing word + flags — see below
- Header byte
[6] = 0x07= programmer model — matches §29.1 (DAT_0080109b: 7 = T48). The same… 01 07 00triplet appears in theREAD_PINS/MEASURE_VOLTAGESreplies below, i.e. byte[6] of every EP1 reply carries the model code. - The firmware version is
byte[0].byte[1].byte[4], decimal. Here00 . 01 . 0x27→00.01.39, which is exactly what Xgpro prints in its connect log (Device 1: T48 [TL866-3G] Ver: 00.01.39).byte[4]is the build number; it reads0x27(39) stably across back-to-back reads. - The flash the operator ran did take effect. The cold-plug capture
before updating had
byte[4] = 0x03(=00.01.03); after the update it is0x27(=00.01.39). So0x00identify can tell you the firmware version — the flash traffic itself still went to the re-enumerated bootloader that ana466:0a53-locked capture misses (the note at the top of §31 stands), but the before/after version delta confirms the update succeeded. - The build-date string (
2024-08-15 17:21) did not change across the update — it is a fixed build-stamp in this firmware line, not the user-facing version; the version lives inbyte[4]. - The trailing 16-bit word is NOT a version — it is a live,
ADC-like sample. Read back-to-back it returned
0x05E7 / 0x05ED / 0x05E9 / 0x05EC(≈1511–1517) and0x05D1(1489) earlier: a few counts of jitter, i.e. an analog reading taken at identify time. (This corrects an earlier draft that mistook this word for the version.) - Immediately after, the capture shows
0x3D(SWITCH, carrying an 8-byte magic23 01 67 45 AB 89 EF CD) and0x3F(RESET) — both consistent with the §3 names.
The self-check uses only EP1 (no EP2 / eMMC), confirming it is safe
with an empty socket. Command framing seen on the wire is the documented
8-byte [op][00 00 00][u32 arg LE]; pin-pattern setters use a 24-byte
form. Three phases were observed, in this order:
SET_OUTwalk — for indexN = 0,1,2,…:0x36(select, arg=N) →0x35READ_PINS→0x31SET_PULLUPS(release).SET_GNDwalk —0x30(24-byte one-hot pin pattern) →0x35READ_PINS.- Voltage / status —
0x33MEASURE_VOLTAGES→ 24-byte reply;0x39REQUEST_STATUS→ 32-byte reply; bracketed by0x2DRESET_PIN_DRIVERSat the very start and end.
READ_PINS (0x35) reply is 16 bytes — not 40 (this corrects the
"recv 40B" guess in §22.7 step 4c):
35 00 10 00 27 01 07 00 <6-byte pin bitmask> 57 00
└─ echo + header (8) ───┘ └─ pins[8..14) ────┘ └ trailer
The 48-bit mask is proof of a walking-bit continuity test: driving
index N reads back with exactly bit N cleared, all others set —
N=0 → FE, 1 → FD, 2 → FB, 3 → F7, … 7 → 7F, 8 → FF FE,
9 → FF FD, … So READ_PINS returns a 6-byte (48-pin) bitmask at
offset 8, not the reply[8..48] per-byte array §22.7 assumed.
MEASURE_VOLTAGES (0x33) reply is 24 bytes: same 8-byte header
(33 00 10 00 27 01 07 00) followed by four u32 LE rail readings.
One healthy idle sample: 1251, 1492, 1241, 2020 (raw units — mV vs ADC
counts is still TBD, one calibrated capture away).
REQUEST_STATUS (0x39) reply = 32 bytes, all-zero on a healthy
idle self-check.
- Confirmed: the §2 endpoint map (4 bulk EPs, 512 B); the minipro
opcode names
0x2D/0x2E/0x2F/0x30/0x31/0x32/0x33/0x35/0x36/0x39seen carrying exactly the expected bytes; §29.1's model byte (7= T48), now visible as byte[6] of every EP1 reply. - Corrected vs §22.7:
READ_PINSreplies are 16 B, not 40 B; the per-pin result is a 6-byte bitmask at offset 8, notreply[8..48]; observed phase order is SET_OUT-walk → GND-walk → voltages/status. - New: top-opcode
0x00= device identify (build date + serial + a trailing dynamic word, §31.1); the stable EP1-reply header shape… 01 07 00with the model code at byte[6].
Running the prototype's safe probe against the device (connect → identify → measure_voltages → request_status → read_pins, none of which apply programming voltage) reproduced the wire formats above and added:
MEASURE_VOLTAGES(0x33) scaling is correct. The minipro-derived scale factors yield sane idle rails:vusb ≈ 4.976 V(the USB 5 V bus — a clean sanity check),vpp ≈ 11.14 V,vcc ≈ 3.37 V,vccio ≈ 3.26 V. So0x33returns real ADC data even with no test bitstream loaded.READ_PINS(0x35) idle reply = the 16-byte block35 00 10 00 27 01 07 00+ all-zero 6-byte mask — i.e. with no driver bitstream loaded, no pin is asserted (as expected). Confirms the 16-byte size and the offset-8 mask from §31.2.REQUEST_STATUS(0x39) with no active session = 32 zero bytes, OVC bit clear — a safe idle baseline.
All four endpoints, the identify record, and these read-backs are reproducible from Linux via libusb with no udev rule on this host (the device node already carries a uaccess ACL).
The first end-to-end read and write of a real chip, both captured
from Xgpro and reproduced byte-for-byte by our pyusb prototype on
Linux. Target: a 93LC56 (3-wire Microwire EEPROM, 256 bytes) — the
configuration EEPROM of an FT2232 board, read/written in-circuit
through the ICSP port (ICSP_VCC Enable). Content seen: FTDI
VID 0x0403 / PID 0x6010, strings "USB <-> Serial Converter" etc.
03 02 8b 81 00 00 00 6c 00 00 00 00 0a 00 00 00
00 01 00 00 00 00 00 00 ... (64 bytes)
byte[0x01] = 0x02→ protocol_id for the 93-series.byte[0x02..03] = 8b 81→ chip variant.byte[0x10] (u32 LE) = 0x00000100 = 256→code_memory_size= the chip size, exactly the §30 field map. First real-chip confirmation.
REQUEST_STATUS (0x39) is issued before/after each phase: 32-byte
reply, OVC at byte[12] (all-clear here).
EP1 OUT (8): 0d 01 40 00 [word_addr LE32] ; 0x40 = 64-byte chunk
EP2 IN (64): <data>
Four calls at word-addr 0x00 / 0x20 / 0x40 / 0x60 → 256 bytes. Data
comes back on EP2 IN (0x82).
Xgpro's full program is read → erase → write → verify-read:
ERASE: EP1 OUT (8): 0e 00 01 00 00 00 00 00
WRITE_CODE: EP1 OUT (8): 0c 01 20 00 [word_addr LE32] ; 0x20 = 32-byte chunk
EP2 OUT (32): <payload>
Eight write chunks at word-addr 0x00 / 0x10 / … / 0x70 → 256 bytes.
Note the asymmetry: writes use 32-byte chunks (byte[2]=0x20),
reads use 64-byte chunks (byte[2]=0x40). Write payload goes on
EP2 OUT (0x02).
§2 originally read "EP2 IN: not observed". This capture disproves that
for the classic path as well (it was already known for eMMC, §19.4):
EP2 IN = bulk read-back, EP2 OUT = bulk write payload, for ordinary
READ_CODE/WRITE_CODE — not just eMMC.
Our prototype (examples/t48_emmc.py low-level ep1_send/ep1_recv/
ep2_send/ep2_recv) replayed all of it against the live device:
- Read — 256 bytes identical to Xgpro's capture (FTDI VID/PID + strings).
- Write — wrote a marker chunk, read it back unchanged.
- Restore — ran the captured canonical cycle (
ERASE+ eight 32-byteWRITE_CODE) to put the original FTDI image back; verify-read returned byte-for-byte the golden image (OVC clear throughout).
This is the first confirmation that the reverse-engineered classic transport supports a complete read-modify-write-verify cycle from Linux, matching first-party Xgpro on the wire.
The capture this project was built for: a 4-bit / 3.3 V eMMC in-circuit read through the XGecu ISP adapter, of the boot partitions + RPMB + 128 MB of the user area, followed by a verify pass. 42 k packets, ~150 MB streamed on EP2 IN. This confirms most of §7–§30 on real silicon and, for the first time, shows the adapter channel and the eMMC init handshake that were marked TBD.
The ISP adapter is driven over a dedicated top-opcode 0x24 (not
0x2B, which §28.1 already retracted). Sub-ops seen: 0xe0, 0xf0,
0xf1. Its reply carries the adapter's identity string:
host → 0x01 OUT: 24 f0 00 00 01 00 00 00
host → 0x01 OUT: 24 e0 28 00 00 ... e5
dev → 0x81 IN : 24 00 10 00 00 08 00 00 "XGecu Directly" 00… 09 00 a0 ad
So the in-circuit adapter announces itself as XGecu Directly. A
0x3E opcode (new) follows with a 16-byte status reply
(3e 00 10 00 …ff 57 00) — the same …57 00-trailered status family as
READ_PINS.
In order, before any bulk transfer:
| Step | EP1 OUT | Reply | Meaning |
|---|---|---|---|
| BEGIN | 03 31 00 00 00 05 a1 00 … |
— | protocol_id = 0x31 (eMMC) ✓, data_memory_size@0x08 = 32, code_memory_size@0x10 = 512 |
| status | 39 31 00 00 00 05 a1 00 |
32 B zero | OVC clear |
| init | 21 00 00 00 00 00 00 00 |
21 00 00 00 80 80 ff c0 |
algorithm/init → OCR-like 80 80 ff c0 |
| READID | 05 00 00 00 … |
07 …80 80 ff c0 CID 44 00 01 45 30 30 34 47 39 … |
CID — but as 32-bit LE words: real decode = SanDisk DG40… (see §33.7), not "E004G9" |
| READ_USER | 06 00 00 00 … |
24 B (…32 00 0f d0 ff 03 …) |
CSD / card status |
| EXT_CSD | 08 48 00 02 00 00 00 00 |
512 B on EP2 IN + 8 B ack | 0x08/0x48 single-block (EXT_CSD) |
| SWITCH ×4 | 27 46 … |
27 00 10 00 00 08 00 00 |
CMD6 (see §33.3) |
| CMD30 | 08 5e 04 00 … |
— | §26.2 wrapper |
0x05/0x06 are reused from the classic set to surface the CID and CSD;
0x21 is the eMMC-specific init.
Each SWITCH is 27 46 00 00 00 [val] b3 [access] — i.e. set EXT_CSD[179]
PARTITION_CONFIG (0xb3 = 179) to [val], whose low 3 bits pick the
hardware partition:
| Command bytes | val | partition |
|---|---|---|
27 46 00 00 00 01 b3 01 |
1 | BOOT1 |
27 46 00 00 00 02 b3 01 |
2 | BOOT2 |
27 46 00 00 00 03 b3 01 |
3 | RPMB |
27 46 00 00 00 07 b3 02 |
clear | USER (CLEAR_BITS 0x07) |
27 46 00 00 00 03 b3 01 and 27 46 00 00 00 07 b3 02 are exactly the
bytes the prototype's cmd_A(SWITCH→RPMB) / cmd_A(SWITCH→USER restore)
emit — the §7/§24 SWITCH encoding is now hardware-confirmed.
The session walks: →RPMB → →USER → →BOOT1 [read] → →BOOT2 [read] → →RPMB → →USER [read 128 MB], then a verify pass repeats →BOOT1 → →BOOT2 → →RPMB → →USER. That is the "system partitions + 128 MB user area, then
verify" operation.
Each partition read is set up by a 40-byte 0x0D descriptor, e.g.:
0d 01 00 00 | 00 00 00 00 | 00 02 00 00 | 20 00 00 00 | 00 01 00 00 |
20 00 00 00 | 20 00 00 00 | 01 00 00 00 | 01 00 00 00 | 00 00 00 00
(start LBA, block size 0x200, and a length/count field that scales with
the read — 0x0100 for the small boot reads vs 0x2000 for the 128 MB
user read). The data then streams as 514 0x14(setup)/0x15(trigger)
16-byte pairs, with the payload arriving on EP2 IN (0x82) in
16 KB and 32 KB USB transfers — ~150 MB across the read + verify.
- Adapter channel =
0x24, identityXGecu Directly— the missing piece of §27/§28. - The full eMMC init handshake (
0x21/0x05/0x06/0x08·0x48), previously TBD, is now a concrete sequence with real replies (CID, CSD, OCR-like word). - CMD6 SWITCH partition map (BOOT1/BOOT2/RPMB/USER via
PARTITION_CONFIG) confirmed, and our SWITCH byte encoding matches the wire exactly. - Bulk eMMC reads use EP2 IN at scale (~150 MB), via
0x0D+0x14/0x15.
Still adapter-gated for our prototype (the 0x24 channel authenticates
to the adapter), but the protocol is no longer guesswork — this is the
reference capture for implementing eMMC-ISP read on the Linux side.
Comparing the 4-bit/3.3 V capture above with a 1-bit, 64 MB read of
the same eMMC (same CID …E004G9, same adapter, same partition walk),
the two BEGIN_TRANSACTION packets differ in exactly two bytes:
| offset | 1-bit | 4-bit | meaning |
|---|---|---|---|
0x0c |
0x51 |
0x54 |
algorithm / bus-width selector |
0x3f |
0x51 |
0x54 |
same value, duplicated (DAT_007a39a9, §30) |
0x51 / 0x54 are the .alg algorithm numbers EMMC_51 (1-bit) and
EMMC_54 (4-bit) — i.e. the FPGA bitstream that bit-bangs the eMMC
bus. Crucially, neither capture contains a CMD6 BUS_WIDTH switch
(EXT_CSD[183], index 0xb7): the SWITCH set is identical
(PARTITION_CONFIG only). So the data-bus width is chosen entirely
host-side by picking the bitstream in BEGIN[0x0c]/[0x3f] — the eMMC
itself is never told to change BUS_WIDTH.
Consequence for the §30 map: BEGIN[0x0c] (the low byte of the
"page_size" field) and BEGIN[0x3f] (DAT_007a39a9) together carry the
algo/bus-width code, and it overrides the per-chip-DB variant letter —
in these ISP reads the DB variant bytes [0x02..03] are 00 00, and
the live selector is 0x51/0x54. The 1-bit read is also ~1.6× slower
on the wire (61 s vs 37 s; ~100 MB vs ~150 MB on EP2 IN), as expected for
the narrower bus.
The 16-byte CID/CSD that follow the 8-byte reply header (READID 0x05,
READCSD 0x06) are not a flat JEDEC MSB-first register. The T48 returns
them as four 32-bit little-endian words — i.e. each consecutive 4-byte
group is byte-reversed relative to the JEDEC layout. Decoding the raw 16 bytes
as-is yields a bogus MID and a garbled PNM (and a wrong manufacturing date).
Proof — CRC7 (x^7+x^3+1) over the first 15 bytes matches the stored CRC byte
only after swapping each 4-byte word, and it matches for both registers
independently (so it is not a coincidence):
| capture | raw 16 B (as received) | forward decode | word-swapped decode | CRC7 |
|---|---|---|---|---|
| DJI Air 3S | 5900019b 363053305e 7c0034fc bb6945 |
MID 0x59 (Unknown), PNM ·60S0^, 2019-09 |
MID 0x9B YMTC, PNM Y0S064, 2024-11, SN 0x7C5E4569 |
swap ✓ / forward ✗ |
§33.2 …E004G9 |
44000145 30303447 39… |
MID 0x44 (Unknown), PNM E004G9 |
MID 0x45 SanDisk, PNM DG40… |
— |
So the §33.2 example was itself read in the wrong order: that chip is a
SanDisk (MID 0x45, DG40…), not an unrecognised 0x44/"E004G9". The
fix is a 4-byte-word swap before parsing — see _decode_cid() in
examples/t48_emmc_isp.py. The EXT_CSD (512-byte data block on EP2 IN) is a
byte stream, not a register, and must not be swapped (SEC_COUNT@212
reads correctly as-is).
A captured 1-bit eMMC write to the user area (same chip/adapter as §33) completes the round-trip: it shows how the eMMC program path works, which is structurally different from the read path.
Init is identical (§33.2): 0x24 adapter (XGecu Directly) → 0x3E →
0x03 BEGIN (protocol_id=0x31, bus-width 0x51) → 0x21/0x05/0x06/
0x08·0x48 → SWITCH →USER (27 46 00 00 00 07 b3 02). One tell: the
write BEGIN has byte[0x18] = 0x04 where the read had 0x03 — a
likely read/write mode marker.
The target region is erased first, as 32 iterations of:
EP1 OUT: 27 4d 01 00 00 00 00 01 00 ; 0x27 sub-op 0x4D (erase-group setup)
EP1 OUT: 0e 00 00 00 [start_blk LE32] ff [end_lo] 00 00 00 00 00 00
EP1 IN : 0e 00 00 00 80 80 ff c0 ; OCR-like ack
start_blk steps by 0x400 (1024 blocks) — 0, 0x400, 0x800, … — and the
ff XX field is the matching erase-group end marker
(ff03, ff07, ff0b, …). 32 × 1024 blocks × 512 B = exactly 16 MB
erased, matching the intended write size. So 0x0E carries (start, end)
block addresses (not the classic 1-shot 0e 00 01 … form of §32), and
0x27/0x4D is the per-group companion (resolves the §25 "0x4D =
commit/finalize?" question — it is the erase-group step).
eMMC writes do not use the read's 0x14/0x15 pairs. Instead a new
opcode 0x1F carries a 40-byte descriptor (the write-side mirror of
the 0x0D read descriptor):
1f 01 00 00 | [addr] | 00 02 00 00 | 20 00 00 00 | [count] |
20 00 00 00 | 20 00 00 00 | 04 00 00 00 | 01 00 00 00 | 00 00 00 00
(note the 04 00 00 00 field where the 0x0D read had 01 00 00 00).
The payload then streams on EP2 OUT (0x02) in 16 KB chunks
(16400-byte transfers = 16384 data + 16). ~8.6 MB streamed here — the
erase covered the full 16 MB but only ~8.6 MB of payload went over the
wire (Xgpro most likely skips blank/0xFF pages; exact policy TBD).
After programming, Xgpro reads the region back with the §33.4 read path
(0x0D + EP2 IN, ~25 MB including the boot/RPMB walk) and compares — the
"write + verify" the operation name implies. A single 0x0A (generic
eMMC CMD wrapper, §23.1) appears near the end as a final status/CMD.
| Phase | Read | Write |
|---|---|---|
| bulk setup | 0x0D (40 B) |
0x1F (40 B) |
| bulk data | 0x14/0x15 → EP2 IN |
direct → EP2 OUT (16 KB) |
| erase | — | 0x0E (start,end) + 0x27/0x4D ×N |
BEGIN[0x18] |
0x03 |
0x04 |
With this, the eMMC-ISP read and write paths are both captured and decoded — the protocol side of the project is essentially complete; what remains is the adapter's on-device authentication (off-wire) and turning these flows into prototype methods.
examples/t48_emmc_isp.py replays the §33–§34 flow parameterised by block
address and arbitrary read AND write were verified on real hardware from
Linux (no Xgpro): a 16 KB pattern written to an arbitrary block reads
back byte-for-byte and persists across sessions. Implementing it pinned
down a few details the raw captures only implied.
Before the first 0x0D read, Xgpro does a one-time sequence that the
prototype must replay or the bulk engine never streams:
SWITCH->RPMB (27 46 00 00 00 03 b3 01) ; EP1, 8-byte reply
0x14 (14 00 00 00 00 00 00 00 01 00 00 02 00 00 00 00) ; EP1
512 bytes 0x00 ; EP2 OUT
0x15 (15 00 00 02 00 00 00 00 01 00 00 02 00 00 00 00) ; EP1
512 bytes + 16 bytes ; EP2 IN (RPMB response + status)
SWITCH->USER (27 46 00 00 00 07 b3 02) ; EP1, 8-byte reply
- READ (
0x0Dsetup: byte[4..8]=start block, byte[16..20]=chunk count): each 16 KB chunk arrives on EP2 IN as[16-byte header + 16368 data]followed by a separate[16-byte tail]— concatenateheader[16:] + tailto recover the full 16384 data bytes. - WRITE (
0x1Fsetup): payload streams on EP2 OUT as 16 KB chunks, each prefixed by a 16-byte headerstruct <u32 0><u32 block_addr><u16 512> <u16 0><u32 32>. The per-chunkblock_addris how Xgpro skips blank sectors and how we address arbitrary blocks. - EXT_CSD (
0x08/0x48) returns its 512-byte block and an 8-byte ack, both on EP2 IN.
In the eMMC branch of the BEGIN builder FUN_00444bc0 (0x444edd):
buffer[0x15] = DAT_007485c8. Every capture had it 0x00; the compare
sites (0x4953a0, next to "50 MHZ (VCCQ=3.3V)" vs "AUTO MAX=160MB/s",
and 0x493d61) treat it as boolean — 0 = 3.3 V / 50 MHz, nonzero =
1.8 V / HS200. So:
BEGIN[0x15] |
VCCQ |
|---|---|
0x00 |
3.3 V (capture-confirmed, read+write verified) |
0x01 |
1.8 V (static-reverse-derived, not hardware-verified) |
t48_emmc_isp.py --voltage {3.3,1.8} sets exactly this byte; nothing else
in BEGIN changes between voltages.
- Wedging: any transfer that times out mid-session wedges the
programmer's eMMC state machine.
clear_halt/usb resetdo not recover a hard wedge — only a physical unplug/replug. The prototype's_recover()+ retry handles transient init hiccups; a hard wedge raises "replug the T48". - The adapter handshake (
0x24→XGecu Directly) replays fine from the PC: the crypto is between adapter and firmware, so the host just replays the captured bytes (confirms §28.1 / §33.1). - Verified ops at 3.3 V / 1-bit: identify, CID/CSD/EXT_CSD, arbitrary read, arbitrary write, cross-session persistence.
Reversed from a native 1 GB read at 40 MHz / 1-bit (чтение_1гб…pcapng),
hardware-verified against a Rust port.
Big read = one RD_SETUP with a huge n_chunks, streamed. The 1 GB read used
5 RD_SETUPs with n_chunks (byte[16..20]) = 256, 256, 65536, 256, 256; the
65536 one carries the whole 1 GB. Its payload is just read 16384 (16-byte header + 16368 data) + read 16 (tail) repeated, with no re-arm for ~10 000+
chunks straight. So a large read does not need periodic re-arming — it streams.
Re-arm exists but only for the small 256-chunk RD_SETUPs (a different
op — possibly a verify/scan pass). There the cycle is, every 32768-read (2
chunks): read 32768 + read 16, then RD_14 → ep2_send(512 zeros) → RD_15, where
the loop RD_15 has byte[8] = 0x40 (the initial-arm RD_15 is byte[8]=0x01).
Injecting this re-arm into a streaming read breaks it (the device, mid-stream,
doesn't expect the OUT) — so don't.
The real ceiling for a port is host USB drain speed. The eMMC clocks data out
at the bus rate (~5 MB/s at 40 MHz 1-bit). Native (WinUSB) drains fast enough to
keep a single 65536-chunk RD_SETUP flowing for 1 GB. A libusb port (Linux,
or rusb on Windows) drains a touch slower (~4.5 MB/s, two bulk-IN per chunk), so on
a long RD_SETUP the programmer's internal read FIFO overflows and wedges
(ep1_send/ep2_send error → physical replug). Measured limit on this setup:
256 chunks (4 MB) per RD_SETUP is safe; 512 is flaky; 1024 wedges. So a
libusb port should cap each read_sectors/RD_SETUP at ~256 chunks and loop
many short RD_SETUPs inside one reused session for large volumes — verified
reliable over 256 MB at 4.55 MB/s (≈ native). (Native avoids the cap purely by
draining faster, not by any extra command.)