aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2022-02-27 06:53:33 -0800
committerFuwn <[email protected]>2022-02-27 06:53:33 -0800
commit225fc572faa1f7f54c7266d5d6a5c74345dfdaa2 (patch)
treea30dd2959f3e99d15bc6b4c12fac5a32c3b8994a
downloadpara-225fc572faa1f7f54c7266d5d6a5c74345dfdaa2.tar.xz
para-225fc572faa1f7f54c7266d5d6a5c74345dfdaa2.zip
feat(para): :star:
-rw-r--r--.gitattributes2
-rw-r--r--.github/workflows/release.yaml50
-rw-r--r--.github/workflows/rust.yaml35
-rw-r--r--.gitignore16
-rw-r--r--Cargo.toml40
-rw-r--r--LICENSE22
-rw-r--r--Makefile.toml60
-rw-r--r--README.rst102
-rw-r--r--assets/para.pngbin0 -> 2304 bytes
-rw-r--r--build.rs31
-rw-r--r--rust-toolchain.toml2
-rw-r--r--rustfmt.toml29
-rw-r--r--src/main.rs129
-rw-r--r--src/ppm.rs566
14 files changed, 1084 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..ccc046e
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,50 @@
+name: Release ⚾
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ release:
+ runs-on: ubuntu-20.04
+ steps:
+ - name: Checkout 🛒
+ uses: actions/checkout@v2
+
+ - name: Toolchain 🧰
+ uses: actions-rs/toolchain@v1
+ with:
+ profile: minimal
+ toolchain: nightly-2022-02-20
+ components: rustfmt, clippy
+ override: true
+
+ - name: Build 🏗
+ uses: actions-rs/cargo@v1
+ continue-on-error: false
+ with:
+ command: build
+ args: --release
+
+ - name: Create Release 🏉
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: ${{ github.ref }}
+ draft: false
+ prerelease: false
+
+ - name: Upload Artifacts to Release 💎
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: ./target/release/para
+ asset_name: divina
+ asset_content_type: application/x-elf # x-msdownload for Windows
diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml
new file mode 100644
index 0000000..255beac
--- /dev/null
+++ b/.github/workflows/rust.yaml
@@ -0,0 +1,35 @@
+name: Rust ✅
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - "*"
+ pull_request:
+ paths:
+ - "*"
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout 🛒
+ uses: actions/checkout@v2
+
+ - name: Toolchain 🧰
+ uses: actions-rs/toolchain@v1
+ with:
+ profile: minimal
+ toolchain: nightly-2022-02-20
+ components: rustfmt, clippy
+ override: true
+
+ - name: Check ✅
+ uses: actions-rs/cargo@v1
+ continue-on-error: false
+ with:
+ command: check
+ args: --verbose
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a0deeb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+
+# These are backup files generated by rustfmt
+**/*.rs.bk
+
+# IDE
+/.idea/
+
+# Development
+*.ppm
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..9067585
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "para"
+version = "0.1.0"
+authors = ["Fuwn <[email protected]>"]
+edition = "2021"
+description = "An example decoder and utility for Flipnote Studios .ppm animation format."
+readme = "README.rst"
+homepage = "https://github.com/Usugata/para"
+repository = "https://github.com/Usugata/para"
+license = "MIT"
+keywords = ["ppm", "nintendo-hacking", "flipnote", "flipnotestudio", "nintendo-dsi"]
+categories = ["encoding"]
+publish = false
+
+# Slower builds, faster executables
+[profile.release]
+lto = "fat"
+codegen-units = 1
+
+[dependencies]
+# Constant `HashMap`
+lazy_static = "1.4.0"
+
+# Byte manipulation
+byteorder = "1.4.3"
+
+# Time
+chrono = "0.4.19"
+
+# Generators
+generator = "0.7.0"
+
+# Image encoding
+image = "0.24.1"
+
+# JSON encoding
+serde_json = "1.0.79"
+
+# Error handling
+human-panic = "1.0.3"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..153eed4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2019 james
+Copyright (c) 2022 Fuwn
+
+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.
diff --git a/Makefile.toml b/Makefile.toml
new file mode 100644
index 0000000..c8a2072
--- /dev/null
+++ b/Makefile.toml
@@ -0,0 +1,60 @@
+# -------------
+# | Variables |
+# -------------
+[env]
+CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
+
+# ------------
+# | Wrappers |
+# ------------
+[tasks.fmt]
+command = "cargo"
+args = ["fmt"]
+private = true
+
+[tasks.check]
+command = "cargo"
+args = ["check"]
+private = true
+
+[tasks.clippy]
+command = "cargo"
+args = ["clippy"]
+private = true
+
+[tasks.test]
+command = "cargo"
+args = ["test"]
+private = true
+
+[tasks.bench]
+command = "cargo"
+args = ["bench"]
+private = true
+
+# -------------
+# | Executors |
+# -------------
+[tasks.checkf]
+workspace = false
+dependencies = ["fmt", "check"]
+
+[tasks.checkfc]
+workspace = false
+dependencies = ["fmt", "check", "clippy"]
+
+[tasks.checkall]
+workspace = false
+dependencies = ["fmt", "check", "clippy", "test", "bench"]
+
+[tasks.docs]
+workspace = false
+toolchain = "nightly"
+command = "cargo"
+args = ["doc", "--open", "--document-private-items", "--no-deps"]
+
+[tasks.run]
+workspace = false
+dependencies = ["checkfc"]
+command = "cargo"
+args = ["run", "--bin", "para", "${@}"]
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..f4149ab
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,102 @@
+🗃 :code:`para`
+====================
+
+.. image:: assets/para.png
+
+What?
+-----
+
+An example decoder and utility for Flipnote Studios :code:`.ppm` animation format.
+
+Why this over that?
+-------------------
+
+This implementation is
+
+- `SIGNIFICANTLY faster <#speed>`_
+- implemented in safe Rust (a language with strict type-checking!)
+- being actively updated
+
+Speed
+^^^^^
+
+In a two-hundred-forty-four frame :code:`.ppm` benchmark running the command
+:code:`$ para ./benchmark.ppm gif benchmark.gif` on a twelve-core,
+twenty-four-thread Ryzen 9 processor, para took an average of 22.7000176 seconds,
+while ppm-parser took an average of 50.4171397 seconds.
+
+Running the command :code:`$ para ./benchmark.ppm 0 benchmark.png` on a twelve-core,
+twenty-four-thread Ryzen 9 processor, para took an average of 98.7967 milliseconds,
+while ppm-parser took an average of 358.2232 milliseconds.
+
+Rust versus Python isn't very fair, however, this benchmark shows the speed improvements
+that para brings to the table.
+
+Getting up and Running
+----------------------
+
+Installation
+^^^^^^^^^^^^
+
+Prebuilt binaries for x86_64-based Linux systems and Windows are available in the
+`releases <https://github.com/Usugata/para/releases/latest>`_. If you are using
+a different operating system or architecture such as macOS, you'll have to build and
+install the tool yourself!
+
+.. code-block:: shell
+
+ $ cargo install --git https://github.com/Usugata/para --branch main
+
+If you are building and installing yourself, you must have
+`Rust <https://www.rust-lang.org/>`_ installed!
+
+Usage
+^^^^^
+
+.. code-block:: shell
+
+ usage: para <in> <index option> <out>
+ index options:
+ gif
+ thumb
+ dump
+ integer(u16)
+
+Examples
+^^^^^^^^
+
+- :code:`$ para ./example.ppm 23 example.png` will output the twenty-fourth frame
+ of :code:`example.ppm` to :code:`example.png`
+- :code:`$ para ./example.ppm thumb example.png` will output the thumbnail of
+ :code:`example.ppm` to :code:`example.png`
+- :code:`$ para ./example.ppm dump example.json` will output the metadata of
+ :code:`example.ppm` to :code:`example.json`
+- :code:`$ para ./example.ppm gif example.gif` will output :code:`example.ppm`
+ to :code:`example.gif`
+
+Prebuilt Binaries
+"""""""""""""""""
+
+Prebuilt binaries for the latest release may or may not be found
+`here <https://github.com/Usugata/para/releases/latest>`_.
+
+Credits
+-------
+
+- `jaames <https://github.com/jaames>`_ for completing PPM reverse-engineering and
+ writing the `original <https://github.com/Flipnote-Collective/ppm-parser>`_ implementation.
+- `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.
+- Midmad and WDLMaster for identifying the adpcm sound codec used.
+- `steven <http://www.dsibrew.org/wiki/User:Steven>`_ and
+ `yellows8 <http://www.dsibrew.org/wiki/User:Yellows8>`_ for the PPM documentation on DSiBrew.
+- `PBSDS <https://github.com/pbsds>`_ for more PPM reverse-engineering, as well as
+ writing `hatenatools <https://github.com/pbsds/Hatenatools>`_
+
+License
+-------
+
+`MIT License <./LICENSE>`_
diff --git a/assets/para.png b/assets/para.png
new file mode 100644
index 0000000..4c31129
--- /dev/null
+++ b/assets/para.png
Binary files differ
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..a47c4ab
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,31 @@
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: MIT
+
+use std::env::var;
+
+/// <https://github.com/denoland/deno/blob/main/cli/build.rs#L265:L285>
+fn git_commit_hash() -> String {
+ if let Ok(output) = std::process::Command::new("git")
+ .arg("rev-list")
+ .arg("-1")
+ .arg("HEAD")
+ .output()
+ {
+ if output.status.success() {
+ std::str::from_utf8(&output.stdout[..40])
+ .unwrap()
+ .to_string()
+ } else {
+ "UNKNOWN".to_string()
+ }
+ } else {
+ "UNKNOWN".to_string()
+ }
+}
+
+fn main() {
+ println!("cargo:rustc-env=GIT_COMMIT_HASH={}", git_commit_hash());
+ println!("cargo:rerun-if-env-changed=GIT_COMMIT_HASH");
+ println!("cargo:rustc-env=TARGET={}", var("TARGET").unwrap());
+ println!("cargo:rustc-env=PROFILE={}", var("PROFILE").unwrap());
+}
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..724c0ea
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,2 @@
+[toolchain]
+channel = "nightly-2022-02-20"
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..cdca26c
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,29 @@
+condense_wildcard_suffixes = true
+edition = "2021"
+enum_discrim_align_threshold = 20
+# error_on_line_overflow = true
+# error_on_unformatted = true
+fn_single_line = true
+force_multiline_blocks = true
+format_code_in_doc_comments = true
+format_macro_matchers = true
+format_strings = true
+imports_layout = "HorizontalVertical"
+# license_template_path = ".license_template"
+match_arm_blocks = false
+imports_granularity = "Crate"
+newline_style = "Unix"
+normalize_comments = true
+normalize_doc_attributes = true
+reorder_impl_items = true
+group_imports = "StdExternalCrate"
+reorder_modules = true
+report_fixme = "Always"
+# report_todo = "Always"
+struct_field_align_threshold = 20
+struct_lit_single_line = false
+tab_spaces = 2
+use_field_init_shorthand = true
+use_try_shorthand = true
+where_single_line = true
+wrap_comments = true
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..7d10479
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,129 @@
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: MIT
+
+#![feature(decl_macro)]
+#![deny(
+ warnings,
+ nonstandard_style,
+ unused,
+ future_incompatible,
+ rust_2018_idioms,
+ unsafe_code
+)]
+#![deny(clippy::all, clippy::nursery, clippy::pedantic)]
+#![recursion_limit = "128"]
+
+mod ppm;
+
+use std::process::exit;
+
+use image::DynamicImage;
+
+use crate::ppm::PPMParser;
+
+#[allow(unused)]
+fn get_image(parser: &mut PPMParser, index: usize) -> DynamicImage {
+ let frame = parser.get_frame_pixels(index);
+ let colours = parser.get_frame_palette(index);
+ let mut img = Vec::new();
+ let mut img_encoder = image::codecs::bmp::BmpEncoder::new(&mut img);
+
+ img_encoder.encode_with_palette(
+ &frame.into_iter().flatten().collect::<Vec<u8>>(),
+ 256,
+ 192,
+ image::ColorType::L8,
+ Some(&[
+ [colours[0].0, colours[0].1, colours[0].2],
+ [colours[1].0, colours[1].1, colours[1].2],
+ [colours[2].0, colours[2].1, colours[2].2],
+ ]),
+ );
+
+ image::load_from_memory(&img).unwrap()
+}
+
+fn main() {
+ human_panic::setup_panic!(Metadata {
+ version: env!("CARGO_PKG_VERSION").into(),
+ name: env!("CARGO_PKG_NAME").into(),
+ authors: env!("CARGO_PKG_AUTHORS").into(),
+ homepage: env!("CARGO_PKG_HOMEPAGE").into(),
+ });
+
+ let args = std::env::args().collect::<Vec<_>>();
+
+ if args.len() < 4 {
+ println!(
+ "{}, version {}(1)-{}-({})-{}\n\
+ usage: {} <in> <index option> <out>\n\
+ index options:\n\
+ \tgif\n\
+ \tthumb\n\
+ \tdump\n\
+ \tinteger(u16)\n\n\
+ {0} home page: <https://github.com/Usugata/{0}>",
+ env!("CARGO_PKG_NAME"),
+ env!("CARGO_PKG_VERSION"),
+ env!("PROFILE"),
+ env!("TARGET"),
+ env!("GIT_COMMIT_HASH"),
+ args[0],
+ );
+ exit(1);
+ }
+
+ let path = &args[1];
+ let index = &args[2];
+ let out_path = &args[3];
+ let mut parser = PPMParser::new_from_file(path);
+ parser.load();
+ let frame_count = usize::from(parser.get_frame_count());
+
+ match index.as_str() {
+ "gif" => {
+ let frame_duration = (1.0 / parser.get_framerate()) * 100.0;
+ let frames = (0..parser.get_frame_count())
+ .map(|i| get_image(&mut parser, i as usize))
+ .collect::<Vec<DynamicImage>>();
+ let mut file_out = std::fs::File::create(out_path).unwrap();
+ let mut gif_encoder = image::codecs::gif::GifEncoder::new_with_speed(
+ &mut file_out,
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ frame_duration as i32
+ },
+ );
+ for frame in frames {
+ gif_encoder
+ .encode_frame(image::Frame::new(frame.into_rgba8()))
+ .unwrap();
+ }
+ gif_encoder
+ .set_repeat(image::codecs::gif::Repeat::Infinite)
+ .unwrap();
+ }
+ "thumb" => {
+ let thumb_index = parser.get_thumb_index() as usize;
+ get_image(&mut parser, thumb_index).save(out_path).unwrap();
+ }
+ "dump" => parser.dump_to_json(out_path),
+ _ => {
+ if !(0..frame_count).contains(&index.parse::<usize>().unwrap()) {
+ println!(
+ "invalid frame index({}), image has {}(0..{}) frames",
+ index,
+ frame_count,
+ frame_count - 1,
+ );
+ exit(1);
+ }
+
+ get_image(&mut parser, index.parse::<usize>().unwrap())
+ .save(out_path)
+ .unwrap();
+ }
+ }
+
+ println!("converted {}({}) to {}", path, index, out_path);
+}
diff --git a/src/ppm.rs b/src/ppm.rs
new file mode 100644
index 0000000..f713ff1
--- /dev/null
+++ b/src/ppm.rs
@@ -0,0 +1,566 @@
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: MIT
+
+#![allow(clippy::cast_sign_loss)]
+
+use std::{
+ collections::HashMap,
+ fs,
+ io::{Cursor, Read},
+};
+
+use byteorder::{LittleEndian, ReadBytesExt};
+use chrono::{DateTime, NaiveDateTime, Utc};
+
+lazy_static::lazy_static! {
+ // Flipnote speed -> frames per second
+ static ref FRAMERATES: HashMap<u8, f64> = {
+ let mut hashmap = HashMap::new();
+
+ hashmap.insert(1, 0.5);
+ hashmap.insert(2, 1.0);
+ hashmap.insert(3, 2.0);
+ hashmap.insert(4, 4.0);
+ hashmap.insert(5, 6.0);
+ hashmap.insert(6, 12.0);
+ hashmap.insert(7, 20.0);
+ hashmap.insert(8, 30.0);
+
+ hashmap
+ };
+
+ // Thumbnail bitmap RGB colours
+ static ref THUMBNAIL_PALETTE: &'static [(u64, u64, u64)] = &[
+ (0xFF, 0xFF, 0xFF),
+ (0x52, 0x52, 0x52),
+ (0xFF, 0xFF, 0xFF),
+ (0x9C, 0x9C, 0x9C),
+ (0xFF, 0x48, 0x44),
+ (0xC8, 0x51, 0x4F),
+ (0xFF, 0xAD, 0xAC),
+ (0x00, 0xFF, 0x00),
+ (0x48, 0x40, 0xFF),
+ (0x51, 0x4F, 0xB8),
+ (0xAD, 0xAB, 0xFF),
+ (0x00, 0xFF, 0x00),
+ (0xB6, 0x57, 0xB7),
+ (0x00, 0xFF, 0x00),
+ (0x00, 0xFF, 0x00),
+ (0x00, 0xFF, 0x00),
+ ];
+
+ // Frame RGB colours
+ static ref BLACK: (u8, u8, u8) = (0x0E, 0x0E, 0x0E);
+ static ref WHITE: (u8, u8, u8) = (0xFF, 0xFF, 0xFF);
+ static ref BLUE: (u8, u8, u8) = (0x0A, 0x39, 0xFF);
+ static ref RED: (u8, u8, u8) = (0xFF, 0x2A, 0x2A);
+}
+
+macro read_n_to_as_utf8_from_stream($n:expr, $from:ident) {
+ String::from_utf8({
+ let mut buffer = vec![0; $n];
+
+ $from.stream.read_exact(&mut buffer).unwrap();
+
+ buffer
+ })
+ .unwrap()
+}
+
+macro read_n_of_size_from_to_vec($n:expr, $from:tt, $size:ty) {{
+ let mut buffer = vec![0 as $size; $n];
+
+ $from.stream.read_exact(&mut buffer).unwrap();
+
+ buffer
+}}
+
+fn strip_null(string: &str) -> String { string.replace(char::from(0), "") }
+
+fn read_n_to_vec(stream: &mut Cursor<Vec<u8>>, n: usize) -> Vec<u8> {
+ let mut buffer = vec![0; n];
+
+ stream.read_exact(&mut buffer).unwrap();
+
+ buffer
+}
+
+fn vec_u8_to_string(vec: &[u8]) -> String {
+ vec.iter().rev().map(|m| format!("{:02X}", m)).collect()
+}
+
+pub struct PPMParser {
+ stream: Cursor<Vec<u8>>,
+ layers: Vec<Vec<Vec<u8>>>,
+ prev_layers: Vec<Vec<Vec<u8>>>,
+ prev_frame_index: usize,
+ animation_data_size: u32,
+ sound_data_size: u32,
+ frame_count: u16,
+ lock: u16,
+ thumb_index: u16,
+ root_author_name: String,
+ parent_author_name: String,
+ current_author_name: String,
+ parent_author_id: String,
+ current_author_id: String,
+ parent_filename: String,
+ current_filename: String,
+ root_author_id: String,
+ partial_filename: String,
+ timestamp: DateTime<Utc>,
+ layer_1_visible: bool,
+ layer_2_visible: bool,
+ loop_: bool,
+ frame_speed: u8,
+ bgm_speed: u8,
+ framerate: f64,
+ bgm_framerate: f64,
+ offset_table: Vec<u32>,
+}
+impl PPMParser {
+ #[allow(unused)]
+ pub fn new(stream: Vec<u8>) -> Self {
+ Self {
+ stream: Cursor::new(stream),
+ ..Self::default()
+ }
+ }
+
+ pub fn new_from_file(file: &str) -> Self {
+ Self {
+ stream: Cursor::new(std::fs::read(file).unwrap()),
+ ..Self::default()
+ }
+ }
+
+ pub fn load(&mut self) {
+ self.read_header();
+ self.read_meta();
+ self.read_animation_header();
+ self.read_sound_header();
+ self.layers = vec![vec![vec![0; 256]; 192]; 2];
+ self.prev_layers = vec![vec![vec![0; 256]; 192]; 2];
+ self.prev_frame_index = isize::MAX as usize; // -1
+ }
+
+ /// Decode header
+ ///
+ /// <https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header>
+ fn read_header(&mut self) {
+ self.stream.set_position(0);
+
+ let _magic = read_n_to_as_utf8_from_stream!(4, self);
+ let animation_data_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let sound_data_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let frame_count = self.stream.read_u16::<LittleEndian>().unwrap();
+ let _version = self.stream.read_u16::<LittleEndian>().unwrap();
+
+ self.animation_data_size = animation_data_size;
+ self.sound_data_size = sound_data_size;
+ self.frame_count = frame_count + 1;
+ }
+
+ fn read_filename(&mut self) -> String {
+ // Parent and current filenames are stored as:
+ //
+ // - three bytes representing the last six digits of the console's MAC address
+ // - thirteen-character `String`
+ // - `u16` edit counter
+ let mac = read_n_to_vec(&mut self.stream, 3);
+ let ident = read_n_to_vec(&mut self.stream, 13)
+ .into_iter()
+ .map(|c| c as char)
+ .collect::<String>();
+ let edits = self.stream.read_u16::<LittleEndian>().unwrap();
+
+ // Filenames are formatted as
+ // <three-byte MAC as hexadecimal>_<thirteen-character string>_<edit counter as
+ // three-digit number>
+ //
+ // Example: F78DA8_14768882B56B8_030
+ format!(
+ "{}_{}_{:#03}",
+ mac
+ .into_iter()
+ .map(|m| format!("{:02X}", m))
+ .collect::<String>(),
+ String::from_utf8(ident.as_bytes().to_vec()).unwrap(),
+ edits,
+ )
+ }
+
+ /// Decode metadata
+ ///
+ /// <https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header>
+ fn read_meta(&mut self) {
+ self.stream.set_position(0x10);
+
+ self.lock = self.stream.read_u16::<LittleEndian>().unwrap();
+ self.thumb_index = self.stream.read_u16::<LittleEndian>().unwrap();
+ self.root_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self));
+ self.parent_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self));
+ self.current_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self));
+ self.parent_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice());
+ self.current_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice());
+ self.parent_filename = self.read_filename();
+ self.current_filename = self.read_filename();
+ self.root_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice());
+ self.partial_filename = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_slice()); // Not really useful for anything
+
+ // Timestamp is stored as the number of seconds since 2000, January, 1st
+ let timestamp = self.stream.read_u32::<LittleEndian>().unwrap();
+ self.timestamp = DateTime::from_utc(
+ // We add 946684800 to convert this to a more common Unix timestamp,
+ // which starts on 1970, January, 1st
+ NaiveDateTime::from_timestamp(i64::from(timestamp) + 946_684_800, 0),
+ Utc,
+ );
+ }
+
+ #[allow(unused)]
+ fn read_thumbnail(&mut self) -> Vec<Vec<u64>> {
+ self.stream.set_position(0xA0);
+
+ let mut bitmap = vec![vec![0; 64]; 48];
+
+ for tile_index in 0..48 {
+ let tile_x = tile_index % 8 * 8;
+ let tile_y = tile_index / 8 * 8;
+
+ for line in 0..8 {
+ // [This](https://linuxtut.com/en/ff1ac20b39137f1ccdb9/) can be used,
+ // but let's do it in Rust.
+ for pixel in (0..8).step_by(2) {
+ let byte = self.stream.read_uint::<LittleEndian>(1).unwrap();
+ let x = tile_x + pixel;
+ let y = tile_y + line;
+
+ bitmap[y][x] = byte & 0x0F;
+ bitmap[y][x + 1] = (byte >> 4) & 0x0F;
+ }
+ }
+ }
+
+ bitmap
+ }
+
+ fn read_animation_header(&mut self) {
+ self.stream.set_position(0x06A0);
+
+ let table_size = self.stream.read_u16::<LittleEndian>().unwrap();
+ let _unknown = self.stream.read_u16::<LittleEndian>().unwrap();
+ let flags = self.stream.read_u32::<LittleEndian>().unwrap();
+
+ // Unpack animation flags
+ self.layer_1_visible = (flags >> 11) & 0x01 != 0;
+ self.layer_2_visible = (flags >> 10) & 0x01 != 0;
+ self.loop_ = (flags >> 1) & 0x01 != 0;
+
+ // Read offset table into an array
+ let offset_table = {
+ let from_buffer = read_n_to_vec(&mut self.stream, table_size.into());
+ let mut buffer = Vec::with_capacity((table_size / 4).into());
+
+ // I'm very glad that I got this working. It took way longer than it
+ // should have...
+ //
+ // 2022. 02. 25. 03:58., Fuwn
+ for index in (0..usize::from(table_size)).step_by(4) {
+ buffer.push(
+ (u32::from(from_buffer[index]))
+ | (u32::from(from_buffer[index + 1]) << 8)
+ | (u32::from(from_buffer[index + 2]) << 16)
+ | (u32::from(from_buffer[index + 3]) << 24),
+ );
+ }
+
+ buffer
+ };
+ self.offset_table = offset_table
+ .into_iter()
+ .map(|m| m + 0x06A0 + 8 + u32::from(table_size))
+ .collect();
+ }
+
+ fn read_sound_header(&mut self) {
+ // offset = frame data offset + frame data length + sound effect flags
+ //
+ // <https://github.com/pbsds/hatena-server/wiki/PPM-format#sound-data-section>
+ let mut offset = 0x06A0 + self.animation_data_size + u32::from(self.frame_count);
+ if offset % 2 != 0 {
+ // Account for multiple-of-four padding
+ offset += 4 - (offset % 4);
+ }
+
+ self.stream.set_position(u64::from(offset));
+
+ let _bgm_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let _se1_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let _se2_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let _se3_size = self.stream.read_u32::<LittleEndian>().unwrap();
+ let frame_speed = self.stream.read_u8().unwrap();
+ let bgm_speed = self.stream.read_u8().unwrap();
+
+ self.frame_speed = 8 - frame_speed;
+ self.bgm_speed = 8 - bgm_speed;
+ self.framerate = *FRAMERATES.get(&self.frame_speed).unwrap();
+ self.bgm_framerate = *FRAMERATES.get(&self.bgm_speed).unwrap();
+ }
+
+ fn frame_is_new(&mut self, index: usize) -> bool {
+ self
+ .stream
+ .set_position(u64::from(*self.offset_table.get(index).unwrap()));
+
+ self.stream.read_uint::<LittleEndian>(1).unwrap() >> 7 & 0x1 != 0
+ }
+
+ fn read_line_types(line_types: Vec<u8>) -> generator::Generator<'static, (), (usize, u8)> {
+ generator::Gn::new_scoped(move |mut s| {
+ for index in 0..192 {
+ let line_type = line_types.get(index / 4).unwrap() >> ((index % 4) * 2) & 0x03;
+ s.yield_((index, line_type));
+ }
+
+ generator::done!();
+ })
+ }
+
+ fn read_frame(&mut self, index: usize) -> &Vec<Vec<Vec<u8>>> {
+ // Decode the previous frames if needed
+ if index != 0 && self.prev_frame_index != index - 1 && !self.frame_is_new(index) {
+ self.read_frame(index - 1);
+ }
+
+ // Copy the current layer buffers to the previous ones
+ self.prev_layers = self.layers.clone();
+ self.prev_frame_index = index;
+ // Clear the current layer buffers by resetting them to zero
+ self.layers.fill(vec![vec![0u8; 256]; 192]);
+
+ // Seek to the frame offset so we can start reading
+ self
+ .stream
+ .set_position(u64::from(*self.offset_table.get(index).unwrap()));
+
+ // Unpack frame header flags
+ let header = self.stream.read_uint::<LittleEndian>(1).unwrap();
+ let is_new_frame = (header >> 7) & 0x01 != 0;
+ let is_translated = (header >> 5) & 0x03 != 0;
+ // If the frame is translated, we need to unpack the x and y values
+ let translation_x = if is_translated {
+ self.stream.read_i8().unwrap()
+ } else {
+ 0
+ };
+ let translation_y = if is_translated {
+ self.stream.read_i8().unwrap()
+ } else {
+ 0
+ };
+ // Read line encoding bytes
+ let line_types = vec![
+ read_n_of_size_from_to_vec!(48, self, u8),
+ read_n_of_size_from_to_vec!(48, self, u8),
+ ];
+
+ // Loop through layers
+ #[allow(clippy::needless_range_loop)]
+ for layer in 0..2 {
+ let bitmap = &mut self.layers[layer];
+
+ for (line, line_type) in Self::read_line_types(line_types[layer].clone()) {
+ let mut pixel = 0;
+
+ // No data stored for this line
+ if line_type == 0 {
+ // pass;
+ } else if line_type == 1 || line_type == 2 {
+ // Compressed line
+ // If `line_type == 2`, the line starts off with all the pixels set to one
+ if line_type == 2 {
+ for i in 0..256 {
+ bitmap[line][i] = 1;
+ }
+ }
+
+ // Unpack chunk usage
+ let mut chunk_usage = self.stream.read_u32::<byteorder::BigEndian>().unwrap();
+
+ // Unpack pixel chunks
+ while pixel < 256 {
+ if chunk_usage & 0x8000_0000 == 0 {
+ pixel += 8;
+ } else {
+ let chunk = self.stream.read_uint::<LittleEndian>(1).unwrap();
+
+ for bit in 0..8 {
+ bitmap[line][pixel] = (chunk >> bit & 0x1) as u8;
+ pixel += 1;
+ }
+ }
+
+ chunk_usage <<= 1;
+ }
+ // Raw line
+ } else if line_type == 3 {
+ // Unpack pixel chunks
+ while pixel < 256 {
+ let chunk = self.stream.read_uint::<LittleEndian>(1).unwrap();
+
+ for bit in 0..8 {
+ bitmap[line][pixel] = (chunk >> bit & 0x1) as u8;
+ pixel += 1;
+ }
+ }
+ }
+ }
+ }
+
+ // Frame diffing
+ //
+ // If the current frame is based on the previous one, merge them by XOR-ing
+ // their pixels. This is a big performance bottleneck...
+ if !is_new_frame {
+ // Loop through lines
+ for y in 0..192 {
+ // Skip to next line if this one falls off the top edge of the screen
+ // if y - (translation_y as usize) < 0 {
+ // continue;
+ // }
+ // Stop once the bottom screen edge has been reached
+ if y - translation_y as usize >= 192 {
+ break;
+ }
+
+ for x in 0..256 {
+ // Skip to the next pixel if this one falls off the left edge of the screen
+ // if x - (translation_x as usize) < 0 {
+ // continue;
+ // }
+ // Stop diffing this line once the right screen edge has been reached
+ if x - translation_x as usize >= 256 {
+ break;
+ }
+
+ // Diff pixels with a binary XOR
+ self.layers[0][y][x] ^=
+ self.prev_layers[0][y - translation_y as usize][x - translation_x as usize];