├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── ppm.py └── ppmImage.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # test files 2 | *.ppm 3 | *.png 4 | *.php 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # SageMath parsed files 92 | *.sage.py 93 | 94 | # Environments 95 | .env 96 | .venv 97 | env/ 98 | venv/ 99 | ENV/ 100 | env.bak/ 101 | venv.bak/ 102 | 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | .dmypy.json 116 | dmypy.json 117 | 118 | # Pyre type checker 119 | .pyre/ 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 james 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | >> This project has not been updated for some time and does not reflect the latest findings of our reverse-engineering efforts. For now, please refer to [flipnote.js](https://github.com/jaames/flipnote.js/blob/master/src/parsers/PpmParser.ts), as this is the closest thing to an up-to-date reference implementation. 2 | 3 | Example decoder and utilities for Flipnote Studio's .ppm animation format. 4 | 5 | **This is not remotely optimised, and is for example purposes only** 6 | 7 | All scripts were written for Python 3.7 and require the [numpy](http://www.numpy.org/) module to be installed. 8 | 9 | ### Credits 10 | 11 | * [jaames](https://github.com/jaames) for completing PPM reverse-engineering and writing this implementation. 12 | * [bricklife](http://ugomemo.g.hatena.ne.jp/bricklife/20090307/1236391313), [mirai-iro](http://mirai-iro.hatenablog.jp/entry/20090116/ugomemo_ppm), [harimau_tigris](http://ugomemo.g.hatena.ne.jp/harimau_tigris), and other members of the Japanese Flipnote community who started reverse-engineering the PPM format almost as soon as the app was released. 13 | * Midmad and WDLMaster for identifying the adpcm sound codec used. 14 | * [steven](http://www.dsibrew.org/wiki/User:Steven) and [yellows8](http://www.dsibrew.org/wiki/User:Yellows8) for the PPM documentation on DSiBrew. 15 | * [PBSDS](https://github.com/pbsds) for more PPM reverse-engineering, as well as writing [hatenatools](https://github.com/pbsds/Hatenatools) 16 | 17 | ## Utilities 18 | 19 | ### ppmImage 20 | 21 | Converts specific ppm frames to standard image formats such as png, gif, jpeg, etc. Requires the [Pillow](https://pillow.readthedocs.io/en/5.2.x/) module to be installed. 22 | 23 | Usage: 24 | 25 | ```bash 26 | python ppmImage.py 27 | ``` 28 | 29 | ``: 30 | 31 | * Specific frame index (e.g. `0` for the first frame) 32 | * `thumb` to get the thumbnail frame 33 | * `gif` to encode the whole Flipnote to an animated GIF. 34 | 35 | ``: 36 | 37 | * Can include placeholders: `{name}` for the input filename (without extention), `{ext}` for input extention, `{index}` for the item index and `{dirname}` for the input file directory. 38 | 39 | ``: 40 | 41 | * You can pass glob patterns as the input filepath to batch convert. For example, the following will extract thumbnail images from all the ppms in a directory: 42 | 43 | ```bash 44 | python ppmImage.py "/flipnotes/*.ppm" thumb /flipnotes/{name}.png 45 | ``` 46 | -------------------------------------------------------------------------------- /ppm.py: -------------------------------------------------------------------------------- 1 | # ==================== 2 | # ppm.py version 1.0.0 3 | # ==================== 4 | 5 | # Class for parsing Flipnote Studio's .ppm animation format 6 | # Implementation by James Daniel (aka jaames) (github.com/jaames | rakujira.jp) 7 | # 8 | # Credits: 9 | # PPM format reverse-engineering and documentation: 10 | # bricklife (http://ugomemo.g.hatena.ne.jp/bricklife/20090307/1236391313) 11 | # mirai-iro (http://mirai-iro.hatenablog.jp/entry/20090116/ugomemo_ppm) 12 | # harimau_tigris (http://ugomemo.g.hatena.ne.jp/harimau_tigris) 13 | # steven (http://www.dsibrew.org/wiki/User:Steven) 14 | # yellows8 (http://www.dsibrew.org/wiki/User:Yellows8) 15 | # PBSDS (https://github.com/pbsds) 16 | # jaames (https://github.com/jaames) 17 | # Identifying the PPM sound codec: 18 | # Midmad from Hatena Haiku 19 | # WDLMaster from hcs64.com 20 | 21 | import struct 22 | import numpy as np 23 | from datetime import datetime 24 | 25 | # Flipnote speed -> frames per second 26 | FRAMERATES = { 27 | 1: 0.5, 28 | 2: 1, 29 | 3: 2, 30 | 4: 4, 31 | 5: 6, 32 | 6: 12, 33 | 7: 20, 34 | 8: 30, 35 | } 36 | 37 | # Thumbnail bitmap RGB colors 38 | THUMBNAIL_PALETTE = [ 39 | (0xFF, 0xFF, 0xFF), 40 | (0x52, 0x52, 0x52), 41 | (0xFF, 0xFF, 0xFF), 42 | (0x9C, 0x9C, 0x9C), 43 | (0xFF, 0x48, 0x44), 44 | (0xC8, 0x51, 0x4F), 45 | (0xFF, 0xAD, 0xAC), 46 | (0x00, 0xFF, 0x00), 47 | (0x48, 0x40, 0xFF), 48 | (0x51, 0x4F, 0xB8), 49 | (0xAD, 0xAB, 0xFF), 50 | (0x00, 0xFF, 0x00), 51 | (0xB6, 0x57, 0xB7), 52 | (0x00, 0xFF, 0x00), 53 | (0x00, 0xFF, 0x00), 54 | (0x00, 0xFF, 0x00), 55 | ] 56 | 57 | # Frame RGB colors 58 | BLACK = (0x0E, 0x0E, 0x0E) 59 | WHITE = (0xFF, 0xFF, 0xFF) 60 | BLUE = (0x0A, 0x39, 0xFF) 61 | RED = (0xFF, 0x2A, 0x2A) 62 | 63 | class PPMParser: 64 | @classmethod 65 | def open(cls, path): 66 | f = open(path, "rb") 67 | return cls(f) 68 | 69 | def __init__(self, stream=None): 70 | if stream: self.load(stream) 71 | 72 | def load(self, stream): 73 | self.stream = stream 74 | self.read_header() 75 | self.read_meta() 76 | self.read_animation_header() 77 | self.read_sound_header() 78 | self.layers = np.zeros((2, 192, 256), dtype=np.uint8) 79 | self.prev_layers = np.zeros((2, 192, 256), dtype=np.uint8) 80 | self.prev_frame_index = -1 81 | 82 | def unload(self): 83 | self.stream.close() 84 | 85 | def read_header(self): 86 | # decode header 87 | # https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header 88 | self.stream.seek(0) 89 | magic, animation_data_size, sound_data_size, frame_count, version = struct.unpack("<4sIIHH", self.stream.read(16)) 90 | self.animation_data_size = animation_data_size 91 | self.sound_data_size = sound_data_size 92 | self.frame_count = frame_count + 1 93 | 94 | def read_filename(self): 95 | # Parent and current filenames are stored as: 96 | # - 3 bytes representing the last 6 digits of the Consoles's MAC address 97 | # - 13-character string 98 | # - uint16 edit counter 99 | mac, ident, edits = struct.unpack("<3s13sH", self.stream.read(18)); 100 | # Filenames are formatted as <3-byte MAC as hex>_<13-character string>_ 101 | # eg F78DA8_14768882B56B8_030 102 | return "{0}_{1}_{2:03d}".format("".join(["%02X" % c for c in mac]), ident.decode("ascii"), edits) 103 | 104 | def read_meta(self): 105 | # decode metadata 106 | # https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header 107 | self.stream.seek(0x10) 108 | self.lock, self.thumb_index = struct.unpack("> 4) & 0x0F 136 | return bitmap 137 | 138 | def read_animation_header(self): 139 | self.stream.seek(0x06A0) 140 | table_size, unknown, flags = struct.unpack("> 11) & 0x01 143 | self.layer_2_visible = (flags >> 10) & 0x01 144 | self.loop = (flags >> 1) & 0x01 145 | # read offset table into a numpy array 146 | offset_table = np.frombuffer(self.stream.read(table_size), dtype=np.uint32) 147 | self.offset_table = [offset + 0x06A0 + 8 + table_size for offset in offset_table] 148 | 149 | def read_sound_header(self): 150 | # https://github.com/pbsds/hatena-server/wiki/PPM-format#sound-data-section 151 | # offset = frame data offset + frame data length + sound effect flags 152 | offset = 0x06A0 + self.animation_data_size + self.frame_count; 153 | # account for multiple-of-4 padding 154 | if offset % 2 != 0: offset += 4 - (offset % 4) 155 | self.stream.seek(offset) 156 | bgm_size, se1_size, se2_size, se3_size, frame_speed, bgm_speed = struct.unpack("> 7 & 0x1 165 | 166 | def read_line_types(self, line_types): 167 | for index in range(192): 168 | line_type = line_types[index // 4] >> ((index % 4) * 2) & 0x03 169 | yield (index, line_type) 170 | 171 | def read_frame(self, index): 172 | # decode previous frames if needed 173 | if index != 0 and self.prev_frame_index != index - 1 and not self.is_frame_new(index): 174 | self.read_frame(index - 1) 175 | # copy the current layer buffers to the previous ones 176 | np.copyto(self.prev_layers, self.layers) 177 | self.prev_frame_index = index 178 | # clear current layer buffers by reseting them to 0 179 | self.layers.fill(0) 180 | # seek to the frame offset so we can start reading 181 | self.stream.seek(self.offset_table[index]) 182 | # unpack frame header flags 183 | header = ord(self.stream.read(1)) 184 | is_new_frame = (header >> 7) & 0x1 185 | is_translated = (header >> 5) & 0x3 186 | translation_x = 0 187 | translation_y = 0 188 | # if the frame is translated, we need to unpack the x and y values 189 | if is_translated: 190 | translation_x, translation_y = struct.unpack("I", self.stream.read(4))[0] 212 | # unpack pixel chunks 213 | while pixel < 256: 214 | if chunk_usage & 0x80000000: 215 | chunk = ord(self.stream.read(1)) 216 | for bit in range(8): 217 | bitmap[line][pixel] = chunk >> bit & 0x1 218 | pixel += 1 219 | else: 220 | pixel += 8 221 | chunk_usage <<= 1 222 | # raw line 223 | elif line_type == 3: 224 | # unpack pixel chunks 225 | while pixel < 256: 226 | chunk = ord(self.stream.read(1)) 227 | for bit in range(8): 228 | bitmap[line][pixel] = chunk >> bit & 0x1 229 | pixel += 1 230 | 231 | # frame diffing - if the current frame is based on the preivous one, merge them by XORing their pixels 232 | # this is a big performance bottleneck... 233 | if not is_new_frame: 234 | # loop through lines 235 | for y in range(192): 236 | # skip to next line if this one falls off the top edge of the screen 237 | if y - translation_y < 0: 238 | continue 239 | # stop once the bottom screen edge has been reached 240 | if y - translation_y >= 192: 241 | break 242 | for x in range(256): 243 | # skip to the next pixel if this one falls off the left edge of the screen 244 | if x - translation_x < 0: 245 | continue 246 | # stop diffing this line once the right screen edge has been reached 247 | if x - translation_x >= 256: 248 | break 249 | # diff pixels with a binary XOR 250 | self.layers[0][y][x] ^= self.prev_layers[0][y - translation_y][x - translation_x] 251 | self.layers[1][y][x] ^= self.prev_layers[1][y - translation_y][x - translation_x] 252 | 253 | return self.layers 254 | 255 | def get_frame_palette(self, index): 256 | self.stream.seek(self.offset_table[index]) 257 | header = ord(self.stream.read(1)) 258 | paper_color = header & 0x1; 259 | pen = [ 260 | None, 261 | BLACK if paper_color == 1 else WHITE, 262 | RED, 263 | BLUE, 264 | ] 265 | return [ 266 | WHITE if paper_color == 1 else BLACK, 267 | pen[(header >> 1) & 0x3], # layer 1 color 268 | pen[(header >> 3) & 0x3], # layer 2 color 269 | ]; 270 | 271 | def get_frame_pixels(self, index): 272 | layers = self.read_frame(index) 273 | pixels = np.zeros((192, 256), dtype=np.uint8) 274 | for y in range(192): 275 | for x in range(256): 276 | if layers[0][y][x] > 0: pixels[y][x] = 1 277 | elif layers[1][y][x] > 0: pixels[y][x] = 2 278 | return pixels 279 | -------------------------------------------------------------------------------- /ppmImage.py: -------------------------------------------------------------------------------- 1 | # ========================= 2 | # ppmImage.py version 1.0.0 3 | # ========================= 4 | # 5 | # Extracts frame images from Flipnote PPMs 6 | # 7 | # Usage: 8 | # python ppmImage.py 9 | 10 | import glob 11 | from sys import argv 12 | import os 13 | from ppm import PPMParser 14 | from PIL import Image 15 | 16 | def get_image(parser, index): 17 | frame = parser.get_frame_pixels(index) 18 | colors = parser.get_frame_palette(index) 19 | img = Image.fromarray(frame, "P") 20 | img.putpalette([*colors[0], *colors[1], *colors[2]]) 21 | return img 22 | 23 | parser = PPMParser() 24 | filelist = glob.glob(argv[1], recursive=True) 25 | 26 | for (index, path) in enumerate(filelist): 27 | with open(path, "rb") as ppm: 28 | basename = os.path.basename(path) 29 | dirname = os.path.dirname(path) 30 | filestem, ext = os.path.splitext(basename) 31 | outpath = argv[3].format(name=filestem, dirname=dirname, index=index, ext=ext) 32 | 33 | print("Converting", path, "->", outpath) 34 | parser.load(ppm) 35 | 36 | if argv[2] == "gif": 37 | frame_duration = (1 / parser.framerate) * 1000 38 | frames = [get_image(parser, i) for i in range(parser.frame_count)] 39 | frames[0].save(outpath, format="gif", save_all=True, append_images=frames[1:], duration=frame_duration, loop=False) 40 | 41 | elif argv[2] == "thumb": 42 | img = get_image(parser, parser.thumb_index) 43 | img.save(outpath) 44 | 45 | else: 46 | index = int(argv[2]) 47 | img = get_image(parser, index) 48 | img.save(outpath) 49 | 50 | parser.unload() --------------------------------------------------------------------------------