m68 / teardown
SJGAM M68 · build V1.0.0 · 2026-01-22

We took a retro game stick apart from the inside.

It arrived as an SD card with five partitions. The fourth is a squashfs root filesystem, and almost everything interesting is in it. None of it matches the spec sheet the thing is sold under.

What follows is a working set of notes: what the hardware actually is, how the menu is assembled, how to add games without breaking it, and an encrypted blob that protects nothing and is never executed. Source, tooling and the long-form notes are on GitHub.

21,520rows in the shipped game database
26 / 37libretro cores present vs. referenced
582bytes of keystream guarding nothing
0callers of the anti-clone check

01 — WHAT IT ISNot the console on the box

The product page this device is sold under describes the M66: an Amlogic-class SoC with Mali-G52 graphics running EmuELEC 4.3. SJGAM's site lists no M68 at all — we enumerated every product ID on it and got M5, M8, M9, M15, M17, M18, M26, M27, M28, M30, M31, M66 and M88.

The card in our M68 contains none of those things. The emuelec/ and emulationstation/ directories in the root filesystem are empty mount points — decoys, essentially, inherited from whatever image this was forked from.

Read from the system partition, not from a listing
Build stringM68 / V1.0.0 / 2026-01-22 14:50 in /etc/version
CPU32-bit ARM Cortex-A7, Rockchip SoC (rockchip-efuse0 in sysfs)
BinariesELF 32-bit LSB ARM EABI5, armhf, /lib/ld-linux-armhf.so.3
UserlandBuildroot 2018.02-rc3, BusyBox init, kernel 4.4-era headers
UI toolkitMiniGUI — not EmulationStation, not RetroArch's own menu
Launcher/usr/bin/game, 135,832 bytes, stripped
EmulationRetroArch 1.x with libretro cores, plus PPSSPP for PSP
DisplayDRM/KMS direct to HDMI (card0-HDMI-A-1)
Card layout/sdcard → /mnt/sdcard; /data is a symlink to the same place
So are the M66 and M68 the same console? No. Different CPU family, different operating system, different interface. The M66 figures are the only ones the manufacturer publishes, and they do not describe this machine. Our sample is one unit from one build; other batches may well differ again.

02 — BOOTA door left open in the init script

BusyBox init runs /etc/init.d/rcS, which runs everything in /etc/init.d/S??* in order. The last one, S50ui, starts the interface — and contains this:

# /etc/init.d/S50ui
export XDG_CONFIG_HOME=/sdcard/
mount /dev/block/by-name/userdata /data

if [ -e "/sdcard/test" ]; then
        export LD_LIBRARY_PATH=LD_LIBRARY_PATH:/sdcard/test/lib
        export MG_CFG_PATH=/sdcard/test
        /sdcard/test/test.sh &
else
        export MG_CFG_PATH=/usr/local/share/minigui/hdmicfg
        /usr/bin/game &
fi

Create a directory called test on the card and the stock launcher never runs. You get to start anything you like — your own UI, RetroArch directly, a shell — with no change to the read-only root filesystem, and deleting the directory puts everything back. A serial console (getty on console) and adbd are both already present.

Two of the three exported variables are wrong for anything started this way, which is a good hint that this path has not been used in a while:

  • LD_LIBRARY_PATH=LD_LIBRARY_PATH:/sdcard/test/lib — the $ is missing, so the real value is replaced with a literal string.
  • MG_CFG_PATH=/sdcard/test — there is no MiniGUI.cfg there, so a MiniGUI app started from test.sh has to reset it.
There is a second hook. game_type 99 loads a libretro core from /sdcard/gmtest/test.so with /sdcard/gmtest/test.cfg. Side-loading an emulator needs no firmware modification either.

Why the picture is 720p at 50 Hz

Just before the launcher starts, S50ui runs /usr/bin/drm-hotplug.sh, which picks the HDMI mode from a hard-coded preference list:

PREFERED_MODES="1280x720 1024x 1280x1024 800x600 1920x1080 [0-9]"

720p beats 1080p whenever the TV offers both. The winner is written to /tmp/drm_mode.cfg as a bare 1280x720, and both MiniGUI and the vendor RetroArch take the first TV mode of that size. 720p50 and 720p60 run at the same pixel clock, so nothing sorts them apart: the TV's EDID order decides, and plenty of sets list 50 Hz first. The 60 Hz in every RetroArch config never gets a say.

