PlayEm is an emulator for the Bandai Playdia (1994), an obscure Japanese FMV console.
- Console: Bandai Playdia Quick Interactive System (1994)
- Main CPU: Toshiba TLCS-870 @ 8MHz
- I/O CPU: NEC µPD78214GC (78K/II family) @ 12MHz
- Video/Audio: Asahi Kasei AK8000 custom ASIC (no datasheet exists)
- Video DAC: Philips TDA8772AH Triple 8-bit (CD-i compatible)
- Media: CD-ROM XA Mode 2
make # standalone, SDL2 window
make libretro # libretro coreDependencies for the standalone: SDL2, libavcodec, libavutil, libswscale. Zips are read with the vendored miniz, so libzip is not needed.
The libretro core needs none of those - only a C compiler and libm. It is built from Makefile.libretro, which carries the platform handling:
make -f Makefile.libretro platform=unix # also osx, ios, android, emscripten, win./playem game.cue # Run with SDL2 window
./playem game.cue --headless # Run without display (600 frames)
./playem game.cue --debug # Run with per-second stats
./playem --test # CPU self-testControls: Arrow keys = D-pad, Z (or Y on QWERTZ)/X = A/B, Enter = Start, Space = Select, F1 = Fullscreen, Esc = Quit
In the libretro core the same buttons are the RetroPad's D-pad, A, B, Start and Select.
playem_libretro.so hands the frontend the 248×216 picture at 4:3, 30 fps and 44100 Hz, and takes .cue, .bin, .iso and Redump .zip by path. It sets need_fullpath, so it opens the disc itself; a path the C library cannot resolve - an Android SAF content:// URI - goes through the frontend's VFS instead, wrapped back into a FILE* (src/vfs_file.c).
Save states are not implemented: the emulator has no serialiser yet, so rewind, run-ahead and netplay are out until it grows one.
| Component | Status |
|---|---|
| TLCS-870 CPU | Substantial subset ported from MAME (BSD-3-Clause, David Haywood). Direct opcodes + e0/e8/f0/ff prefix families implemented. |
| NEC 78K/II CPU | Substantial subset of the µPD78214GC ISA per the 78K/II manual (opcode encoding cross-checked against MAME's upd78k2d disassembler — MAME has no execution core). |
| CD-ROM | CUE/BIN loading (single + multi-file), raw Mode 2/2352, ZIP support (miniz) |
| BIOS HLE | Auto-scan for GLB/AJS, FMV playback loop |
| Video decode | AK8000 proprietary DCT codec — 4×4 blocks, run/level VLC, DC prediction per macroblock (src/ak8000_pd.c) |
| Audio decode | XA ADPCM with resampling to 44100 Hz |
| Interactive | F2 commands — jumps, choices (F2 44/64), loops, quiz, timeout |
| Display | SDL2 window, or a libretro core; 248×216 picture at 4:3 |
| Pipeline | 10 sectors/frame @ 30fps (~4× CD speed) |
The Playdia uses F2 sectors with submode 0x09 for interactive control:
| Command | Function | Implementation |
|---|---|---|
| F2 40 | Unconditional jump / scene loop | Forward=auto-seek, backward=loop until button |
| F2 44 | Player choice (7 button destinations) | Wait for input, seek to chosen destination |
| F2 64 | Timed jump with a player override | Seeks by itself; a button held right then takes its own slot |
| F2 50 | Quiz answer verification | Wait for input, extra byte = correct/wrong flag |
| F2 60 | Timed jump / animation | Auto-seek with duration parameter |
| F2 80 | Timeout handler | Sets fallback destination for preceding F2 44 |
| F2 90 | Score/result display | Auto-seek, sequential counter per button |
| F2 A0 | Loop/animation control | Flag F0 = boot marker (skipped) |
Button mapping: B1=Start/default, B2=Up, B3=Down, B4=Left, B5=Right, B6=A, B7=B
MSF destination encoding: target_LBA = M×4500 + S×75 + F − 150 (binary, not BCD)
BSD 3-Clause — see LICENSE, which also lists the parts that come from elsewhere and keep their own terms: the AK8000 decoder ported from PlaydiaEmu (BSD 3-Clause), the TLCS-870 core ported from MAME (BSD 3-Clause), miniz (MIT) and the libretro API header (MIT).
The AK8000 uses a proprietary DCT-based video codec. No documentation exists anywhere — this was reverse-engineered from raw disc data across multiple games.
- F1 sectors: Video data (marker byte
0xF1, 2047 bytes payload) - F2 sectors: End of picture. The sector carries the tail of the picture from byte
0x23, then the assembled packet is decoded - F3 sectors: Scene marker (resets the accumulator). Filler - two bytes then
0xFFpadding - is not a scene change and must not reset it - Each video packet: 6–13 F1 sectors, holding one picture
Offset Content
[0-2] 00 80 04 — 19-bit picture marker (0x400), then picture type
and quantizer shift
[3] factor — quantizer scale (observed: 4–40)
[4-19] 16-byte qtable — luma quantization table
[20-35] 16-byte qtable — chroma table (equal to the luma one on every game tested)
The entropy-coded rows start at bit 36 × 8; what earlier looked like a second
marker at [36-38] is the first row marker of the picture.
The qtable is always 0A 14 0E 0D 12 25 16 1C 0F 18 0F 12 12 1F 11 14 — likely hardcoded in the AK8000 chip.
- Resolution: 248×216 pixels
- Color: YCbCr 4:2:0 — 6 blocks per macroblock (4Y + Cb + Cr)
- Macroblocks: 31×27 = 837 macroblocks, 5022 blocks total, 8×8 pixels each
- Blocks: 4×4, not 8×8; each row of macroblocks opens with a 19-bit row marker
- Y sub-block order: TL, TR, BL, BR within each 8×8 macroblock
- DC prediction: the first luma block predicts the other three and the first luma block of the next macroblock; chroma predicts per component, reset each row
- IDCT: integer 4×4 transform, pixel = IDCT(coeff × qtable × factor) + 128
Run/level codes, 63 of them, from 2 to 13 bits, each followed by a sign bit.
Two codes stand outside the table: 01 ends the block, and 001000 is the
escape, carrying a 4-bit run and a 10-bit signed level. Coefficients are placed
through the scan 0 1 4 8 5 2 3 6 9 12 13 10 7 11 14 15, and a run that pushes
past the sixteenth coefficient is an error rather than something to clamp.
The table is in src/ak8000_pd.c; it is decoded through an 8192-entry lookup
built at first use.
coeff × qtable[i] × factor, with the luma table for the four luma blocks and
the chroma table for the other two, followed by the integer 4×4 inverse
transform and +128. The trailer is at most 15 zero bits before byte-aligned
0xFF fill - anything else there means the picture is not intact.
Standard CD-ROM XA ADPCM audio:
- 18 sound groups × 128 bytes per sector
- Each group: 16 bytes header + 112 bytes sample data (28 words × 4 bytes)
- 4-bit ADPCM with 4 IIR filter modes
- Coding byte: bit 0 = stereo, bit 2 = half rate (18900 Hz vs 37800 Hz)
- Resampled to 44100 Hz via linear interpolation for SDL output
All games share identical qtable values and frame header format.
| Game | Notes |
|---|---|
| Dragon Ball Z - Uchuu-hen | Primary test game, QS=13/11/8 frames extracted |
| Aqua Adventure - Blue Lilty | Different intro, same codec |
| Ultraman Powered | Shares Bandai intro with DBZ |
| Sailor Moon S | Shares Bandai intro with DBZ |
| Elements Voice Series | Various titles tested |
4/5 games share identical Bandai logo intro. QS decreases over intro: 13→13→13→13→11→10→10→8→8→7 (quality ramp-up).
- Hardware pixel accuracy: the decoder produces the right picture, but the rounding of the inverse transform and the exact YCbCr coefficients have not been checked against hardware output.
- Eight pictures out of 39109 on Aqua Adventure fail entropy validation, in the same places PlaydiaEmu's own decoder fails. Whether the discs are like that or both decoders share a gap is unknown.
- Picture type and quantizer shift: the decoder only accepts type 1 with shift 0, which is all the tested discs carry. What else the fields can hold is unknown.
- F2 commands
0x88and0xB0appear on discs and are not implemented;0x64is implemented from its layout rather than from documentation.
Real hardware screenshots in reference/ directory:
pd_real_qis.png— QIS boot screenpd_real_suzu.png— Ie Naki Ko live-action FMVpd_real_keroppi.png— Kero Kero Keroppi animationpd_real_forest.png— Forest Sways title screen
src/cpu_tlcs870.c is a C port of MAME's
TLCS-870 core,
originally written by David Haywood under the BSD-3-Clause license.
The C port adapts MAME's C++ implementation to work without MAME's framework
(no device_t, no address_space — operates directly on a uint8_t mem[64K]
mirror provided by the host).
Coverage: all single-byte primary opcodes, plus the 0xE0..0xE7 source-prefix,
0xE8..0xEF reg-prefix, 0xF0..0xF7 dst-prefix, and 0xFF SWI families.
Flag handling matches MAME's documented JF/ZF/CF/HF semantics with the spec's
"JF = CF for ADD/SUB" rule (MAME's own implementation has a typo here; the
comment in the source documents the correct intent).
Reset reads the vector at 0xFFFE..0xFFFF. Interrupts vector through
0xFFE0..0xFFFF (16 entries, priorities 0..15 with priority 15 being RESET).
RBS-banked register file lives at 0x40 + RBS*8.
src/cpu_nec78k.c is hand-written against the NEC 78K/II Instructions User's
Manual, with opcode encoding cross-checked against MAME's upd78k2d
disassembler (MAME has no execution core for this CPU family —
upd78k2.cpp is explicitly tagged "Currently these devices are just stubs with no actual execution core.").
Implemented opcode families:
- 1-byte direct ops:
INC/DEC r,INCW/DECW rp,MOV A,r,XCH A,r,PUSH/POP rp,PUSH/POP PSW,DI/EI,RET/RETI/RETB/BRK,INCW SP / DECW SP,MOV [DE/HL +/-/=],Aand inverse,CALLT [tbl] - ALU
A,#imm / A,saddr / A,saddr_to_saddr / r,r' / AX,#word16 / AX,saddrpin all eight variants (ADD, ADDC, SUB, SUBC, AND, XOR, OR, CMP) - 16-bit ALU
ADDW / SUBW / CMPW AX,#word16 / AX,saddrp / AX,rp MOVWin all variants (rp,#word / sfrp,#word / saddrp,#word / AX,sfrp / AX,saddrp / sfrp,AX / saddrp,AX / rp,rp' / AX,[DE]/[HL] indirect)- Branches:
BR$rel8 / BR!addr16 / BNZ/BZ/BNC/BC / DBNZ r / DBNZ saddr / CALLF !addr / CALL !addr16 / CALL rp / BR rp / CALLT [tbl] - Bit ops:
SET1/CLR1/NOT1 CY / saddr.bit / PSW.bit / r.bit,MOV1/AND1/OR1/XOR1 CY,<bit>and inverse,BT/BF/BTCLR <bit>,$rel8for saddr/sfr/PSW/r forms - Shifts/rotates:
RORC/ROR/SHR/SHRW/ROLC/ROL/SHL/SHLW r/rp,nvia the 0x30/0x31 prefix - Indirect addressing:
[base+disp8](DE/SP/HL),word[A] / word[B]indexed,word[DE] / word[HL]16-bit-displaced,[DE+]/[HL+]/[DE-]/[HL-]auto-mod MULU r / DIVUW r(78K/II extensions)SEL RB0..RB3register-bank switchingMOV STBC,#imm(write-protected SFR) drives HALT/STOP via bits 0/1BRK / RETB(software interrupt + return)
Reset reads vector at mem[0x0000..0x0001]; PSW initialised to 0x02; SP
starts at 0xFEDF. Register banks at 0xFEF8/0xFEF0/0xFEE8/0xFEE0 for
RB0..RB3 per MAME's register_base() computation. CALLT table at
0x0040..0x007E.
Not yet implemented: the & (alternate-bank, prefix 0x01 0x05/0x06/0x09/0x0A/ 0x16/0x5x) expansion-memory forms. The 1 MB physical space is paged via the
external MM SFR at 0xFFC4 (host responsibility — the CPU core itself only
sees the current 64 KB window).
The syntax was recovered by PlaydiaEmu and
is implemented here in src/ak8000_pd.c. tools/pd_vcodec_test.c decodes every
picture on a disc and matches PlaydiaEmu's own playdia-frame byte for byte.
Everything from here to the end of this file is the record of the search that did not find it, kept because the negative results are still worth something. It describes what was believed at the time, not how the decoder works now.
The video bitstream encoding remained unsolved for six months. After extensive analysis including 600K+ brute-force VLC permutation tests, 225K structural parameter combinations, and comparison with multiple game-era codecs, no decode model produces recognizable images.
- Bitstream starts somewhere around bytes 40-44 of the packet
- Each frame consumes approximately 28-33% of the packet bitstream → 3 frames per packet
- The VLC produces values with a correct brightness distribution (QQ-correlation 0.96 with reference images)
- AC sections within blocks show real DCT basis function patterns
- Bytes 40-42 correlate with global frame color (Y, Cb, Cr)
- The MPEG-1 luminance DC VLC decodes without errors (but this is trivially true for ANY bitstream since it's a complete prefix code)
- The correct VLC table (MPEG-1 DC VLC is assumed but NOT confirmed)
- Whether DC and AC use the same or different VLC tables (PC-FX uses different tables)
- The DC prediction model (DPCM, offset, 2D predictor, or other)
- The AC coding model (run-level, sequential, fixed count)
- The spatial block ordering (scan order)
- The dequantization formula
- Whether level shift (+128) is applied
Over 1 million decode configurations tested including:
VLC tables: Standard MPEG-1 DC (sizes 0-16), modified size 7/8 (both 6-bit), 362K permutations of size assignments, H.261, MPEG-2, JPEG AC, Exp-Golomb, Rice coding
DC models: DPCM (continuous, per-row reset, per-MB reset), DC offset (init+diff), absolute, 2D predictor (avg left+above), vertical DPCM, various init values (0, 128, byte[40])
AC models: Sequential VLC 0=EOB, fixed count (7-63), 6-bit EOB + unary run + VLC level, combined run-level (|v|-1)/3, MPEG-1 B.14/B.15, no AC
Structural: Resolutions 128-288 width, 4:2:0/4:2:2, MB orders (row/col/zigzag/boustro + flips), Y block orders (std/col/rev), bit orders (MSB/LSB), start bytes (36-48), bit offsets (0-7)
Other codecs: PS1 MDEC, CD-i DYUV, Hadamard transform, DST, pixel DPCM, Cinepak-like VQ, 4-bit raw, 4×4 DCT sub-blocks
Correlation methods: Raw Pearson, 1D detrended (row means), 2D detrended (row+col means), Spearman rank, QQ-plot, cross-game matching, shuffle tests
- Format is 100% proprietary — no MPEG-1 start codes, no MPEG-PS pack headers, ffprobe recognizes no standard codec or container
- QIS boot logo appears on all 32 tested games — Group A (12 games, Y_init=127) and Group B (16 games, Y_init=144) with identical bitstreams within each group
- VLC value DISTRIBUTION matches reference (QQ-correlation 0.96) but spatial ordering does not — the brightness values are correct but placed at wrong 2D positions
- All high correlations proved to be DPCM drift artifacts — cumulative sums always create gradients that spuriously match reference images (1D, 2D, diagonal variants)
- VLC brute-force overfits — different packets produce different "best" VLC tables, confirming the matches are statistical coincidences, not the real VLC
- AC blocks show DCT basis patterns — horizontal/vertical gradients within 8×8 blocks consistent with DCT transform
The PC-FX RAINBOW decoder (reversed by David Michel, implemented in Mednafen) uses:
- Different VLC tables for DC vs AC and for luma vs chroma
- DPCM DC prediction, run-level AC coding
- Dequant:
coeff × qtable[pos] - Y block order: TL, BL, TR, BR (not standard TL, TR, BL, BR)
- Level shift +128
The Playdia AK8000 likely uses a similar DCT+VLC architecture but with unknown custom tables.
Nothing, as it turned out: the syntax came from PlaydiaEmu rather than from any of the approaches below. The list is kept as the record of what was tried.
- Hardware capture: Record Playdia composite video output synchronized with disc sector reads to create ground-truth input/output pairs
- AK8000 die shot: Decap the chip and analyze the decoder logic
- Japanese community contact: Someone may have reversed this already (Twitter #レトロゲーム, #プレイディア)
See git history for ~80 test tools in tools/.