Skip to content

Instantly share code, notes, and snippets.

@dogtopus
Last active January 19, 2024 21:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dogtopus/da9f2977d1ca1f75a4f2aef71e356ac5 to your computer and use it in GitHub Desktop.
Save dogtopus/da9f2977d1ca1f75a4f2aef71e356ac5 to your computer and use it in GitHub Desktop.
Don't use. Just refer to MAME mononcol instead.

Moonoon Color Technical Specs

Hardware

Basic specs

CPU: AppoTech AX208 AXC51 (8051-compatible with 16-bit extended instruction set) microcontrolle @ ~~100MHz~~ 96MHz?
RAM: 15KB (12K IRAM + 1K Data RAM + 2K PRAM)
Display: 2.4" 320x240 TFT w/ backlight
Communication: Built-in IR port
USB: No (USB pinouts have been found on the motherboard, no tests have been performed yet)
Input Voltage: 4.5V (3x AA batteries)

(TODO: Verify if it actually uses the same ISA as this SD controller that bunnie talked about seems not)

AX208 Product Page

AX208 Datasheet (Incomplete)

Cartridge

Moonoon Color uses 25QXX series (more specifically, 25Q64 and 25Q128) SPI Flash chip as storage media for both ROM and savegame. ROM dumping and flashing have been successfully performed using a SPI programmer.

Coprocessor

Background music is handled by the coprocessor microcontroller under the blob. The coprocessor outputs audio using 2-pin PWM similar to how HitClips work.

Coprocessor could be based on 6502 (specifically 65ce02) judging from the challenge/response command returning 6502 program fragments. (Blob, large enough EPROM that can hold audio samples, 6502, microcontroller, toy-oriented. Wild guess: GeneralPlus?)

See pwm2pcm_poc.py for a crude but pretty good quality software decoder for signals coming out of PWM1/2.

Speed

The ROM bus seems to run at 48/72MHz depending on the data being loaded. The coprocessor bus is running at around 4.8kHz.

Peripheral - badge and Card Fighter scanner

The scanner itself is a simple reflective IR sensor. Exact part number is unknown.

The IR sensor is controlled by a separate microcontroller. This microcontroller seems to connect to the auxiliary bus alongside the coprocessor.

TODO: figure out the protocol.

Emulating

Output can be easily spoofed via an IR blaster. Default parameter (38kHz carrier wave, 0.33 duty cycle) for Flipper Zero works reasonably well (as in 100% correct recognition out of around 10 tries) but just DC pulsing (high = constantly on, low = constantly off) might also work good.

The pulse timing rule is as follows:

  • 10000us for preamble and tail (high pulses)
    • tail pulse needs to be merged with the high pulse of the last line, e.g. 13000 for mark and 17000 for space
  • (7000, 3000)us (low, high) for thick lines (mark)
  • (3000, 7000)us (low, high) for thin lines (space)

Example for Roco Kingdom: The King's Badge:

Filetype: IR signals file
Version: 1
# 
name: Sprigatito
type: raw
frequency: 38000
duty_cycle: 0.330000
data: 10000 7000 3000 3000 7000 3000 7000 3000 7000 3000 7000 3000 7000 3000 7000 3000 7000 3000 7000 7000 3000 7000 3000 3000 7000 3000 17000

Cartridge slot

Moonoon Color uses generic SATA socket (with both 7-pin data connector and 15-pin power connector) as its cartridge slot. However, the pinout is not compatible with the SATA standard.

The following figure shows the pinout of Moonoon Color cartidge slot:

- NC
- NC
- NC
- NC
- NC
- NC
- NC

- GND
- GND
- UNK1
- SPI_CLK
- SPI_MOSI
- SPI_MISO
- SPI_CS
- PWM1 (Positive half)
- PWM2 (Negative half)
- P25 (COPRO_CLK)
- VPP
- P27 (COPRO_DO)
- P21 (COPRO_DI)
- GND
- VDD  

Software

ROM structure

(TODO fill this)

