~aleteoryx/pjsekai_emote_packs

2d8193c0f60e139471adc075587bf4cb06930de7 — Aleteoryx 10 months ago 77e184e
generate all the things!
8 files changed, 707 insertions(+), 324 deletions(-)

M .gitignore
M Cargo.lock
M Cargo.toml
D slugs_en.json
M src/en.rs
M src/main.rs
A src/model.rs
M src/slug.rs
M .gitignore => .gitignore +1 -0
@@ 1,3 1,4 @@
/target
*~
/stamps_cache
/stamp_packs

M Cargo.lock => Cargo.lock +316 -11
@@ 34,7 34,7 @@ dependencies = [
 "accesskit_consumer",
 "atspi-common",
 "serde",
 "thiserror",
 "thiserror 1.0.69",
 "zvariant",
]



@@ 126,6 126,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"

[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
 "cfg-if",
 "cipher",
 "cpufeatures",
]

[[package]]
name = "ahash"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 156,7 167,7 @@ dependencies = [
 "ndk-context",
 "ndk-sys 0.6.0+11769913",
 "num_enum",
 "thiserror",
 "thiserror 1.0.69",
]

[[package]]


@@ 175,6 186,15 @@ dependencies = [
]

[[package]]
name = "arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
 "derive_arbitrary",
]

[[package]]
name = "arboard"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 552,6 572,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"

[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
 "bzip2-sys",
 "libc",
]

[[package]]
name = "bzip2-sys"
version = "0.1.11+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
dependencies = [
 "cc",
 "libc",
 "pkg-config",
]

[[package]]
name = "calloop"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 562,7 603,7 @@ dependencies = [
 "polling",
 "rustix",
 "slab",
 "thiserror",
 "thiserror 1.0.69",
]

[[package]]


@@ 622,6 663,16 @@ dependencies = [
]

[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
 "crypto-common",
 "inout",
]

[[package]]
name = "clipboard-win"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 660,6 711,12 @@ dependencies = [
]

[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"

[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 719,6 776,21 @@ dependencies = [
]

[[package]]
name = "crc"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636"
dependencies = [
 "crc-catalog",
]

[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"

[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 750,6 822,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991"

[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"

[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
 "powerfmt",
]

[[package]]
name = "derive_arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 757,6 855,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
 "block-buffer",
 "crypto-common",
 "subtle",
]

[[package]]


@@ 880,7 979,7 @@ dependencies = [
 "epaint",
 "log",
 "profiling",
 "thiserror",
 "thiserror 1.0.69",
 "type-map",
 "web-time",
 "wgpu",


@@ 1465,6 1564,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"

[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
 "digest",
]

[[package]]
name = "home"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1756,6 1864,15 @@ dependencies = [
]

[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
 "generic-array",
]

[[package]]
name = "ipnet"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1778,7 1895,7 @@ dependencies = [
 "combine",
 "jni-sys",
 "log",
 "thiserror",
 "thiserror 1.0.69",
 "walkdir",
 "windows-sys 0.45.0",
]


@@ 1881,12 1998,28 @@ dependencies = [
]

[[package]]
name = "lockfree-object-pool"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"

[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"

[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
 "byteorder",
 "crc",
]

[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1988,7 2121,7 @@ dependencies = [
 "rustc-hash",
 "spirv",
 "termcolor",
 "thiserror",
 "thiserror 1.0.69",
 "unicode-xid",
]



@@ 2021,7 2154,7 @@ dependencies = [
 "ndk-sys 0.6.0+11769913",
 "num_enum",
 "raw-window-handle",
 "thiserror",
 "thiserror 1.0.69",
]

[[package]]


@@ 2068,6 2201,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"

[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"

[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2432,6 2571,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"

[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
 "digest",
 "hmac",
]

[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2490,7 2639,9 @@ dependencies = [
 "reqwest",
 "serde",
 "serde_json",
 "sha256",
 "strum",
 "zip",
]

[[package]]


@@ 2528,6 2679,12 @@ dependencies = [
]

[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"

[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2917,6 3074,30 @@ dependencies = [
]

[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "digest",
]

[[package]]
name = "sha256"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0"
dependencies = [
 "async-trait",
 "bytes",
 "hex",
 "sha2",
 "tokio",
]

[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2975,7 3156,7 @@ dependencies = [
 "log",
 "memmap2",
 "rustix",
 "thiserror",
 "thiserror 1.0.69",
 "wayland-backend",
 "wayland-client",
 "wayland-csd-frame",


@@ 3158,7 3339,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
 "thiserror-impl",
 "thiserror-impl 1.0.69",
]

[[package]]
name = "thiserror"
version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
dependencies = [
 "thiserror-impl 2.0.9",
]

[[package]]


@@ 3173,6 3363,36 @@ dependencies = [
]

[[package]]
name = "thiserror-impl"
version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "time"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
dependencies = [
 "deranged",
 "num-conv",
 "powerfmt",
 "serde",
 "time-core",
]

[[package]]
name = "time-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"

[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 3722,7 3942,7 @@ dependencies = [
 "raw-window-handle",
 "rustc-hash",
 "smallvec",
 "thiserror",
 "thiserror 1.0.69",
 "wgpu-hal",
 "wgpu-types",
]


@@ 3760,7 3980,7 @@ dependencies = [
 "renderdoc-sys",
 "rustc-hash",
 "smallvec",
 "thiserror",
 "thiserror 1.0.69",
 "wasm-bindgen",
 "web-sys",
 "wgpu-types",


@@ 4405,6 4625,20 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
 "zeroize_derive",
]

[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zerovec"


@@ 4429,6 4663,77 @@ dependencies = [
]

[[package]]
name = "zip"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45"
dependencies = [
 "aes",
 "arbitrary",
 "bzip2",
 "constant_time_eq",
 "crc32fast",
 "crossbeam-utils",
 "deflate64",
 "displaydoc",
 "flate2",
 "hmac",
 "indexmap",
 "lzma-rs",
 "memchr",
 "pbkdf2",
 "rand",
 "sha1",
 "thiserror 2.0.9",
 "time",
 "zeroize",
 "zopfli",
 "zstd",
]

[[package]]
name = "zopfli"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946"
dependencies = [
 "bumpalo",
 "crc32fast",
 "lockfree-object-pool",
 "log",
 "once_cell",
 "simd-adler32",
]

[[package]]
name = "zstd"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9"
dependencies = [
 "zstd-safe",
]

[[package]]
name = "zstd-safe"
version = "7.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059"
dependencies = [
 "zstd-sys",
]

[[package]]
name = "zstd-sys"
version = "2.0.13+zstd.1.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa"
dependencies = [
 "cc",
 "pkg-config",
]

[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"

M Cargo.toml => Cargo.toml +2 -0
@@ 10,4 10,6 @@ egui_extras = { version = "0.30.0", features = ["image", "file"] }
reqwest = { version = "0.12.12", features = ["blocking"] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.134"
sha256 = "1.5.0"
strum = { version = "0.26.3", features = ["derive"] }
zip = "2.2.2"

D slugs_en.json => slugs_en.json +0 -11
@@ 1,11 0,0 @@
{
  "701": "nene_i_dont_want_to_lose",
  "1": "miku_hello",
  "64": "honami_oh",
  "480": "text_nice_to_meet_you",
  "574": "ena_can_i_keep_drawing",
  "437": "text_have_some_bonus_energy",
  "505": "minori_shocked",
  "250": "mizuki_laugh",
  "723": "mizuki_satisfied"
}
\ No newline at end of file

M src/en.rs => src/en.rs +13 -251
@@ 1,256 1,18 @@
use std::collections::HashSet;

use serde::Deserialize;
use strum::{EnumIter, IntoEnumIterator, EnumProperty};

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RawStamp {
  id: usize,
  seq: usize,

  name: String,
  description: Option<String>,
  stamp_type: String,

  assetbundle_name: String,
  balloon_assetbundle_name: String,

  // ignored by us, used in sekai-viewer UI
  archive_published_at: usize,
  // ignored by us, used in sekai-viewer UI
  archive_display_type: Option<String>,

  character_id_1: Option<Character>,
  character_id_2: Option<Character>,
  game_character_unit_id: Option<Character>
}

impl RawStamp {
  pub fn to_stamp(self) -> Stamp {
    use StampCharacter::*;

    let RawStamp { id, seq,
                   name, description, stamp_type,
                   assetbundle_name, balloon_assetbundle_name,
                   character_id_1, character_id_2, game_character_unit_id, .. } = self;

    let character = match (character_id_1, character_id_2, game_character_unit_id) {
      (None, None, None)
        => Nobody,
      (Some(character_id), None, None)
        => Solo{character_id},
      (Some(character_id_1), Some(character_id_2), None)
        => Duo{character_id_1, character_id_2},
      (Some(character_id), None, Some(game_character_unit_id))
        => SoloExtra{character_id, game_character_unit_id},
      (None, None, Some(game_character_unit_id))
        => GCUID{game_character_unit_id},

      x => { panic!("They added a new character format: {x:?}"); }
    };

    Stamp { id, seq,
            name, description, stamp_type,
            assetbundle_name, balloon_assetbundle_name,
            character }
  }
}

#[derive(Clone, Debug, Default)]
pub struct Stamp {
  pub id: usize,
  pub seq: usize,

  pub name: String,
  pub description: Option<String>,
  pub stamp_type: String,

  pub assetbundle_name: String,
  pub balloon_assetbundle_name: String,

  pub character: StampCharacter
}

#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Hash)]
pub enum StampCharacter {
  #[default]
  Nobody,
  Solo{character_id: Character},
  Duo{character_id_1: Character, character_id_2: Character},
  SoloExtra{character_id: Character, game_character_unit_id: Character},
  GCUID{game_character_unit_id: Character},
}
impl std::fmt::Display for StampCharacter {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match *self {
      Self::Nobody => write!(f, "text"),
      Self::Solo{character_id} => write!(f, "{}", character_id.get_str("prefix").unwrap()),
      Self::Duo{character_id_1, character_id_2} => write!(f, "{}_{}", character_id_1.get_str("prefix").unwrap(), character_id_2.get_str("prefix").unwrap()),
      Self::SoloExtra{game_character_unit_id, ..} => write!(f, "{}", game_character_unit_id.get_str("prefix").unwrap()),
      Self::GCUID{game_character_unit_id} => write!(f, "text_{}", game_character_unit_id.get_str("prefix").unwrap())
    }
  }
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Hash, EnumIter, EnumProperty)]
#[repr(u8)]
#[serde(from = "u8")]
pub enum Character {
  // Leo / Need
  #[strum(props(prefix = "ichika"))]
  Ichika = 1,
  #[strum(props(prefix = "saki"))]
  Saki = 2,
  #[strum(props(prefix = "honami"))]
  Honami = 3,
  #[strum(props(prefix = "shiho"))]
  Shiho = 4,

  // MORE MORE JUMP!
  #[strum(props(prefix = "minori"))]
  Minori = 5,
  #[strum(props(prefix = "haruka"))]
  Haruka = 6,
  #[strum(props(prefix = "airi"))]
  Airi = 7,
  #[strum(props(prefix = "shizuku"))]
  Shizuku = 8,

  // Vivid BAD SQUAD
  #[strum(props(prefix = "kohane"))]
  Kohane = 9,
  #[strum(props(prefix = "an"))]
  An = 10,
  #[strum(props(prefix = "akito"))]
  Akito = 11,
  #[strum(props(prefix = "toya"))]
  Toya = 12,

  // Wonderlands x Showtime
  #[strum(props(prefix = "tsukasa"))]
  Tsukasa = 13,
  #[strum(props(prefix = "emu"))]
  Emu = 14,
  #[strum(props(prefix = "nene"))]
  Nene = 15,
  #[strum(props(prefix = "rui"))]
  Rui = 16,

  // Nightcord at 25:00
  #[strum(props(prefix = "kanade"))]
  Kanade = 17,
  #[strum(props(prefix = "mafuyu"))]
  Mafuyu = 18,
  #[strum(props(prefix = "ena"))]
  Ena = 19,
  #[strum(props(prefix = "mizuki"))]
  Mizuki = 20,

  // VOCALOID
  #[strum(props(prefix = "miku"))]
  Miku = 21,
  #[strum(props(prefix = "rin"))]
  Rin = 22,
  #[strum(props(prefix = "len"))]
  Len = 23,
  #[strum(props(prefix = "luka"))]
  Luka = 24,
  #[strum(props(prefix = "meiko"))]
  MEIKO = 25,
  #[strum(props(prefix = "kaito"))]
  KAITO = 26,

  // "Game Character Unit ID"
  // Basically just, character IDs associated to a particular group
  // - Miku
  #[strum(props(prefix = "ln_miku"))]
  LnMiku = 27,
  #[strum(props(prefix = "mmj_miku"))]
  MmjMiku = 28,
  #[strum(props(prefix = "vbs_miku"))]
  VbsMiku = 29,
  #[strum(props(prefix = "wxs_miku"))]
  WxsMiku = 30,
  #[strum(props(prefix = "n25_miku"))]
  N25Miku = 31,

  // - Rin
  #[strum(props(prefix = "ln_rin"))]
  LnRin = 32,
  #[strum(props(prefix = "mmj_rin"))]
  MmjRin = 33,
  #[strum(props(prefix = "vbs_rin"))]
  VbsRin = 34,
  #[strum(props(prefix = "wxs_rin"))]
  WxsRin = 35,
  #[strum(props(prefix = "n25_rin"))]
  N25Rin = 36,

  // - Len
  #[strum(props(prefix = "ln_len"))]
  LnLen = 37,
  #[strum(props(prefix = "mmj_len"))]
  MmjLen = 38,
  #[strum(props(prefix = "vbs_len"))]
  VbsLen = 39,
  #[strum(props(prefix = "wxs_len"))]
  WxsLen = 40,
  #[strum(props(prefix = "n25_len"))]
  N25Len = 41,

  // - Luka
  #[strum(props(prefix = "ln_luka"))]
  LnLuka = 42,
  #[strum(props(prefix = "mmj_luka"))]
  MmjLuka = 43,
  #[strum(props(prefix = "vbs_luka"))]
  VbsLuka = 44,
  #[strum(props(prefix = "wxs_luka"))]
  WxsLuka = 45,
  #[strum(props(prefix = "n25_luka"))]
  N25Luka = 46,

  // - MEIKO
  #[strum(props(prefix = "ln_meiko"))]
  LnMEIKO = 47,
  #[strum(props(prefix = "mmj_meiko"))]
  MmjMEIKO = 48,
  #[strum(props(prefix = "vbs_meiko"))]
  VbsMEIKO = 49,
  #[strum(props(prefix = "wxs_meiko"))]
  WxsMEIKO = 50,
  #[strum(props(prefix = "n25_meiko"))]
  N25MEIKO = 51,

  // - KAITO
  #[strum(props(prefix = "ln_kaito"))]
  LnKAITO = 52,
  #[strum(props(prefix = "mmj_kaito"))]
  MmjKAITO = 53,
  #[strum(props(prefix = "vbs_kaito"))]
  VbsKAITO = 54,
  #[strum(props(prefix = "wxs_kaito"))]
  WxsKAITO = 55,
  #[strum(props(prefix = "n25_kaito"))]
  N25KAITO = 56,
}
impl From<u8> for Character {
  fn from(val: u8) -> Self {
    for var in Self::iter() {
      if var as u8 == val {
        return var;
pub struct Helpers;
impl crate::Helpers for Helpers {
  fn escape_name(name: &str) -> String {
    let mut ret = String::new();

    let name = name.split(":").nth(1).unwrap().trim();

    for c in name.chars() {
      if c.is_ascii_alphanumeric() {
        ret.push(c.to_ascii_lowercase());
      } else if c.is_ascii_whitespace() {
        ret.push('_');
      }
    }

    panic!("Unknown Character ID: {val}");
  }
}
impl std::fmt::Display for Character {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
    write!(f, "{self:?}")
    ret
  }
}

impl Stamp {
}

M src/main.rs => src/main.rs +102 -40
@@ 1,13 1,35 @@
#![feature(iter_intersperse)]
mod en;

mod model;
mod slug;

use std::collections::HashSet;
use std::collections::{BTreeMap, HashSet};
use std::fs::{File, self};
use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};

use bytes::Bytes;
use reqwest::blocking::Client;
use strum::{EnumIs, EnumProperty};
use zip::write::{SimpleFileOptions, ZipWriter};

const BASE_DOWNLOAD_PATH: &'static str = "https://aleteoryx.me/downloads2/emotes/pleroma/";

trait Helpers {
  fn escape_name(name: &str) -> String;
}

#[derive(Default, serde::Serialize)]
struct PackMetadata {
  name: String,
  description: String,
  homepage: String,
  files: String,
  src: String,
  src_sha256: String,
  license: String
}

fn get_stamp_asset_png(client: &Client, id: &str) -> Bytes {
  client


@@ 22,25 44,28 @@ fn get_stamp_asset_png(client: &Client, id: &str) -> Bytes {

fn main() {
  let repo_dir: PathBuf = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_owned()).into();

  let client = Client::new();

  // English region (global)
  let sekai_en_dir = repo_dir.join("sekai_world/db_en_diff");
  let stamps_en_path = sekai_en_dir.join("stamps.json");
  let stamps_en =
    serde_json::from_reader::<_, Vec<en::RawStamp>>(
      File::open(&stamps_en_path)
  handle_region::<en::Helpers>(&client, &repo_dir, "en");
}

fn handle_region<H: Helpers>(client: &Client, repo_dir: &Path, region: &'static str) {
  let sekai_dir = repo_dir.join(format!("sekai_world/db_{region}_diff"));

  let stamps =
    serde_json::from_reader::<_, Vec<model::RawStamp>>(
      File::open(sekai_dir.join("stamps.json"))
        .expect("Couldn't open stamps.json"))
      .expect("stamps.json invalid")
      .into_iter()
      .map(|a| a.to_stamp())
      .collect::<Vec<_>>();

  let cache_dir = repo_dir.join("stamps_cache/en");
  let cache_dir = repo_dir.join("stamps_cache").join(&region);
  fs::create_dir_all(&cache_dir);

  for stamp in &stamps_en {
  for stamp in &stamps {
    let download_path = cache_dir.join(&stamp.assetbundle_name).with_extension("png");
    if !fs::exists(&download_path).unwrap() {
      println!("Downlading asset {}...", &stamp.assetbundle_name);


@@ 50,44 75,81 @@ fn main() {
  }

  let character_stamps =
    stamps_en.clone().into_iter()
      .filter(|a|
        match a.character {
          en::StampCharacter::Solo { .. } | en::StampCharacter::SoloExtra { .. } => true,
          _ => false
       })
    stamps.clone().into_iter()
      .filter(|a| a.character.is_solo() || a.character.is_solo_extra())
       .collect::<Vec<_>>();
  let text_stamps =
    stamps_en.clone().into_iter()
      .filter(|a|
        match a.character {
          en::StampCharacter::Nobody => true,
          _ => false
       })
    stamps.clone().into_iter()
      .filter(|a| a.character.is_nobody())
       .collect::<Vec<_>>();

  for stamp in text_stamps {
    println!("{} => {}", stamp.name, stamp.name.to_ascii_lowercase().replace(" ", "_"));
  }

  let name_stamps =
    stamps_en.clone().into_iter()
      .filter(|a|
        match a.character {
          en::StampCharacter::GCUID { .. } => true,
          _ => false
       })
    stamps.clone().into_iter()
      .filter(|a| a.character.is_gcuid())
       .collect::<Vec<_>>();
  let pair_stamps =
    stamps_en.clone().into_iter()
      .filter(|a|
        match a.character {
          en::StampCharacter::Duo { .. } => true,
          _ => false
       })
    stamps.clone().into_iter()
      .filter(|a| a.character.is_duo())
       .collect::<Vec<_>>();

  slug::SlugPrompter::run("en".into(), &repo_dir, character_stamps);
  let packs_dir = repo_dir.join("stamp_packs");
  let out_dir = packs_dir.join("Project SEKAI").join(region);
  fs::create_dir_all(&out_dir);
  let mut packs_list: BTreeMap<String, PackMetadata> = BTreeMap::new();

  let character_out_dir = out_dir.join("characters");
  fs::create_dir_all(&character_out_dir);

  let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
  for cid in 1..=26 {
    let character = model::Character::from(cid);
    let character_slug = character.get_str("prefix").unwrap();

    let zip_path = character_out_dir.join(&character_slug).with_extension("zip");
    let mut writer =
      ZipWriter::new(
        File::create(&zip_path).unwrap());
    let mut emotes: BTreeMap<String, String> = BTreeMap::new();

    println!("Generating character stamp pack for {}...", character);

    for stamp in &character_stamps {
      match stamp.character {
        model::StampCharacter::Solo{ character_id } |
        model::StampCharacter::SoloExtra{ character_id, .. } if character_id as u8 == cid => {
          let character_slug = stamp.character.to_string() + "_" + &H::escape_name(&stamp.name);
          writer.start_file(character_slug.clone() + ".png", options);
          writer.write_all(&fs::read(cache_dir.join(&stamp.assetbundle_name).with_extension("png")).unwrap());
          emotes.insert(character_slug.clone(), character_slug + ".png");
        },
        _ => ()
      }
    }

    writer.finish();

    File::create(
      character_out_dir
        .join(&character_slug)
        .with_extension("json"))
      .unwrap()
      .write_all(&serde_json::to_vec(&emotes).unwrap());

    let pack_path = format!("Project SEKAI/{region}/characters/{character_slug}");

    packs_list.insert(
      pack_path.clone(),
      PackMetadata {
        name: format!("Project SEKAI ({region}): {}", character.get_str(&format!("name_{region}")).unwrap()),
        description: "Made possible by the sekai-viewer project. <https:///sekai.best>".into(),
        homepage: "https://git.amehut.dev/~aleteoryx/pjsekai_emote_packs".into(),
        files: BASE_DOWNLOAD_PATH.to_string() + &pack_path + ".json",
        src: BASE_DOWNLOAD_PATH.to_string() + &pack_path + ".zip",
        src_sha256: sha256::try_digest(&zip_path).unwrap(),
        license: "N/A".into()
      });
  }

  File::create(packs_dir.join("manifest.json"))
    .unwrap()
    .write_all(&serde_json::to_vec_pretty(&packs_list).unwrap());
}

A src/model.rs => src/model.rs +256 -0
@@ 0,0 1,256 @@
use std::collections::HashSet;

use serde::Deserialize;
use strum::{EnumIter, IntoEnumIterator, EnumProperty, EnumIs};

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RawStamp {
  id: usize,
  seq: usize,

  name: String,
  description: Option<String>,
  stamp_type: String,

  assetbundle_name: String,
  balloon_assetbundle_name: String,

  // ignored by us, used in sekai-viewer UI
  archive_published_at: usize,
  // ignored by us, used in sekai-viewer UI
  archive_display_type: Option<String>,

  character_id_1: Option<Character>,
  character_id_2: Option<Character>,
  game_character_unit_id: Option<Character>
}

impl RawStamp {
  pub fn to_stamp(self) -> Stamp {
    use StampCharacter::*;

    let RawStamp { id, seq,
                   name, description, stamp_type,
                   assetbundle_name, balloon_assetbundle_name,
                   character_id_1, character_id_2, game_character_unit_id, .. } = self;

    let character = match (character_id_1, character_id_2, game_character_unit_id) {
      (None, None, None)
        => Nobody,
      (Some(character_id), None, None)
        => Solo{character_id},
      (Some(character_id_1), Some(character_id_2), None)
        => Duo{character_id_1, character_id_2},
      (Some(character_id), None, Some(game_character_unit_id))
        => SoloExtra{character_id, game_character_unit_id},
      (None, None, Some(game_character_unit_id))
        => GCUID{game_character_unit_id},

      x => { panic!("They added a new character format: {x:?}"); }
    };

    Stamp { id, seq,
            name, description, stamp_type,
            assetbundle_name, balloon_assetbundle_name,
            character }
  }
}

#[derive(Clone, Debug, Default)]
pub struct Stamp {
  pub id: usize,
  pub seq: usize,

  pub name: String,
  pub description: Option<String>,
  pub stamp_type: String,

  pub assetbundle_name: String,
  pub balloon_assetbundle_name: String,

  pub character: StampCharacter
}

#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Hash, EnumIs)]
pub enum StampCharacter {
  #[default]
  Nobody,
  Solo{character_id: Character},
  Duo{character_id_1: Character, character_id_2: Character},
  SoloExtra{character_id: Character, game_character_unit_id: Character},
  GCUID{game_character_unit_id: Character},
}
impl std::fmt::Display for StampCharacter {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match *self {
      Self::Nobody => write!(f, "text"),
      Self::Solo{character_id} => write!(f, "{}", character_id.get_str("prefix").unwrap()),
      Self::Duo{character_id_1, character_id_2} => write!(f, "{}_{}", character_id_1.get_str("prefix").unwrap(), character_id_2.get_str("prefix").unwrap()),
      Self::SoloExtra{game_character_unit_id, ..} => write!(f, "{}", game_character_unit_id.get_str("prefix").unwrap()),
      Self::GCUID{game_character_unit_id} => write!(f, "text_{}", game_character_unit_id.get_str("prefix").unwrap())
    }
  }
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Hash, EnumIter, EnumProperty)]
#[repr(u8)]
#[serde(from = "u8")]
pub enum Character {
  // Leo / Need
  #[strum(props(prefix = "ichika", name_en = "Ichika Hoshino"))]
  Ichika = 1,
  #[strum(props(prefix = "saki", name_en = "Saki Tenma"))]
  Saki = 2,
  #[strum(props(prefix = "honami", name_en = "Honami Mochizuki"))]
  Honami = 3,
  #[strum(props(prefix = "shiho", name_en = "Shiho Hinomori"))]
  Shiho = 4,

  // MORE MORE JUMP!
  #[strum(props(prefix = "minori", name_en = "Minori Hanasato"))]
  Minori = 5,
  #[strum(props(prefix = "haruka", name_en = "Haruka Kiritani"))]
  Haruka = 6,
  #[strum(props(prefix = "airi", name_en = "Airi Momoi"))]
  Airi = 7,
  #[strum(props(prefix = "shizuku", name_en = "Shizuku Hinomori"))]
  Shizuku = 8,

  // Vivid BAD SQUAD
  #[strum(props(prefix = "kohane", name_en = "Kohane Azusawa"))]
  Kohane = 9,
  #[strum(props(prefix = "an", name_en = "An Shiraishi"))]
  An = 10,
  #[strum(props(prefix = "akito", name_en = "Akito Shinonome"))]
  Akito = 11,
  #[strum(props(prefix = "toya", name_en = "Toya Aoyagi"))]
  Toya = 12,

  // Wonderlands x Showtime
  #[strum(props(prefix = "tsukasa", name_en = "Tsukasa Tenma"))]
  Tsukasa = 13,
  #[strum(props(prefix = "emu", name_en = "Emu Otori"))]
  Emu = 14,
  #[strum(props(prefix = "nene", name_en = "Nene Kusanagi"))]
  Nene = 15,
  #[strum(props(prefix = "rui", name_en = "Rui Kamishiro"))]
  Rui = 16,

  // Nightcord at 25:00
  #[strum(props(prefix = "kanade", name_en = "Kanade Yoisaki"))]
  Kanade = 17,
  #[strum(props(prefix = "mafuyu", name_en = "Mafuyu Asahina"))]
  Mafuyu = 18,
  #[strum(props(prefix = "ena", name_en = "Ena Shinonome"))]
  Ena = 19,
  #[strum(props(prefix = "mizuki", name_en = "Mizuki Akiyama"))]
  Mizuki = 20,

  // VOCALOID
  #[strum(props(prefix = "miku", name_en = "Hatsune Miku"))]
  Miku = 21,
  #[strum(props(prefix = "rin", name_en = "Kagamine Rin"))]
  Rin = 22,
  #[strum(props(prefix = "len", name_en = "Kagamine Len"))]
  Len = 23,
  #[strum(props(prefix = "luka", name_en = "Megurine Luka"))]
  Luka = 24,
  #[strum(props(prefix = "meiko", name_en = "MEIKO"))]
  MEIKO = 25,
  #[strum(props(prefix = "kaito", name_en = "KAITO"))]
  KAITO = 26,

  // "Game Character Unit ID"
  // Basically just, character IDs associated to a particular group
  // - Miku
  #[strum(props(prefix = "ln_miku", name_en = "Leo / Need Miku"))]
  LnMiku = 27,
  #[strum(props(prefix = "mmj_miku", name_en = "MORE MORE JUMP! Miku"))]
  MmjMiku = 28,
  #[strum(props(prefix = "vbs_miku", name_en = "Vivid BAD SQUAD Miku"))]
  VbsMiku = 29,
  #[strum(props(prefix = "wxs_miku", name_en = "Wonderlands x Showtime Miku"))]
  WxsMiku = 30,
  #[strum(props(prefix = "n25_miku", name_en = "Nightcord at 25:00 Miku"))]
  N25Miku = 31,

  // - Rin
  #[strum(props(prefix = "ln_rin", name_en = "Leo / Need Rin"))]
  LnRin = 32,
  #[strum(props(prefix = "mmj_rin", name_en = "MORE MORE JUMP! Rin"))]
  MmjRin = 33,
  #[strum(props(prefix = "vbs_rin", name_en = "Vivid BAD SQUAD Rin"))]
  VbsRin = 34,
  #[strum(props(prefix = "wxs_rin", name_en = "Wonderlands x Showtime Rin"))]
  WxsRin = 35,
  #[strum(props(prefix = "n25_rin", name_en = "Nightcord at 25:00 Rin"))]
  N25Rin = 36,

  // - Len
  #[strum(props(prefix = "ln_len", name_en = "Leo / Need Len"))]
  LnLen = 37,
  #[strum(props(prefix = "mmj_len", name_en = "MORE MORE JUMP! Len"))]
  MmjLen = 38,
  #[strum(props(prefix = "vbs_len", name_en = "Vivid BAD SQUAD Len"))]
  VbsLen = 39,
  #[strum(props(prefix = "wxs_len", name_en = "Wonderlands x Showtime Len"))]
  WxsLen = 40,
  #[strum(props(prefix = "n25_len", name_en = "Nightcord at 25:00 Len"))]
  N25Len = 41,

  // - Luka
  #[strum(props(prefix = "ln_luka", name_en = "Leo / Need Luka"))]
  LnLuka = 42,
  #[strum(props(prefix = "mmj_luka", name_en = "MORE MORE JUMP! Luka"))]
  MmjLuka = 43,
  #[strum(props(prefix = "vbs_luka", name_en = "Vivid BAD SQUAD Luka"))]
  VbsLuka = 44,
  #[strum(props(prefix = "wxs_luka", name_en = "Wonderlands x Showtime Luka"))]
  WxsLuka = 45,
  #[strum(props(prefix = "n25_luka", name_en = "Nightcord at 25:00 Luka"))]
  N25Luka = 46,

  // - MEIKO
  #[strum(props(prefix = "ln_meiko", name_en = "Leo / Need MEIKO"))]
  LnMEIKO = 47,
  #[strum(props(prefix = "mmj_meiko", name_en = "MORE MORE JUMP! MEIKO"))]
  MmjMEIKO = 48,
  #[strum(props(prefix = "vbs_meiko", name_en = "Vivid BAD SQUAD MEIKO"))]
  VbsMEIKO = 49,
  #[strum(props(prefix = "wxs_meiko", name_en = "Wonderlands x Showtime MEIKO"))]
  WxsMEIKO = 50,
  #[strum(props(prefix = "n25_meiko", name_en = "Nightcord at 25:00 MEIKO"))]
  N25MEIKO = 51,

  // - KAITO
  #[strum(props(prefix = "ln_kaito", name_en = "Leo / Need KAITO"))]
  LnKAITO = 52,
  #[strum(props(prefix = "mmj_kaito", name_en = "MORE MORE JUMP! KAITO"))]
  MmjKAITO = 53,
  #[strum(props(prefix = "vbs_kaito", name_en = "Vivid BAD SQUAD KAITO"))]
  VbsKAITO = 54,
  #[strum(props(prefix = "wxs_kaito", name_en = "Wonderlands x Showtime KAITO"))]
  WxsKAITO = 55,
  #[strum(props(prefix = "n25_kaito", name_en = "Nightcord at 25:00 KAITO"))]
  N25KAITO = 56,
}
impl From<u8> for Character {
  fn from(val: u8) -> Self {
    for var in Self::iter() {
      if var as u8 == val {
        return var;
      }
    }

    panic!("Unknown Character ID: {val}");
  }
}
impl std::fmt::Display for Character {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
    write!(f, "{self:?}")
  }
}

impl Stamp {
}

M src/slug.rs => src/slug.rs +17 -11
@@ 1,4 1,4 @@
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::fs::{File, self};
use std::path::{Path, PathBuf};



@@ 6,9 6,9 @@ use eframe::egui;

#[derive(Default)]
pub struct SlugPrompter {
  id_to_stamp_map: HashMap<usize, crate::en::Stamp>,
  id_to_slug_map: HashMap<usize, Option<String>>,
  slug_to_id_map: HashMap<String, usize>,
  id_to_stamp_map: BTreeMap<usize, crate::model::Stamp>,
  id_to_slug_map: BTreeMap<usize, Option<String>>,
  slug_to_id_map: BTreeMap<String, usize>,

  current_id: usize,
  current_slug: String,


@@ 20,9 20,9 @@ pub struct SlugPrompter {
}

impl SlugPrompter {
  pub fn run(locale: String, repo_dir: &Path, stamps: Vec<crate::en::Stamp>) {
  pub fn run(locale: String, repo_dir: &Path, stamps: Vec<crate::model::Stamp>) {
    let file_path = repo_dir.join(format!("slugs_{locale}.json"));
    let id_to_slug_map: HashMap<usize, Option<String>>;
    let id_to_slug_map: BTreeMap<usize, Option<String>>;
    if fs::exists(&file_path).unwrap() {
      id_to_slug_map =
        serde_json::from_reader(


@@ 40,7 40,7 @@ impl SlugPrompter {
      Box::new(|cc| Ok(Box::new(SlugPrompter::new(cc, id_to_slug_map, stamps, repo_dir.join("stamps_cache").join(&locale), file_path)))));
  }

  fn new(_cc: &eframe::CreationContext<'_>, id_to_slug_map: HashMap<usize, Option<String>>, stamps: Vec<crate::en::Stamp>, cache_dir: PathBuf, file_path: PathBuf) -> Self {
  fn new(_cc: &eframe::CreationContext<'_>, id_to_slug_map: BTreeMap<usize, Option<String>>, stamps: Vec<crate::model::Stamp>, cache_dir: PathBuf, file_path: PathBuf) -> Self {
    let current_id = stamps[0].id;

    let slug_to_id_map = id_to_slug_map.clone().into_iter().filter_map(|(a, b)| b.map(|b2| (b2, a))).collect();


@@ 97,18 97,24 @@ impl eframe::App for SlugPrompter {
            .split("]").nth(1).unwrap()
            .trim());
      });
      ui.text_edit_singleline(&mut self.current_slug);
      let text_res = ui.text_edit_singleline(&mut self.current_slug);

      ui.label(format!("{}/{}", self.id_to_slug_map.len(), self.id_to_stamp_map.len()));

      if let Some(s) = self.current_err.as_ref() {
        ui.colored_label(egui::Color32::RED, s);
      }

      if ui.button("Confirm").clicked() {
      if ui.button("Confirm").clicked() || text_res.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
        if self.slug_to_id_map.contains_key(&self.current_slug) {
          self.current_err = Some(format!("Slug \"{}\" already taken by {}!", self.current_slug, self.slug_to_id_map[&self.current_slug]));
        } else {
          self.slug_to_id_map.insert(self.current_slug.clone(), self.current_id);
          self.id_to_slug_map.insert(self.current_id, Some(self.current_slug.clone()));
          if self.current_slug == "" {
            self.id_to_slug_map.insert(self.current_id, None);
          } else {
            self.slug_to_id_map.insert(self.current_slug.clone(), self.current_id);
            self.id_to_slug_map.insert(self.current_id, Some(self.current_slug.clone()));
          }

          self.write_file();
        }