It can be overridden from test.sh without touching the root filesystem — make_test_launcher.sh -m 1920x1080 rewrites the mode file before the launcher starts and keeps the UI drawing at 720p so the layout survives. On our unit 1080p came up, with a little flicker and no visible improvement: the menu is drawn at 720p and the games are far smaller still, so it only adds a second upscale. We went back to stock.

04 — CORESCounting the "30+ emulators"

Launching a game execs a shell script with the ROM's type number, which selects a core:

# /usr/local/share/minigui/start_game.sh
$EMU_RA -c $GAME_CFG --appendconfig "$2" \
        -L /usr/local/share/minigui/retro_lib/$GAME_LIB "$3"

The script's case statement names 37 distinct cores. The retro_lib/ directory contains 26 of them. Eleven core files are referenced and absent, covering nine emulators:

Referenced by start_game.sh, not present on the card (11 files, 9 emulators)
Missing coregame_typeSystem
mupen64plus8Nintendo 64
desmume201512Nintendo DS
fmsx37MSX
picodrive38Mega Drive / 32X
atari80016, 22Atari 800
daphne2Laserdisc arcade
mame20034, 26Arcade
prosystem_other23Atari 7800
snes9x_racing19SNES (tuned build)

A game_type whose .so is missing produces a black screen, not an error message. Going the other way, pcsx_rearmed_libretro2.so ships and nothing references it.

The N64 trap. The firmware's own ROM scanner classifies anything in the n64 folder as game_type 8 — mupen64plus, which is one of the missing eleven. The 24 N64 titles in the shipped database are all game_type 66, a standalone emulator called n2626 with its own config. Add an N64 ROM the easy way and it will not boot.

05 — DATABASE21,520 rows and one landmine

The menu is populated from /sdcard/game/games.db, a SQLite file with nine tables. The important one is tbl_game; four more hold titles in English, Simplified Chinese, Traditional Chinese and Korean, and tbl_match holds pinyin-initial search keys (拳皇97 → qh97QH97).

tbl_game, with what the columns actually mean
ColumnMeaning
gameid1-based, always equal to rowid
gameROM filename without extension
suffixExtension with the dot, e.g. .zip
timerThe ROM's directory. Yes, really.
game_typeThe start_game.sh case number → picks the core
class_typeVestigial. Mirrors game_type above 18, incoherent below it
hardDifficulty. Read, never usefully written

A column called timer that contains a filesystem path is the join key between the card and the database. We have no theory either.

The landmine

Titles are loaded like this, with no WHERE and no ORDER BY:

SELECT en_title FROM tbl_en

…into a flat array, then indexed positionally against the tbl_game array. The en_id, zh_id, ko_id and tw_id columns are never used in a join — they are decoration.

Every title table must have exactly as many rows as tbl_game, in the same order. One missing row silently shifts every title after it, and you get a library where each game is named after a different one.

While you are in there: tbl_tw contains Korean and tbl_ko contains Simplified Chinese. tbl_total claims 1,329 rows against an actual 21,520 and is ignored by the UI. tbl_video has preview clips for the first 1,099 games and dangling IDs for the other 20,421.

How a tile finds its games

Each category tile runs one query against the database, built with sprintf("/sdcard/game/%s", dirname):

SELECT rowid FROM tbl_game where timer like '/sdcard/game/snes'

There is no wildcard, so this is an exact, case-insensitive match. A game appears under a tile only if its timer is precisely that tile's directory. Counts come from the same query and the database alone: a tile will cheerfully report 540 Mega Drive games with none of the files present, and you only find out at launch, when it prints Game file not found.

The game1 folder is a dead end

Every boot, the launcher copies the database and then scans a second tree:

system("cp -a /sdcard/game/games.db /sdcard/game/games_scanf.db");
scanf_game1();   // creates /sdcard/game1/<27 systems>, appends a row per file

It looks like a drop-in folder for new ROMs, and we first wrote it up as one. It is not. Each row it appends gets timer = '/sdcard/game1/<system>', tiles only come from /sdcard/game, and no tile's query can match a game1 path. The scan dutifully adds the rows and nothing in the menu lists them. Leave the folders empty; the launcher recreates them every boot regardless.

