Teaching a Bootleg Dance Mat to Speak English
I bought a weird bootleg dance mat.

You know the kind. Plugs into the TV, boots off an SD card, comes with about ninety games, and every single word on screen is in Chinese.

I can't read Chinese. ![]()
A normal person would memorise which button means "start" and move on. I decided to figure out how the games are stored, pull the dance game and the main menu apart, and put them back together in English.

Full disclosure: I didn't do this alone. I pointed Claude (in Cowork) at a copy of the SD card. It wrote the Python tooling and did most of the byte-staring, while I steered, checked results, and handled the "wait, why is she blonde" QA. More on that later.
What's on the card
| folder | what's inside |
|---|---|
game\ | ~90 .bin files, ~85 matching .dat files, ~250 .drm sound effects |
dance_note\ | the songs (.mp3 or .mov video) plus a .nte step chart for each |
Every game is a .bin + .dat pair: dance.bin / dance.dat, Snake.bin / Snake.dat, and so on. The .dat files are the chonky ones (dance.dat alone is 58 MB), so the obvious guess is code in .bin, assets in .dat.
What I could not find was the main menu. No menu.bin, no launcher, nothing.
The .bin files are just ARM code
The first eight bytes of dance.bin:
08 7C F8 01 79 DD 01 00
Read as little-endian words, that's 0x01F87C08 and 0x0001DD79.
The first one looks like a stack pointer sitting near the top of 32 MB of RAM. The second one is odd, which on ARM means "Thumb-mode code lives here". Put together, that's a Cortex-M vector table: initial stack pointer, then the reset handler.
So there's no header and no compression. The file gets copied into RAM and jumped into. That's it.
The strings settle which chip we're dealing with: gplib_M4_mp3_dec, Only Support GP File System, drvl1_sdc_..., PPU Active. This is a Generalplus GPM4-family SoC: a Cortex-M4 glued to a 2D "PPU" (four tiled background layers plus a sprite layer, very old-console energy), a JPEG engine, an SD controller and a camera interface.
From there it's the usual firmware archaeology:
- Load address:
reset & 0xFFF00000. Games are linked at0x00000000. - Startup code: the reset handler is GCC-style. Three literals (source, destination, end) and a copy loop that moves the
.datasection from the end of the file into RAM at0x00200000. Anything living in.data, like tables of pointers, has to be read at its RAM address, not its file offset. - Disassembly: Capstone handles Thumb-2 just fine, so "who calls what" is just scripting.
So what's actually inside the box?
The "console" is a little black UFO that plugs into the TV over HDMI. It also has a spinning disco light on top, because of course it does.

The label underneath says:
| field | value |
|---|---|
| brand | DSXR |
| product | 跳舞毯 ("dance mat") |
| model | A870II |
| power | 5V ⎓ 1A |
| maker | 深圳市东升旭日电子科技有限公司 (Shenzhen DongShengXuRi Electronics Technology) |
DSXR is just the initials of DongShengXuRi. And the model number has an FCC filing, 2AYG3-A870II, filed in December 2020 as an "HDMI Wireless Dancing Mat".
FCC filings come with internal photos. Free teardown. ![]()

Photo from the FCC filing's internal photos (GTS test lab). The board is silkscreened A885B, dated 2020-09-16.
What's on it:
- HDMI out, a USB-A port, a microSD slot and a power switch on the front edge, with micro-USB power on the back.
- A camera on a ~10 cm flex cable. That's what the menu's camera games use.
- A radio chip from ShanWan, the company behind a lot of cheap PS3-style controllers, sitting next to a printed antenna. The FCC test report lists 79 channels, 1 MHz apart, with GFSK / π/4-DQPSK / 8-DPSK modulation. That's exactly Bluetooth Classic's channel plan, so the mat and the controllers almost certainly talk Bluetooth to the box.
- An unlabelled 4-pin header at the board edge. My money is on a serial debug port, but I haven't poked it yet.