0x00000000:0x00010000: Game script VM (AXC51 code)
  0x0000e000:0x0000f000: Coprocessor challenge/response table (65ce02 code fragment)
0x00050000:0x00070000: Save area

Badge barcode format

tl;dr

1 <id:9> <parity:1> 00

Slightly longer version

(Here 1 means mark and 0 means space)

The barcode has a prologue pattern (fixed 1) and an epilogue pattern (fixed 00) used to detect the scanning orientation. The id is then simply encoded as bits (MSB first) and a single parity bit is calculated from XORing together all 9 bits in the id and added to the end of the id bitstream.

See mononcol_barcode.py for a script that generates all possible barcodes (including the unused/invalid ones) as a single Flipper Zero IR signal file.

from typing import Sequence, Literal
import argparse
import sys
import contextlib
import pathlib
MARK = (7000, 3000)
SPACE = (3000, 7000)
TABLE = (SPACE, MARK)
PREAMBLE_SIZE = 10000
PROLOGUE = MARK
EPILOGUE = SPACE * 2
PROLOGUE_BITS_BADGE = [1]
PROLOGUE_BITS_CF = [1, 1]
EPILOGUE_BITS = [0, 0]
FLIPPER_IR_HEADER = '''
Filetype: IR signals file
Version: 1
'''.strip()
FLIPPER_IR_TEMPLATE = '''
#
name: {name}
type: raw
frequency: 38000
duty_cycle: 0.330000
data: {sequence}
'''.strip()
def anybaserange(str_: str) -> tuple[int, int | None]:
if ':' in str_:
try:
start_str, end_str = str_.split(':')
except TypeError:
raise ValueError(f'Invalid data range {repr(str_)}')
try:
start = int(start_str, 0) if start_str != '' else 0
# The actual end range of an open-end cannot be determined here. Use None instead.
end = int(end_str, 0) if end_str != '' else None
except ValueError:
raise ValueError(f'Invalid data range {repr(str_)}')
else:
try:
start = int(str_, 0)
except ValueError:
raise ValueError(f'Invalid data range {repr(str_)}')
end = start + 1
return (start, end)
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('-b', '--bit-length', type=int, default=9, help='Bit length (default: 9)')
p.add_argument('-t', '--barcode-type', choices=('card-fighter', 'badge'), default='badge',
help='Barcode type.')
p.add_argument('-f', '--format', choices=('flipper-timings', 'flipper-file', 'bits'), default='flipper-file',
help='Output format.')
p.add_argument('-o', '--output-file', help='Output file (omit to output to stdout)')
p.add_argument('-r', '--range', nargs='*', type=anybaserange, default=[],
help='Range of data (either a Python range in x:y format or a single number)')
return p, p.parse_args()
@contextlib.contextmanager
def open_or_stdout(filename=None):
if filename and filename != '-':
fh = open(filename, 'w')
else:
fh = sys.stdout
try:
yield fh
finally:
if fh is not sys.stdout:
fh.close()
def parity(bits: int) -> int:
parity = 0
while bits != 0:
parity ^= bits & 1
bits >>= 1
return parity
def int_to_bits_list(bits: int, bitlen: int = 9) -> tuple[int]:
bitmask = (1 << bitlen) - 1
return tuple(int(c) for c in f'{{:0{bitlen:d}b}}'.format(bits & bitmask))
def generate_bitseq_from_bits(bits: int, bitlen: int = 9, type_: Literal['card-fighter', 'badge'] = 'badge') -> list[int]:
result = []
if type_ == 'card-fighter':
result.extend(PROLOGUE_BITS_CF)
elif type_ == 'badge':
result.extend(PROLOGUE_BITS_BADGE)
else:
raise ValueError(f'Unknown barcode type {type_}')
for bit in int_to_bits_list(bits, bitlen):
result.append(bit)
result.append(parity(bits))
result.extend(EPILOGUE_BITS)
return result
def generate_timing_seq(bits: Sequence[int]) -> list[int]:
result = [PREAMBLE_SIZE]
for bit in bits:
result.extend(TABLE[bit])
result[-1] += PREAMBLE_SIZE
return result
def timing_seq_to_flipper(sequence: Sequence[int]):
return ' '.join(str(us) for us in sequence)
def do_flipper_timings(name: str, bits: int, bitlen: int, type_: Literal['card-fighter', 'badge']):
result = timing_seq_to_flipper(generate_timing_seq(generate_bitseq_from_bits(bits, bitlen, type_)))
return f'{name}: {result}'
def do_flipper_file(name: str, bits: int, bitlen: int, type_: Literal['card-fighter', 'badge']):
result = timing_seq_to_flipper(generate_timing_seq(generate_bitseq_from_bits(bits, bitlen, type_)))
return FLIPPER_IR_TEMPLATE.format(name=name, sequence=result)
def do_bits(name: str, bits: int, bitlen: int, type_: Literal['card-fighter', 'badge']):
result = ''.join(str(b) for b in generate_bitseq_from_bits(bits, bitlen, type_))
return f'{name}: {result}'
DATA_FORMATTERS = {
'flipper-file': do_flipper_file,
'flipper-timings': do_flipper_timings,
'bits': do_bits,
}
def main():
p, args = parse_args()
max_id = 1 << args.bit_length
with open_or_stdout(args.output_file) as out_file:
if len(args.range) == 0:
args.range.append((0, max_id))
if args.format == 'flipper-file':
out_file.write(FLIPPER_IR_HEADER)
out_file.write('\n')
for start, end in args.range:
if end is None:
end = max_id
for bits in range(start, end):
name = f'{{:0{len(str(max_id - 1))}d}}'.format(bits)
out_file.write(DATA_FORMATTERS[args.format](name, bits, args.bit_length, args.barcode_type))
out_file.write('\n')
if __name__ == '__main__':
main()
# coding: utf-8
# F-tier DSP shamelessly stolen from that HitClips script
# Expected format: 16-channel raw binary output from sigrok.
# Ch0: PWM1, Ch1: PWM2, Sample rate: 100MHz
# Outputs 32bit float samples @ 40kHz sample rate.
# Usually needs manual SoXing before playback.
import io
import numpy
import scipy.signal
import mmap
with open('pwm.bin', 'rb') as f, open('pcm.f32.bin', 'wb') as f2:
mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
block_size = 120_000_000
sample_size = block_size // 2 # int16
overlap_size = 10_000_000
overlap_samples = overlap_size // 2
headtail_size = overlap_size * 2
source_sample_rate = 100_000_000
target_sample_rate = 40_000
q = source_sample_rate // target_sample_rate
target_real_sample_end = (sample_size - overlap_samples) // q
target_real_sample_start = overlap_samples // q
include_head = True
tail = None
offset = 0
if mm.size() % 2 != 0:
raise RuntimeError('File not aligned')
while offset <= mm.size():
buf = mm[offset:offset+block_size]
offset += block_size - headtail_size
npa = numpy.frombuffer(buf, dtype=numpy.int16, count=len(buf)//2)
npaf = numpy.zeros(len(npa), dtype=numpy.float32)
# Positive pulse -> 1, negative pulse -> -1, both pulse or no pulse -> 0
npaf[npa == 1] = 1.0
npaf[npa == 2] = -1.0
npad = scipy.signal.decimate(npaf, q, ftype='fir')
if include_head:
f2.write(npad[:target_real_sample_end].tobytes())
include_head = False
else:
f2.write(npad[target_real_sample_start:target_real_sample_end].tobytes())
tail = npad[target_real_sample_end:]
if tail is not None and len(tail) > 0:
f2.write(tail.tobytes())
READ 12k @ 0x200
READ 512B @ 0x4c00
READ 512B @ 0x4e00
READ 512B @ 0x5000
READ 512B @ 0x5200
READ 512B @ 0x5400
(stage 2 is illegible due to my LA being too slow to keep up. Definitely >12.5MHz, probably also >25MHz)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment