diff options
| author | Fuwn <[email protected]> | 2022-02-27 06:53:33 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2022-02-27 06:53:33 -0800 |
| commit | 225fc572faa1f7f54c7266d5d6a5c74345dfdaa2 (patch) | |
| tree | a30dd2959f3e99d15bc6b4c12fac5a32c3b8994a | |
| download | para-225fc572faa1f7f54c7266d5d6a5c74345dfdaa2.tar.xz para-225fc572faa1f7f54c7266d5d6a5c74345dfdaa2.zip | |
feat(para): :star:
| -rw-r--r-- | .gitattributes | 2 | ||||
| -rw-r--r-- | .github/workflows/release.yaml | 50 | ||||
| -rw-r--r-- | .github/workflows/rust.yaml | 35 | ||||
| -rw-r--r-- | .gitignore | 16 | ||||
| -rw-r--r-- | Cargo.toml | 40 | ||||
| -rw-r--r-- | LICENSE | 22 | ||||
| -rw-r--r-- | Makefile.toml | 60 | ||||
| -rw-r--r-- | README.rst | 102 | ||||
| -rw-r--r-- | assets/para.png | bin | 0 -> 2304 bytes | |||
| -rw-r--r-- | build.rs | 31 | ||||
| -rw-r--r-- | rust-toolchain.toml | 2 | ||||
| -rw-r--r-- | rustfmt.toml | 29 | ||||
| -rw-r--r-- | src/main.rs | 129 | ||||
| -rw-r--r-- | src/ppm.rs | 566 |
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" @@ -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 Binary files differnew file mode 100644 index 0000000..4c31129 --- /dev/null +++ b/assets/para.png 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]; |