The main chip hides under that heatsink, but the firmware had already told us most of what we need. Generalplus's GPM4730A datasheet lines up with it almost point for point:
| what the firmware says | GPM4730A datasheet |
|---|---|
Cortex-M4 code, gplib_M4_... strings | ARM Cortex-M4F |
stack pointers just under 32 MB (0x01F87C08) | 256 Mb (32 MB) of DDR built into the chip |
| four background layers plus sprites | "4 Text layers + 1024 internal Sprites" |
| HDMI output, camera games | 720p HDMI out, MIPI / parallel camera input |
| one big QFP under a heatsink | LQFP128 package |
So it's almost certainly a GPM4730A or a close sibling. I'll confirm it the day I'm brave enough to pop the heatsink off.
Finding the main menu
It was hiding in plain sight the whole time: game\menucode.bin.
Its reset vector is 0x00E2805D, so it's linked at 0x00E00000. That's the trick: the menu sits high up in RAM and stays resident while a game gets loaded at address 0 underneath it.
Inside it there's a table of over 90 f:\Game\*.bin paths, one per menu entry (a few point at games that aren't even on my card
). The console's built-in firmware starts f:\game\menucode.bin from the card, and the menu launches everything else.
So the menu is just another game. Same format, same engine. It's simply the first one that runs.
The .dat has no header. The map is in the code.
dance.dat has no magic number, no directory, no offsets. Just 58 MB of pixels back to back.
The description of what's inside lives in the .bin, as a table compiled straight into the code:
struct ResEntry { // 16 bytes
char *path; // -> "f:\game\dance.dat"
u32 dat_offset; // where it is in the .dat
u32 ram_address; // where the loader copies it
u32 size;
};
Finding it is a fun little trick. Search the .bin for the string f:\game\dance.dat, work out that string's address, then search for that address stored as a 32-bit word. Every hit is the first field of a ResEntry, and they're all lined up in a row.
dance.bin has 44 entries (36 unique regions, since scenes reuse RAM and some regions show up twice). The menu has 18.
A loader function takes a scene number, looks it up in two small u16 tables, and copies that scene's entries into RAM. Whatever shows up on the next screen comes from whatever just landed at those addresses.
Two kinds of things come out of that table: background banks with their tilemaps, and sprite banks.
Backgrounds, and widths that don't exist
Backgrounds are classic tile graphics:
- Tile bank: an array of 32×32 tiles, 2 KB each, in YUV422. Tile 0 is all zeros.
- Tilemap: the entry right after the bank. An array of
u32values, each one the byte offset of a tile inside the bank. They're always multiples of0x800, which makes tilemaps really easy to spot.
What the tilemap doesn't contain is its own width. A tilemap with 2,560 entries could be 32 tiles wide or 64 tiles wide, and the data alone can't tell you which.
The width only exists as an argument at the call site:
copy_tilemap(layer, src_tilemap, 32, 32,
src_width_px, copy_w_px, copy_h_px,
src_y, src_x, dst_y, dst_x);
So the extractor disassembles the game, finds every call to that function, and tracks which constants end up in the argument registers and on the stack. A few scenes use a table of {bank, tilemap, width} triples instead, which is even easier.
Animated backgrounds turned out to be tall images. A 1024×2560 tilemap is four 1024×640 frames stacked vertically, and the game animates by changing src_y.
The blue-skinned dancer
The song-select screen shows a big 480×480 preview of each song. Those pictures aren't in the resource table at all ![]()
They're 69 chunks (3 game modes × 23 songs) tacked onto the end of dance.dat and streamed in on demand, with the offset computed in code:
offset = 0x1930FC0 + mode * 0xA27000 + song * 0x71000;
Getting an actual picture out of one took a few tries:

- As plain scanlines: stripes. The data is tiled.
- As 32×32 tiles, 16 per row: almost, but sheared into a staircase. It's 15 tiles per row, and each chunk starts with one blank tile that has to be skipped.
- Right shape, cursed colours: the dancer has blue skin, and a certain very famous teal-haired vocaloid came out blonde. The chroma bytes were swapped.
- Fixed: the real byte order is V Y0 U Y1, not the more common UYVY or YUYV.
That colour fix ended up unlocking everything else, since every background tile and all of the menu's sprites use the same VYUY layout.
Sprites: the sheet doesn't know where its pixels are
Sprites were the hardest part by far.
A sprite bank in the .dat is a big array of cells. A sprite sheet (the thing that says "this logo is these cells in this grid") lives in the .bin:
struct SheetHeader { // followed by u16 cell[stride * rows]
u16 type; // always 3
u16 zero;
u16 wcode, hcode; // cell size: 8, 16, 32, 64, 128 or 256 px
u16 mode; // 2
u16 zero, zero;
};
Each cell index is relative to whichever bank the game last pointed the PPU's sprite-segment register at. And the sheet itself does not tell you which bank that is.
Here's the exact same sheet decoded against three candidate banks:

Bank 8 gives you chopped-up pieces of a completely different character. Bank 17 gives you mush (that's the font, more on that below). Bank 34 is the right one.
Now multiply that by 50 sheets in the dance game and 116 in the menu.
The extractor ended up using a pile of signals to decide which bank each sheet belongs to:
- Allocation order: sheets that were exported together sit back to back in their bank. If one sheet's cells end around 2,000, the next sheet in the
.binprobably starts around 2,000 in the same bank. - Pixel continuity: decode the sheet against every candidate bank and score how well neighbouring cells line up at their edges. Real art is smooth across cell edges. Wrong banks give you random seams.
- Roughness and blank checks: noisy results, and results that land on empty cells, get penalised.
- View records: 20-byte structs
{sheet, cols, rows, row0, col0, x, y, stride}that say which part of a sheet is on screen and how wide its rows are. Lists of icons share one record through a pointer table. - Manual overrides: two sheets in the dance game still guessed wrong, so there's an
overrides.jsonthat pins them. Sometimes the heuristic is just you.
Two more details made sprites extra spicy.
The blank marker. cell[0] of every sheet is special: the renderer skips every grid slot whose index equals cell[0]. So the top-left slot is never drawn, and neither is any slot holding that same number. In the translation kit those cells are crossed out in red, because anything you paint there will never show up on screen:

Two pixel formats. The dance game's sprites are RGB565 with 0x0000 as the transparent colour. The menu's sprites are YUV, where a pixel is transparent if its Y is 0.
The fun part: in YUV422, two neighbouring pixels share one U and one V. So a transparent pixel sitting next to an opaque one has to carry the opaque pixel's chroma. The original files have 7,675 of these mixed pairs, and the re-encoder has to do the same thing or you get coloured fringes around every letter.
A whole Chinese font hiding in a sprite bank
While mapping bank 17 of the dance game, a full 32×32, 4-bit-per-pixel font showed up: ASCII followed by a big chunk of GB2312 Chinese characters, in order.

It's used for text drawn at run time, specifically the song titles, which come from the file names in dance_note\.
So translating the song list is literally renaming files. And since the font already has ASCII, English names just work. Easiest win of the whole project. ![]()
There is no text. Only pictures of text.
Here's the catch: there are zero Chinese UI strings in either binary. Every menu item, button, judgement word and title is a picture in a sprite sheet.
Translating the game means redrawing art.
The pipeline:
- A translation table. 195 entries covering judgement words, menus, the 79 game names, 30 kids' story titles, buttons and help text. A few needed judgement calls: 喔 (the fourth judgement, between "good" and "miss") became BAD, 霓裳 and 叶秋 are character names so they became NISHANG and YE QIU, and 劲舞争霸 became DANCE BATTLE.
- Lettering that matches the art. Fonts from Google Fonts (Anton, Russo One, Titan One, Lilita One, Bungee and friends), with colours sampled from the original: gradient fill, outline colour, outer stroke, drop shadow and italic slant.
- Removing the Chinese. Where the text sits on top of artwork (banners, buttons, cards), the original glyphs get masked out and the hole is filled with OpenCV inpainting before the English goes on top.



Here's the dance game's setup screen on my actual TV, with one player or two, and a 4-key, 6-key or hands-and-feet layout:

And those exact buttons, before and after:


The menu got the same treatment: categories, every game name in the list, story titles, the help line and the camera-game cards.

Repacking: no room to grow
Drawing English is the fun part. Getting it back into the console is a packing puzzle with some very rude rules:
- Banks can't grow. Each bank's size and RAM address are hardcoded in the resource table, and the next bank starts right after it in memory.
- Cells are shared. A button's normal and highlighted sheets often reuse the same cells, and some cells are shared by several unrelated sheets.
- Blank cells are invisible. Text that spills into a blank-marked slot just doesn't get drawn.
- English is longer. 连击 is two characters. COMBO is five letters.
- Cell sizes overlap. A 64×64 cell and four 32×32 cells occupy the same bytes in the same bank, so "unused" has to be checked for every cell size at once.
So the repacker treats each bank like a tiny memory allocator:
- Pool: a cell becomes reusable only when every sheet that uses it was edited.
- Pinned: cells used by any untouched sheet stay exactly as they are.
- Deduplicated: identical new cells share storage.
- Spare cells: cells nothing references can be used, but only after checking that no sheet of any cell size points at them and that they aren't part of the font.
Then it patches the cell index arrays in the .bin to point at the new layout. That's the only change to the actual code: 73 bytes in dance.bin and 82 bytes in menucode.bin.
Some slots were simply too small. A few game names in the menu list live in 64-pixel-wide cells, so they got short names (Mines, Moles, Plumber), with the full names kept in the translation table:

Yes, some names are tiny. Blame the 64-pixel slots, not me. ![]()
Did it work?
Before going anywhere near the console, the boring checks:
- Round trip: extracting and repacking without any edits reproduces both
.datfiles byte for byte. - Re-render check: a
verify.pyscript re-renders every edited sheet from the new.binand.datand compares it with the intended image. Everything matches, except one atlas sheet that's expected to differ. - Contained diff: all
.binchanges are inside sheet index arrays, and all.datchanges are inside sprite banks. The font is untouched.
One last boss fight before the real test: the patched dance.dat is 58 MB, which was too big to hand back to my machine in one piece. So it shipped as three parts plus a JOIN_dance_dat.bat that glues them back together with copy /b and checks the SHA-256 with certutil. Peak infrastructure, again.
Then I copied four files onto the SD card, put it back in the UFO, and turned it on.
It works. ![]()




No garbled sprites anywhere I looked: every repointed cell landed where the repacker said it would, and the console didn't mind its 73 patched bytes in dance.bin one bit.

Also, an A. On a bootleg dance mat. In my living room. I'll take it.
The menu side came out just as clean:


A few things are still Chinese, and they're all expected (well, one of them is on me):
- The "HDMI高清版" badge in the corner of the dance title screen. That one is my miss. It's a separate sprite in bank 8 that the extractor never picked up as a sheet, so it never got translated.
- Titles painted into artwork, like the Food Hunter logo and the Pop Star, Pocket Sprites, Little Warrior and Fishing Master thumbnails. The text is part of the illustration itself.
- "100M RAC..." on the Running tile, clipped by the runner's hair. That's not a bug. The Chinese title was hidden behind his head in exactly the same spot.
Bonus: there's a Famicom in there
One of the menu tiles is 经典怀旧游戏, "classic nostalgic games". I assumed it would be more of the same Generalplus stuff.
It's not. classic.bin is an NES emulator. Inside it is a list of 57 F:\fcgame\*.nes paths ("FC" being the Famicom): Super Mario, Contra, Contra II, Ninja Gaiden 1 and 2, Rock Man, Battle City, Bomber Man, Ice Climber, Tetris... the whole very-much-unlicensed starter pack.
And yes, it runs them:

A Cortex-M4 dance mat, emulating a 6502, playing a 1985 penguin game on an HDMI TV. Peak bootleg. ![]()
What's left
- The HDMI badge. Find the sheet in bank 8, redraw it as "HDMI HD EDITION", repack. Small job, next on the list.
- Logos baked into artwork. Eight game icons and one card logo (美食猎人, "Food Hunter") have stylised titles painted right into the illustration. Those are a job for an AI image tool, not a script, so they're sitting in a separate folder with the original, a cell guide and a suggested English title.
- The other ~90 games. Same engine, same format. The extractor already works on another game from the card, so the rest is mostly a matter of time (and probably a few more
overrides.jsonpins). starlevel.dat. It starts withcbohcbohcboh, which smells a lot like a repeating 4-byte XOR key over a run of zeros. A puzzle for another day.
Grab the patch
Got the same mat? The patch is up on archive.org:
DSXR A870II Dance Mat - English Translation Patch

What's in it:
- BPS patches for
dance.bin,dance.dat,menucode.binandmenucode.dat. Patches only, no game files, so you apply them to the files from your own SD card. apply_patch.py, a tiny pure-Python patcher that checks the SHA-256 of every file going in and coming out. If your console has a different firmware version, it refuses instead of handing you a broken file.- The tools:
gpm4res.py(extract / repack any game on the card), the verifier and the lettering scripts. - The docs: the full
.bin/.datformat write-up, the translation table and before/after images of every translated sheet.
The quick version:
- Back up the card's
gamefolder. Seriously. - Run
python apply_patch.py D:\game, or apply each.bpsin the browser with Rom Patcher JS (rename the(patched)files back to their original names). - Copy the four English files into the card's
gamefolder. Always copy each.bintogether with its.dat.
Cheat sheet
For anyone else staring at one of these consoles:
| thing | where / what |
|---|---|
| hardware | DSXR A870II (FCC ID 2AYG3-A870II), almost certainly a Generalplus GPM4730A |
.bin | raw Cortex-M4 Thumb-2 image, vector table at 0, load address reset & 0xFFF00000 |
| main menu | game\menucode.bin, linked at 0x00E00000 |
.dat | headerless concatenation of resources |
| resource table | 16-byte {path*, dat_offset, ram_address, size} entries in the .bin's .data |
| background tiles | 32×32, YUV422, byte order V Y0 U Y1, 2 KB per tile, tile 0 empty |
| tilemaps | u32 byte offsets into the bank; width comes from the copy_tilemap call site |
| sprite sheets | in the .bin: 14-byte header + u16 cell grid; cell[0] = blank marker |
| sprite pixels | RGB565 with key 0x0000 (dance) or VYUY with Y = 0 transparent (menu) |
| song previews | streamed from the end of dance.dat, 480×480, offset computed in code |
| song names | file names in dance_note\, drawn with a built-in 32×32 4bpp font |
.nte | step charts, one bitmask byte per step |
.drm | Generalplus SPF2ALP ADPCM audio |
| classic games | classic.bin is an NES emulator loading F:\fcgame\*.nes |
The tooling is a single Python script (gpm4res.py, using numpy, Pillow and capstone) that can info, extract and repack any .bin + .dat pair, plus the lettering scripts and a verifier.
This whole thing started as "can I read the menu?"
It became "can I redraw every word on a bootleg console without growing a single file by a single byte?"
The answer is: yes, on paper. Now I just have to actually dance on it.