From 1ef0d697a62eff28115d6642c850ba4d01ef6a89 Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Fri, 16 Sep 2022 07:34:44 +0100 Subject: Added CAS support to the example game --- .gitlab-ci.yml | 1 + CHANGES.md | 2 + Makefile | 6 +- README.md | 22 +++++ TODO.md | 1 - docs/tools.md | 32 +++++++ game/Makefile | 5 +- game/data/loading.png | Bin 0 -> 8188 bytes game/src/Makefile | 26 +++++- game/src/cas/loader.bas | 1 + game/src/cas/loader.z80 | 219 ++++++++++++++++++++++++++++++++++++++++++++++++ game/src/crt0.z80 | 115 +++++++++++++------------ tools/mkcas.py | 164 ++++++++++++++++++++++++++++++++++++ tools/png2scr.py | 140 +++++++++++++++++++++++++++++++ 14 files changed, 675 insertions(+), 59 deletions(-) create mode 100644 game/data/loading.png create mode 100644 game/src/cas/loader.bas create mode 100644 game/src/cas/loader.z80 create mode 100755 tools/mkcas.py create mode 100755 tools/png2scr.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6c232e7..3147c8b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,6 +14,7 @@ compile: stage: build script: - make game + - make game-cas test: stage: test diff --git a/CHANGES.md b/CHANGES.md index 28436bc..421f4c8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ ## Release 1.1.12 - 2022-??-?? + - Added CAS support to the example game, introducing new tools: `mkcas.py` and + `png2scr.py`. - Fix in `png2sprites.py` and `png2tiles.py`: ensure the order of the colours is always the same diff --git a/Makefile b/Makefile index e97a66c..b467286 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,10 @@ game: bin libs make -C tools make -C game all +game-cas: bin libs + make -C tools + make -C game cas + test: game make -C tests test @@ -32,7 +36,7 @@ ap: bin/apultra: bin make -C tools ../bin/apultra -.PHONY: clean cleanall docs libs game +.PHONY: clean cleanall docs libs game game-cas clean: make -C src/ubox clean make -C src/spman clean diff --git a/README.md b/README.md index dfbd8f3..301e856 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ An example game is included with the libraries and it can be built with: After a successful build, the game ROM should be in `./bin`. +#### CAS support + +Although the focus is cartridge ROMs, CAS files (and audio) is still one of the +cheapest ways of loading homebrew games on a real MSX. + +Optionally, is possible to generate a CAS file of the example game running: + + make game-cas + +After a successful build, the game CAS should be in `./bin`. + +The CAS support has some limitations: + + - It requires 32K of extra RAM. + - The compressed ROM must be less than 24576 bytes. + - The loader uses the BIOS, so it won't be fast. + - Machines with disk must have it disabled (by pressing shift on boot), to + have more memory available. + +Despite these limitations, it is worth considering releasing your game in CAS +format as well as cartridge ROM. + ### Building the docs The documentation is available at diff --git a/TODO.md b/TODO.md index cc75e31..e919fa8 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,6 @@ Short/mid term: - Add util tools/libs (e.g. compression) - Improve MSX 1 support - Support 48K ROMs - - CAS support - Some sort of cookie-cutter support to easily start new projects Long term: diff --git a/docs/tools.md b/docs/tools.md index 19705a7..67b797a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -197,6 +197,38 @@ The entities are encoded in a stream that ends with the byte `0xff` as terminato Refer to the example game and `init_map_entities()` in `game.c` for a full example. +### mkcas.py + +This is a simple tool to make CAS files to be used in different MSX emulators. + +It supports the following block types: + + - binary: binary executable data, include loading and execution addresses. + - basic: tokenized BASIC code. + - ascii: ASCII text, can be BASIC for example. In case of BASIC code, the CR + LF is expected as end of line. + - custom-header: no block type, header with loading address and block length + followed by the data. + - custom: no block type, data stored "as-is". + +Use `-h` flag to get command line help, and check the example game for sample on +how to build a CAS file with a loading screen. + +### png2scr.py + +This tools converts an PNG RGB image into a Screen 2 SCR image. + +The image is expected to use an approximation to RGB for the colours in the +Toshiba palette (see `png2tiles.py` for the palette values). + +The image must be 256x192 pixels. + +The SCR file is 768 background tiles (6144 bytes) followed by the +background colours for the 3 areas of the screen (6144 bytes), that can be +uploaded to the VDP "as is". + +Check the example game for a sample on how to use it. + ## Build helpers ### chksize diff --git a/game/Makefile b/game/Makefile index 3fe43f3..347d37b 100644 --- a/game/Makefile +++ b/game/Makefile @@ -6,6 +6,9 @@ all: generated bin build cd src && ../../tools/mkdeps.py -b ../build -I ../generated:../../include ./ Makefile.deps make -C src all +cas: all + make -C src cas + generated: mkdir -p ./generated @@ -18,7 +21,7 @@ build: openmsx: all make -C src openmsx -.PHONY: all clean default +.PHONY: all cas clean default clean: rm -rf ./generated ./bin ./build make -C src clean diff --git a/game/data/loading.png b/game/data/loading.png new file mode 100644 index 0000000..ce196ef Binary files /dev/null and b/game/data/loading.png differ diff --git a/game/src/Makefile b/game/src/Makefile index b05ef4e..3ca267a 100644 --- a/game/src/Makefile +++ b/game/src/Makefile @@ -32,6 +32,9 @@ all: $(TMPDIR)/$(TARGET).rom @$(CHKSIZE) $(ROM_MAX) 4000 $(TMPDIR)/$(TARGET).map @cp $(TMPDIR)/$(TARGET).rom $(OUTDIR) +cas: $(TMPDIR)/$(TARGET).cas + @cp $(TMPDIR)/$(TARGET).cas $(OUTDIR) + openmsx: all openmsx -carta $(OUTDIR)/$(TARGET).rom -machine msx1 @@ -51,10 +54,31 @@ $(TMPDIR)/$(TARGET).rom: $(OBJS) $(TMPDIR)/crt0.rel $(UBOX_LIBS) hex2bin -e bin -p 00 -l $(ROM_MAX) $(TMPDIR)/$(TARGET).ihx @cp $(TMPDIR)/$(TARGET).bin $(TMPDIR)/$(TARGET).rom +LOADER_ADDR=0xe000 +LOADER_WORK_ADDR=0x8000 +$(TMPDIR)/$(TARGET).cas: $(TMPDIR)/$(TARGET).rom cas/loader.bas $(TMPDIR)/loader.bin $(TMPDIR)/loading.ap + apultra -v $(TMPDIR)/$(TARGET).rom $(TMPDIR)/$(TARGET).ap + ../../tools/mkcas.py --name $(TARGET) $@ ascii cas/loader.bas + ../../tools/mkcas.py -a --name loader --addr $(LOADER_ADDR) --exec $(LOADER_ADDR) $@ binary $(TMPDIR)/loader.bin + ../../tools/mkcas.py -a --addr $(LOADER_WORK_ADDR) $@ custom-header $(TMPDIR)/loading.ap + ../../tools/mkcas.py -a --addr $(LOADER_WORK_ADDR) $@ custom-header $(TMPDIR)/$(TARGET).ap + +$(TMPDIR)/loader.bin: $(TMPDIR)/loader.rel + $(CC) $(CFLAGS) $(LDFLAGS) --code-loc $(LOADER_ADDR) --data-loc 0 -lap $< -o $(TMPDIR)/loader.ihx + hex2bin -p 00 $(TMPDIR)/loader.ihx + +$(TMPDIR)/loading.ap: ../data/loading.png + ../../tools/png2scr.py ../data/loading.png > $(TMPDIR)/loading.bin + apultra -v $(TMPDIR)/loading.bin $(TMPDIR)/loading.ap + +$(TMPDIR)/loader.rel: cas/loader.z80 + $(AS) -g -o $@ $< + clean: rm -f $(TMPDIR)/* rm -f $(OUTDIR)/$(TARGET).rom + rm -f $(OUTDIR)/$(TARGET).cas -.PHONY: all clean +.PHONY: all cas clean include Makefile.deps diff --git a/game/src/cas/loader.bas b/game/src/cas/loader.bas new file mode 100644 index 0000000..c94a478 --- /dev/null +++ b/game/src/cas/loader.bas @@ -0,0 +1 @@ +10 BLOAD"cas:",R diff --git a/game/src/cas/loader.z80 b/game/src/cas/loader.z80 new file mode 100644 index 0000000..d7b058c --- /dev/null +++ b/game/src/cas/loader.z80 @@ -0,0 +1,219 @@ +; +; TAPE LOADER USING THE BIOS +; + +TAPION = 0x00e1 +TAPIN = 0x00e4 +TAPIOF = 0x00e7 +DISSCR = 0x0041 +ENASCR = 0x0044 +LDIRVM = 0x005c +CHGMOD = 0x005f +CHGCLR = 0x0062 +FORCLR = 0xf3e9 +BAKCLR = 0xf3ea +BDRCLR = 0xf3eb +CHPUT = 0x00a2 +TOTEXT = 0x00d2 +ENASLT = 0x0024 +RSLREG = 0x0138 + + .area _CODE + di + ; init the stack + ld sp, #0xf380 + + ; sslot fix + ld a, (0xffff) + cpl + and #0xf0 + ld c, a + rrca + rrca + rrca + rrca + or c + ld (0xffff),a + + in a, (#0xa8) + + ; map RAM on 0x8000 + and #0xcf + ld c, #16 + ld hl, #0x8000 + call select_ram + + ; map RAM on 0x4000 + and #0xf3 + ld c, #4 + ld hl, #0x4000 + call select_ram + + ei + + ; now we have: ROM RAM RAM RAM + + ; set these to black + ld a, #1 + ld (FORCLR), a + ld (BAKCLR), a + ld (BDRCLR), a + call CHGCLR + + ; load the loading screen + call load_custom_block + + ld hl, (block_addr) + ld de, #0x4000 + call ap_uncompress + + call upload_screen + + ; load the code + call load_custom_block + + ld hl, (block_addr) + ld de, #0x4000 + call ap_uncompress + + ; cas main + jp 0x4010 + +upload_screen: + ld a, #2 + call CHGMOD + + call DISSCR + + ld hl, #0x4000 + ld de, #0 + ld bc, #256 * 8 + call LDIRVM + + ld hl, #0x4000 + 256 * 8 + ld de, #256 * 8 + ld bc, #256 * 8 + call LDIRVM + + ld hl, #0x4000 + 256 * 8 * 2 + ld de, #256 * 8 * 2 + ld bc, #256 * 8 + call LDIRVM + + ld hl, #0x4000 + 256 * 8 * 3 + ld de, #0x2000 + ld bc, #256 * 8 + call LDIRVM + + ld hl, #0x4000 + 256 * 8 * 4 + ld de, #0x2000 + 256 * 8 + ld bc, #256 * 8 + call LDIRVM + + ld hl, #0x4000 + 256 * 8 * 5 + ld de, #0x2000 + 256 * 8 * 2 + ld bc, #256 * 8 + call LDIRVM + + jp ENASCR + +load_custom_block: + call TAPION + jp c, tape_error + + ld bc, #4 + ld hl, #block_addr + call load_block + + ld bc, (block_len) + ld hl, (block_addr) + call load_block + + jp TAPIOF + +load_block: + ld a, r + and #15 + out (#0x99), a + ld a, #0x87 + nop + nop + out (#0x99), a + + push bc + push hl + call TAPIN + pop hl + pop bc + jr c, tape_error + + ld (hl), a + inc hl + + dec bc + ld a, b + or c + jr nz, load_block + + ld a, #1 + out (#0x99), a + ld a, #0x87 + nop + nop + out (#0x99), a + ret + +select_ram: + ld b, #4 +select_ram_loop: + out (#0xa8), a + ld (hl), a + cp (hl) + ret z + add a, c + djnz select_ram_loop + +memory_error: + ld hl, #mem_err_message + jp display_error + +tape_error: + ld hl, #tape_err_message + +display_error: + push hl + call TOTEXT + + ld a, #1 + ld (BAKCLR), a + ld (BDRCLR), a + ld a, #6 + ld (FORCLR), a + call CHGCLR + + pop hl +print_loop: + ld a, (hl) + or a + jr z, halt0 + inc hl + call CHPUT + jr print_loop +halt0: + halt + jr halt0 + +tape_err_message: + .str "TAPE READ ERROR" + .db 0 +mem_err_message: + .str "MEMORY INIT ERROR" + .db 0 + + .area _DATA + +block_addr: + .dw 2 +block_len: + .dw 2 + diff --git a/game/src/crt0.z80 b/game/src/crt0.z80 index 81763d0..c8bd167 100644 --- a/game/src/crt0.z80 +++ b/game/src/crt0.z80 @@ -1,82 +1,87 @@ .module crt0 -.globl _main -.area _HOME -.area _CODE -.area _INITIALIZER +.globl _main + +.area _HOME +.area _CODE +.area _INITIALIZER .area _GSINIT .area _GSFINAL -.area _DATA -.area _INITIALIZED -.area _BSEG +.area _DATA +.area _INITIALIZED +.area _BSEG .area _BSS .area _HEAP -.area _CODE - ENASLT = 0x0024 RSLREG = 0x0138 CLIKSW = 0xf3db - ; ROM header - .str "AB" - .dw _main_init - .db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +.area _CODE + ; ROM header + .str "AB" + .dw _main_init + .db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + + ; entry point skipping the init code that is used + ; when starting from cassette + jp _cas_main _main_init:: - ; init the stack - di - ld sp, #0xf380 - ei + ; init the stack + di + ld sp, #0xf380 + ei - ; setup memory - ; ref: https://www.msx.org/forum/msx-talk/development/memory-pages-again + ; setup memory + ; ref: https://www.msx.org/forum/msx-talk/development/memory-pages-again - call RSLREG - rrca - rrca - and #3 - ld c, a - add a, #0xc1 - ld l, a - ld h, #0xfc - ld a, (hl) - and #0x80 - or c - ld c, a - inc l - inc l - inc l - inc l - ld a, (hl) - and #0x0c - or c - ld h, #0x80 - call ENASLT + call RSLREG + rrca + rrca + and #3 + ld c, a + add a, #0xc1 + ld l, a + ld h, #0xfc + ld a, (hl) + and #0x80 + or c + ld c, a + inc l + inc l + inc l + inc l + ld a, (hl) + and #0x0c + or c + ld h, #0x80 + call ENASLT - ; disable key click sound - xor a - ld (CLIKSW), a +_cas_main:: + ; disable key click sound + xor a + ld (CLIKSW), a - call gsinit - call _main + call gsinit + call _main halt0: - halt - jr halt0 + halt + jr halt0 .area _GSINIT gsinit:: - ld bc, #l__INITIALIZER - ld a, b - or a, c - jr Z, gsinit_next - ld de, #s__INITIALIZED - ld hl, #s__INITIALIZER - ldir + ld bc, #l__INITIALIZER + ld a, b + or a, c + jr Z, gsinit_next + ld de, #s__INITIALIZED + ld hl, #s__INITIALIZER + ldir gsinit_next: .area _GSFINAL - ret + ret diff --git a/tools/mkcas.py b/tools/mkcas.py new file mode 100755 index 0000000..267270d --- /dev/null +++ b/tools/mkcas.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2022 by Juan J. Martinez +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +__version__ = "1.1" + +import os +from argparse import ArgumentParser + +DEF_ADDR = 0x4000 +TYPES = ("binary", "basic", "ascii", "custom-header", "custom") +TYPE_BLOCK = { + "binary": bytes((0xD0 for _ in range(10))), + "basic": bytes((0xD3 for _ in range(10))), + "ascii": bytes((0xEA for _ in range(10))), +} + +BLOCK_ID = bytes((0x1F, 0xA6, 0xDE, 0xBA, 0xCC, 0x13, 0x7D, 0x74)) + + +def write_word(fd, word): + fd.write(bytes((word & 0xFF, word >> 8))) + + +def auto_int(value): + return int(value, 0) + + +def main(): + + parser = ArgumentParser( + description="Make a CAS file for the MSX", + epilog="Copyright (C) 2020-2022 Juan J Martinez ", + ) + + parser.add_argument( + "--version", action="version", version="%(prog)s " + __version__ + ) + parser.add_argument( + "-a", + "--add", + dest="add", + action="store_true", + help="append to the existing CAS file instead of creating a new one", + ) + parser.add_argument( + "--name", + dest="name", + default=None, + type=str, + help="name to use for the file (limit 6 chars, defaults to the file name)", + ) + parser.add_argument( + "--addr", + dest="addr", + default=DEF_ADDR, + type=auto_int, + help="address to load if binary file (default: 0x%04x)" % DEF_ADDR, + ) + parser.add_argument( + "--exec", + dest="exec", + default=DEF_ADDR, + type=auto_int, + help="address to exec if binary file (default: 0x%04x)" % DEF_ADDR, + ) + + parser.add_argument("output", help="target .CAS file") + parser.add_argument("type", help="file type", choices=TYPES) + parser.add_argument("file", help="input file") + + args = parser.parse_args() + + if args.add: + flags = "ab" + else: + flags = "wb" + + with open(args.output, flags) as out: + + if args.name: + name = args.name + else: + name = os.path.basename(args.file) + + with open(args.file, "rb") as inf: + data = inf.read() + + out.write(BLOCK_ID) + + if args.type == "ascii": + + out.write(TYPE_BLOCK[args.type]) + out.write(name[:6].ljust(6).encode("ascii")) + + for b in range(0, len(data), 256): + out.write(BLOCK_ID) + out.write(data[b : b + 256]) + + padding = 256 - (len(data) % 256) + if padding == 256: + out.write(BLOCK_ID) + out.write(bytes((0x1A for _ in range(padding)))) + + elif args.type == "basic": + + out.write(TYPE_BLOCK[args.type]) + out.write(name[:6].ljust(6).encode("ascii")) + out.write(BLOCK_ID) + out.write(data) + + elif args.type == "binary": + + addr = args.addr + exec_addr = args.exec + end_addr = addr + len(data) - 1 + + if end_addr > 0xFFFF: + parser.error("Binary doesn't fit in memory") + + out.write(TYPE_BLOCK[args.type]) + out.write(name[:6].ljust(6).encode("ascii")) + + out.write(BLOCK_ID) + write_word(out, addr) + write_word(out, end_addr) + write_word(out, exec_addr) + + out.write(data) + + elif args.type == "custom-header": + + addr = args.addr + length = len(data) + + write_word(out, addr) + write_word(out, length) + out.write(data) + + else: + # custom + out.write(data) + + +if __name__ == "__main__": + main() diff --git a/tools/png2scr.py b/tools/png2scr.py new file mode 100755 index 0000000..5d08915 --- /dev/null +++ b/tools/png2scr.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 by Juan J. Martinez +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +__version__ = "1.0" + +import sys +from argparse import ArgumentParser +from PIL import Image +from collections import defaultdict + +DEF_W = 8 +DEF_H = 8 + +# TOSHIBA palette +MSX_COLS = [ + (255, 0, 255), + (0, 0, 0), + (102, 204, 102), + (136, 238, 136), + (68, 68, 221), + (119, 119, 255), + (187, 85, 85), + (119, 221, 221), + (221, 102, 102), + (255, 119, 119), + (204, 204, 85), + (238, 238, 136), + (85, 170, 85), + (187, 85, 187), + (204, 204, 204), + (238, 238, 238), +] + + +def main(): + + parser = ArgumentParser( + description="PNG to SCR file", + epilog="Copyright (C) 2018-2022 Juan J Martinez ", + ) + + parser.add_argument( + "--version", action="version", version="%(prog)s " + __version__ + ) + + parser.add_argument("image", help="image to convert") + + args = parser.parse_args() + + try: + image = Image.open(args.image) + except IOError: + parser.error("failed to open the image") + + if image.mode != "RGB": + parser.error("not a RGB image") + + (w, h) = image.size + + if w % 256 or h % 192: + parser.error("%s size is not 256x192" % args.image) + + data = image.getdata() + + color_idx = defaultdict(list) + color = [] + out = [] + ntiles = 0 + for y in range(0, h, DEF_H): + for x in range(0, w, DEF_W): + # tile data + tile = [ + data[x + i + ((y + j) * w)] for j in range(DEF_H) for i in range(DEF_W) + ] + + # get the attibutes of the tile + # FIXME: this may not be right + for i in range(0, len(tile), DEF_W): + cols = list(sorted(set(tile[i : i + DEF_W]))) + + if len(cols) > 2: + parser.error( + "tile %d (%d, %d) has more than two colors: %r" + % (ntiles, x, y, cols) + ) + elif len(cols) == 1: + cols.append(MSX_COLS[1]) + + for c in cols: + if c not in MSX_COLS: + parser.error( + "tile %d (%d, %d) has a color not in the MSX palette: %r" + % (ntiles, x, y, c) + ) + + # each tile has two coloributes per row + color_idx[ntiles * DEF_H + i // DEF_W] = cols + color.append((MSX_COLS.index(cols[1]) << 4) | MSX_COLS.index(cols[0])) + + frame = [] + for i in range(0, len(tile), 8): + byte = 0 + p = 7 + for k in range(8): + # 0 or 1 is determined by the order in the coloributes for that row + byte |= ( + color_idx[ntiles * DEF_H + i // DEF_W].index(tile[i + k]) << p + ) + p -= 1 + + frame.append(byte) + ntiles += 1 + out.extend(frame) + + sys.stdout.buffer.write(bytes(out)) + sys.stdout.buffer.write(bytes(color)) + + +if __name__ == "__main__": + main() -- cgit v1.2.3