Starting from scratch

To replace the stock library with your own selection, the ROMs and the database both go under /sdcard/game:

  1. On a PC, lay the ROMs out one folder per system using the firmware's names: roms/snes/, roms/md/, roms/cps/…
  2. build_gamesdb.py scan roms to check every folder maps to a core that is on the card.
  3. build_gamesdb.py build roms -o games.db — without --base, so none of the stock rows come along.
  4. Back up /sdcard/game, then delete its system folders and its three .db files. Any folder left behind is still a tile.
  5. Copy the system folders in, then the games.db you just built. Do not copy a games_fav.db: the launcher creates a fresh one at boot, and the old one stores favourites by row number, which a rebuild changes.
  6. Leave /sdcard/game1 empty.

06 — CRYPTO/etc/webster, decrypted

A 4,986-byte encrypted blob sits in /etc. The launcher decrypts it to /tmp/webb.sh, chmod +x's it and runs it. We expected a licence check or a dropper. It is an older copy of start_game.sh that loads its cores from the SD card instead of the root filesystem.

The cipher keys itself off three things that are all meant to be untouched on a genuine unit:

# /etc/version -> how long the keystream is and where it starts
sum     = every byte of the first two lines of /etc/version
a-b-c/d = the third line, parsed as "%d-%d-%d/%d"
klen    = (sum % 64) + d
size    = klen + 512
offset  = (b*c*d + a) % (len(S50ui) - klen - 512)

# /proc/version -> the kernel builder's user@host
key     = text between the first '(' and the next ')'

# the keystream is a window of the init script itself
block[j] = S50ui[offset + j] ^ key[j % len(key)]
plain[i] = ((cipher[i] - klen) & 0xff) ^ block[i % size]

Which is a neat idea: the keystream is a slice of /etc/init.d/S50ui, XORed with the username of whoever built the kernel. Edit the init script, edit the version file, or boot a kernel compiled somewhere else, and the output turns to noise. It is a tamper detector wearing a cipher's clothing.

Recovering it without the device took an index-of-coincidence scan for the period, a #!/bin/sh crib to find the offset, and a per-position solve for the key:

Recovered parameters — re-encrypting reproduces the blob byte for byte
Keystream size582
Offset into S50ui2215
Subtracted byte70
Key from /proc/versionPercival@dell-build-sz-35

The recovered keystream repeats with a clean period of 25 across all 582 bytes, which also proves the S50ui on our card is byte-identical to the one on the vendor's build machine. Somewhere there is a Dell in Shenzhen, and a person or service account called Percival.

This firmware cannot decrypt its own blob. The shipped /etc/version derives size 544, offset 11 — not the 582/2215 the file needs. It was encrypted against a different version string and never re-generated. On this unit it would decrypt to garbage, get chmod +x'd, and fail to run.

It is also moot, because nothing calls it. See below.

07 — DEAD CODEThings that were wired up and unplugged

The anti-clone check

Three functions sit next to each other in game: the webster decryptor (0x2a898), its caller (0x2aca0), and a hardware check (0x2abcc). None of them is reachable — no bl targets them and their addresses appear nowhere as data. The live path is enterGame at 0x18d40, which calls start_game.sh like everything else.

Which is a pity, because the hardware check is the most entertaining function in the binary:

fd = open("/sys/bus/nvmem/devices/rockchip-efuse0/nvmem", O_RDONLY);
if (fd < 0)                         system("poweroff");
if (lseek(fd, 7, SEEK_SET) < 0)      system("poweroff");
read(fd, buf, 2);
if (buf[0] != 'C' && buf[1] != 'X')  system("poweroff");

Two bytes at offset 7 of the Rockchip eFuse must be 'C' or 'X'. Note the OR — either byte passing is enough. Note also that system("poweroff") does not abort, so control falls through regardless and the box keeps running while the shutdown request makes its way through init. An anti-clone measure that is both half-written and disconnected.

A whole second interface

/usr/bin/lvui is a 10 MB LVGL application that S50ui never launches. It links sqlite3, ALSA and libstdc++ but not MiniGUI, draws to /dev/fb0 rather than DRM, and reads /proc/ov7251 — an OmniVision camera sensor this device does not have. It has music, video and photo apps alongside the game launcher. Its artwork lives in /usr/local/share/lvgl/{picture,game,music,video}/, of which this firmware ships exactly one file: nosdbg.png. It is a handheld's firmware that came along for the ride.

Odds and ends

  • /proc/secic — a 32-byte challenge/response against a security chip: write, sleep 15 ms, read back. Returns success if the node is absent, so probably not a hard gate.
  • A custom base64 alphabet, abcdefghij…WXYZ8923456701+/, sits in .rodata with nothing referencing it.
  • The button-remap screen writes to /sdcard/w20c/minigui/retroarch_hdmi.cfg. There is no w20c directory in this firmware's layout.
  • start_game.sh is salted throughout with comments like # M1nO4pQ7rS2tU6vW9xY3zA5bC8dE1fG6hI2jK7mN0pQ9rS3tU5vW8xY2zA7bC4dE, apparently as watermarking.

08 — TOOLINGWhat's in the repo

Everything above is reproducible from the card. The repository carries the notes, the recovered plaintext and four scripts:

  • NOTES.md The long-form teardown: boot path, menu construction, full database schema, the folder → core mapping table, and the crypto, with addresses.
  • tools/build_gamesdb.py Builds or extends a games.db from a ROM tree, keeping the title tables row-aligned so you do not trip the landmine, and correcting the core assignments the firmware's own scanner gets wrong. Rebuilding with --base reproduces all 21,520 stock rows byte-identically.
  • tools/make_test_launcher.sh Writes the /sdcard/test/ override onto a card. Logs each boot to the card, and falls back to the stock launcher if the target dies within 15 seconds, so a failed experiment cannot leave a black screen with no way back in. -m 1920x1080 forces the HDMI mode and logs what the TV offers.
  • tools/make_blank_dbs.py Writes an empty games.db and games_fav.db with the stock schema. Not for setting up a card: an empty games.db gives a menu with nothing in it.
  • tools/webster.py Decrypts and re-encrypts /etc/webster, prints the derivation for any root filesystem, and recovers the parameters from scratch without /proc/version.
  • webster-decrypted.sh The plaintext, for anyone who would rather just read it.

The tooling and these notes are MIT licensed. Nothing that came off the device is: no ROMs, copyrighted game data or firmware images are distributed.

09 — THE HARDWAREFor completeness

Manufacturer imagery from the M66 listing — the page this device is sold under, whether or not it describes it.

The game stick alongside its red and blue wireless controllers
Stick and controllers
The stick with its end cap removed, exposing the HDMI connector
HDMI connector, cap off
Views of the housing showing the ports along its edges
Housing and ports
Close view of the two supplied controllers
Controllers
Retail packaging for the console
Retail packaging
The full bundle: stick, controllers, cables and manual
Bundle

10 — ANSWERSQuestions we had first

Can I brick it by experimenting?

The root filesystem is a read-only squashfs on its own partition, and everything described here is driven from the writable data partition. The realistic failure is a black screen from a launcher override that does not come up, which is recovered by deleting /sdcard/test on a PC. Reflashing partitions is a different risk and not something we have tested.

Does it really have 21,520 games?

It has 21,520 database rows. A row is an index entry, not a guarantee that the file exists or that a core can run it, and a large share are duplicates and regional variants of the same titles. The 13,506 rows of game_type 1 are NES.

Will it run PS2, Dreamcast or N64?

There are menu icons for Dreamcast and Naomi and no emulator for either. N64 works only through the standalone n2626 emulator at game_type 66; the libretro N64 core the scanner assigns is not on the card. PlayStation 1 works, via pcsx_rearmed. PSP works, via PPSSPP.

Why only 720p, and why 50 Hz?

A boot script prefers 1280x720 and matches the mode on size alone, so the refresh rate is whichever the TV lists first — see section 02. 1080p can be forced; on our unit it flickered a little and looked no better.

Is this the same as the M66?

No — see section 01. It is worth saying plainly because the two are sold under near-identical names and photographs while the hardware inside differs.

Who is Percival?

No idea. The account that built the kernel, on a machine called dell-build-sz-35. Hello.