From 8cb38875afcb939b47e60a9d2afd28ca438f65e3 Mon Sep 17 00:00:00 2001 From: Anna Clemens Date: Sun, 3 Oct 2021 15:52:21 -0400 Subject: [PATCH] chore: initial commit --- .gitignore | 2 + Cargo.toml | 19 +++ src/error.rs | 18 ++ src/lib.rs | 43 +++++ src/payload.rs | 300 ++++++++++++++++++++++++++++++++++ src/payload/auto_translate.rs | 39 +++++ src/payload/dalamud_link.rs | 48 ++++++ src/payload/emphasis.rs | 49 ++++++ src/payload/icon.rs | 35 ++++ src/payload/item.rs | 76 +++++++++ src/payload/map_link.rs | 51 ++++++ src/payload/new_line.rs | 16 ++ src/payload/player.rs | 53 ++++++ src/payload/quest.rs | 42 +++++ src/payload/raw.rs | 31 ++++ src/payload/se_hyphen.rs | 16 ++ src/payload/status.rs | 41 +++++ src/payload/text.rs | 72 ++++++++ src/payload/ui_foreground.rs | 35 ++++ src/payload/ui_glow.rs | 35 ++++ src/serde.rs | 19 +++ src/test/auto_translate.rs | 14 ++ src/test/dalamud_link.rs | 16 ++ src/test/emphasis.rs | 15 ++ src/test/icon.rs | 11 ++ src/test/item.rs | 15 ++ src/test/map_link.rs | 18 ++ src/test/mod.rs | 52 ++++++ src/test/new_line.rs | 13 ++ src/test/player.rs | 14 ++ src/test/quest.rs | 11 ++ src/test/raw.rs | 11 ++ src/test/se_hyphen.rs | 13 ++ src/test/status.rs | 11 ++ src/test/text.rs | 11 ++ src/test/ui_foreground.rs | 11 ++ src/test/ui_glow.rs | 11 ++ 37 files changed, 1287 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/payload.rs create mode 100644 src/payload/auto_translate.rs create mode 100644 src/payload/dalamud_link.rs create mode 100644 src/payload/emphasis.rs create mode 100644 src/payload/icon.rs create mode 100644 src/payload/item.rs create mode 100644 src/payload/map_link.rs create mode 100644 src/payload/new_line.rs create mode 100644 src/payload/player.rs create mode 100644 src/payload/quest.rs create mode 100644 src/payload/raw.rs create mode 100644 src/payload/se_hyphen.rs create mode 100644 src/payload/status.rs create mode 100644 src/payload/text.rs create mode 100644 src/payload/ui_foreground.rs create mode 100644 src/payload/ui_glow.rs create mode 100644 src/serde.rs create mode 100644 src/test/auto_translate.rs create mode 100644 src/test/dalamud_link.rs create mode 100644 src/test/emphasis.rs create mode 100644 src/test/icon.rs create mode 100644 src/test/item.rs create mode 100644 src/test/map_link.rs create mode 100644 src/test/mod.rs create mode 100644 src/test/new_line.rs create mode 100644 src/test/player.rs create mode 100644 src/test/quest.rs create mode 100644 src/test/raw.rs create mode 100644 src/test/se_hyphen.rs create mode 100644 src/test/status.rs create mode 100644 src/test/text.rs create mode 100644 src/test/ui_foreground.rs create mode 100644 src/test/ui_glow.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b9c6040 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sestring" +version = "0.1.0" +edition = "2018" +authors = ["Anna Clemens "] +description = "SeString parser/encoder for FFXIV-related purposes." +license = "EUPL-1.2" +categories = ["ffxiv"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +byteorder = "1" +thiserror = "1" +serde = { version = "1", optional = true, features = ["derive"] } + +[dev-dependencies] +lazy_static = "1" +paste = "1" diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..397c84a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; +use std::backtrace::Backtrace; + +#[derive(Debug, Error)] +pub enum Error { + #[error("io error")] + Io { + #[from] + error: std::io::Error, + backtrace: Backtrace, + }, + #[error("string was not valid utf-8")] + Utf8 { + #[from] + error: std::string::FromUtf8Error, + backtrace: Backtrace, + }, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b86d536 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,43 @@ +#![feature(backtrace)] + +pub mod error; +pub mod payload; + +#[cfg(feature = "serde")] +pub mod serde; + +#[cfg(test)] +mod test; + +pub use self::{ + error::Error, + payload::Payload, +}; + +#[derive(Debug, PartialEq)] +pub struct SeString(Vec); + +impl SeString { + pub fn parse>(bytes: B) -> Result { + let payloads = Payload::parse(bytes)?; + Ok(Self(payloads)) + } + + pub fn encode(&self) -> Vec { + self.0 + .iter() + .flat_map(|p| p.encode()) + .collect() + } + + pub fn text(&self) -> String { + self.0.iter() + .flat_map(|payload| { + match payload { + Payload::Text(t) => Some(&*t.0), + _ => None, + } + }) + .collect() + } +} diff --git a/src/payload.rs b/src/payload.rs new file mode 100644 index 0000000..362df12 --- /dev/null +++ b/src/payload.rs @@ -0,0 +1,300 @@ +pub mod auto_translate; +pub mod dalamud_link; +pub mod emphasis; +pub mod icon; +pub mod item; +pub mod map_link; +pub mod new_line; +pub mod player; +pub mod quest; +pub mod raw; +pub mod se_hyphen; +pub mod status; +pub mod text; +pub mod ui_foreground; +pub mod ui_glow; + +pub use self::{ + auto_translate::AutoTranslatePayload, + dalamud_link::DalamudLinkPayload, + emphasis::EmphasisPayload, + icon::IconPayload, + item::ItemPayload, + map_link::MapLinkPayload, + new_line::NewLinePayload, + player::PlayerPayload, + quest::QuestPayload, + raw::RawPayload, + se_hyphen::SeHyphenPayload, + status::StatusPayload, + text::TextPayload, + ui_foreground::UiForegroundPayload, + ui_glow::UiGlowPayload, +}; +use crate::Error; +use std::io::{Read, Cursor, Seek, SeekFrom}; + +#[derive(Debug, PartialEq)] +pub enum Payload { + AutoTranslate(AutoTranslatePayload), + DalamudLink(DalamudLinkPayload), + Emphasis(EmphasisPayload), + Icon(IconPayload), + Item(ItemPayload), + MapLink(MapLinkPayload), + NewLine(NewLinePayload), + Player(PlayerPayload), + Quest(QuestPayload), + Raw(RawPayload), + SeHyphen(SeHyphenPayload), + Status(StatusPayload), + Text(TextPayload), + UiForeground(UiForegroundPayload), + UiGlow(UiGlowPayload), +} + +impl Payload { + const START_BYTE: u8 = 0x02; + const END_BYTE: u8 = 0x03; + + pub fn parse>(bytes: B) -> Result, Error> { + let bytes = bytes.as_ref(); + let num_bytes = bytes.len(); + let mut cursor = Cursor::new(bytes); + let mut payloads = Vec::with_capacity(1); + + while (cursor.position() as usize) < num_bytes { + payloads.push(Self::read_one(&mut cursor)?); + } + + Ok(payloads) + } + + pub fn read_one(mut reader: R) -> Result { + use byteorder::ReadBytesExt; + + let initial = reader.read_u8().map_err(Error::from)?; + reader.seek(SeekFrom::Current(-1)).map_err(Error::from)?; + + if initial != Self::START_BYTE { + // chunk len doesn't matter here + TextPayload::decode(&mut reader, 0).map(Self::Text) + } else { + // seek back forward + reader.seek(SeekFrom::Current(1)).map_err(Error::from)?; + + let post_start = reader.stream_position().map_err(Error::from)?; + let chunk_type = reader.read_u8().map_err(Error::from)?; + let chunk_len = TextPayload::read_integer(&mut reader)? as usize; + + let start = reader.stream_position().map_err(Error::from)?; + + let chunk = match SeStringChunkKind::from_u8(chunk_type) { + SeStringChunkKind::Emphasis => EmphasisPayload::decode(&mut reader, chunk_len).map(Self::Emphasis)?, + SeStringChunkKind::NewLine => Self::NewLine(NewLinePayload), + SeStringChunkKind::SeHyphen => Self::SeHyphen(SeHyphenPayload), + SeStringChunkKind::Interactable => { + let sub_type = reader.read_u8().map_err(Error::from)?; + match SeInteractableKind::from_u8(sub_type) { + SeInteractableKind::PlayerName => PlayerPayload::decode(&mut reader, chunk_len).map(Self::Player)?, + SeInteractableKind::ItemLink => ItemPayload::decode(&mut reader, chunk_len).map(Self::Item)?, + SeInteractableKind::MapPositionLink => MapLinkPayload::decode(&mut reader, chunk_len).map(Self::MapLink)?, + SeInteractableKind::QuestLink => QuestPayload::decode(&mut reader, chunk_len).map(Self::Quest)?, + SeInteractableKind::Status => StatusPayload::decode(&mut reader, chunk_len).map(Self::Status)?, + SeInteractableKind::DalamudLink => DalamudLinkPayload::decode(&mut reader, chunk_len).map(Self::DalamudLink)?, + SeInteractableKind::LinkTerminator | SeInteractableKind::Unknown(_) => { + reader.seek(SeekFrom::Start(post_start)).map_err(Error::from)?; + let additional = start - post_start; + RawPayload::decode(&mut reader, chunk_len + additional as usize).map(Self::Raw)? + } + } + } + SeStringChunkKind::AutoTranslate => AutoTranslatePayload::decode(&mut reader, chunk_len).map(Self::AutoTranslate)?, + SeStringChunkKind::UiForeground => UiForegroundPayload::decode(&mut reader, chunk_len).map(Self::UiForeground)?, + SeStringChunkKind::UiGlow => UiGlowPayload::decode(&mut reader, chunk_len).map(Self::UiGlow)?, + SeStringChunkKind::Icon => IconPayload::decode(&mut reader, chunk_len).map(Self::Icon)?, + SeStringChunkKind::Unknown(_) => { + reader.seek(SeekFrom::Start(post_start)).map_err(Error::from)?; + let additional = start - post_start; + RawPayload::decode(&mut reader, chunk_len + additional as usize).map(Self::Raw)? + }, + }; + + // skip whatever's left + let total_read = reader.stream_position().map_err(Error::from)? - start; + // +1 for END_BYTE + reader.seek(SeekFrom::Current((chunk_len as u64 - total_read + 1) as i64)).map_err(Error::from)?; + + Ok(chunk) + } + } + + pub fn encode(&self) -> Vec { + match self { + Payload::AutoTranslate(p) => p.encode(), + Payload::DalamudLink(p) => p.encode(), + Payload::Emphasis(p) => p.encode(), + Payload::Icon(p) => p.encode(), + Payload::Item(p) => p.encode(), + Payload::MapLink(p) => p.encode(), + Payload::NewLine(p) => p.encode(), + Payload::Player(p) => p.encode(), + Payload::Quest(p) => p.encode(), + Payload::Raw(p) => p.encode(), + Payload::SeHyphen(p) => p.encode(), + Payload::Status(p) => p.encode(), + Payload::Text(p) => p.encode(), + Payload::UiForeground(p) => p.encode(), + Payload::UiGlow(p) => p.encode(), + } + } +} + +trait Encode { + fn encode(&self) -> Vec; + + fn make_integer(int: u32) -> Vec { + use byteorder::{ByteOrder, LittleEndian}; + + let mut vec = Vec::with_capacity(4); + + if int < 0xCF { + vec.push(int as u8 + 1); + return vec; + } + + let mut bytes = [0; 4]; + LittleEndian::write_u32(&mut bytes, int); + + vec.push(0xF0); + + for i in (0..4).rev() { + if bytes[i] != 0 { + vec.push(bytes[i]); + vec[0] |= 1 << i; + } + } + + vec[0] -= 1; + + vec + } + + fn make_packed_integers(high: u32, low: u32) -> Vec { + let value = (high << 16) | (low & 0xFF); + Self::make_integer(value) + } +} + +trait Decode { + fn decode(reader: R, chunk_len: usize) -> Result + where Self: Sized; + + fn read_integer(mut reader: R) -> Result { + use byteorder::{ByteOrder, LittleEndian, ReadBytesExt}; + + let mut marker = reader.read_u8().map_err(Error::from)?; + if marker < 0xD0 { + return Ok((marker - 1) as u32); + } + + marker = (marker + 1) & 0b1111; + + let mut ret = [0; 4]; + for i in (0..4).rev() { + ret[i] = if (marker & (1 << i)) == 0 { + 0 + } else { + reader.read_u8().map_err(Error::from)? + }; + } + + Ok(LittleEndian::read_u32(&ret)) + } + + fn read_packed_integers(reader: R) -> Result<(u32, u32), crate::Error> { + let value = Self::read_integer(reader)?; + Ok((value >> 16, value & 0xFFFF)) + } +} + +pub(crate) enum SeStringChunkKind { + Icon, + Emphasis, + NewLine, + SeHyphen, + Interactable, + AutoTranslate, + UiForeground, + UiGlow, + Unknown(u8), +} + +impl SeStringChunkKind { + fn from_u8(byte: u8) -> Self { + match byte { + 0x12 => Self::Icon, + 0x1A => Self::Emphasis, + 0x10 => Self::NewLine, + 0x1F => Self::SeHyphen, + 0x27 => Self::Interactable, + 0x2E => Self::AutoTranslate, + 0x48 => Self::UiForeground, + 0x49 => Self::UiGlow, + x => Self::Unknown(x), + } + } + + pub(crate) fn as_u8(&self) -> u8 { + match self { + Self::Icon => 0x12, + Self::Emphasis => 0x1A, + Self::NewLine => 0x10, + Self::SeHyphen => 0x1F, + Self::Interactable => 0x27, + Self::AutoTranslate => 0x2E, + Self::UiForeground => 0x48, + Self::UiGlow => 0x49, + Self::Unknown(x) => *x, + } + } +} + +enum SeInteractableKind { + PlayerName, + ItemLink, + MapPositionLink, + QuestLink, + Status, + DalamudLink, + LinkTerminator, + Unknown(u8), +} + +impl SeInteractableKind { + fn from_u8(byte: u8) -> Self { + match byte { + 0x01 => Self::PlayerName, + 0x03 => Self::ItemLink, + 0x04 => Self::MapPositionLink, + 0x05 => Self::QuestLink, + 0x09 => Self::Status, + 0x0F => Self::DalamudLink, + 0xCF => Self::LinkTerminator, + x => Self::Unknown(x), + } + } + + fn as_u8(&self) -> u8 { + match self { + Self::PlayerName => 0x01, + Self::ItemLink => 0x03, + Self::MapPositionLink => 0x04, + Self::QuestLink => 0x05, + Self::Status => 0x09, + Self::DalamudLink => 0x0F, + Self::LinkTerminator => 0xCF, + Self::Unknown(x) => *x, + } + } +} diff --git a/src/payload/auto_translate.rs b/src/payload/auto_translate.rs new file mode 100644 index 0000000..afcc1ca --- /dev/null +++ b/src/payload/auto_translate.rs @@ -0,0 +1,39 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::{ + io::{Read, Seek}, + iter::once, +}; +use byteorder::ReadBytesExt; +use crate::payload::SeStringChunkKind; + +#[derive(Debug, PartialEq)] +pub struct AutoTranslatePayload { + pub group: u8, + pub key: u32, +} + +impl Decode for AutoTranslatePayload { + fn decode(mut reader: R, _chunk_len: usize) -> Result { + let group = reader.read_u8().map_err(Error::from)?; + let key = Self::read_integer(reader)?; + Ok(Self { + group, + key, + }) + } +} + +impl Encode for AutoTranslatePayload { + fn encode(&self) -> Vec { + let key = Self::make_integer(self.key); + let chunk_len = key.len() + 2; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::AutoTranslate.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(self.group)) + .chain(key.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/dalamud_link.rs b/src/payload/dalamud_link.rs new file mode 100644 index 0000000..664d6cb --- /dev/null +++ b/src/payload/dalamud_link.rs @@ -0,0 +1,48 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::io::{Read, Seek}; +use byteorder::ReadBytesExt; +use crate::payload::{SeStringChunkKind, SeInteractableKind}; + +#[derive(Debug, PartialEq)] +pub struct DalamudLinkPayload { + pub plugin: String, + pub command: u32, +} + +impl Decode for DalamudLinkPayload { + fn decode(mut reader: R, _chunk_len: usize) -> Result { + let plugin_len = reader.read_u8().map_err(Error::from)?; + let mut plugin_bytes = vec![0; plugin_len as usize]; + reader.read_exact(&mut plugin_bytes).map_err(Error::from)?; + let plugin = String::from_utf8(plugin_bytes).map_err(Error::from)?; + + let command = Self::read_integer(reader)?; + + Ok(Self { + plugin, + command, + }) + } +} + +impl Encode for DalamudLinkPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let plugin = self.plugin.as_bytes(); + let command = Self::make_integer(self.command); + let chunk_len = 3 + plugin.len() + command.len(); + + // FIXME: check if chunk_len > 255 + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::DalamudLink.as_u8())) + .chain(once(plugin.len() as u8)) + .chain(plugin.iter().copied()) + .chain(command.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/emphasis.rs b/src/payload/emphasis.rs new file mode 100644 index 0000000..01de488 --- /dev/null +++ b/src/payload/emphasis.rs @@ -0,0 +1,49 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct EmphasisPayload(pub bool); + +impl EmphasisPayload { + pub fn enable() -> Self { + Self(true) + } + + pub fn disable() -> Self { + Self(false) + } + + pub fn enabled(&self) -> bool { + self.0 + } +} + +impl From for EmphasisPayload { + fn from(enabled: bool) -> Self { + Self(enabled) + } +} + +impl Decode for EmphasisPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let enabled = Self::read_integer(reader)?; + Ok(Self(enabled == 1)) + } +} + +impl Encode for EmphasisPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let enabled = Self::make_integer(if self.0 { 1 } else { 0 }); + let chunk_len = enabled.len() + 1; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Emphasis.as_u8())) + .chain(once(chunk_len as u8)) + .chain(enabled.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/icon.rs b/src/payload/icon.rs new file mode 100644 index 0000000..8fd9705 --- /dev/null +++ b/src/payload/icon.rs @@ -0,0 +1,35 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct IconPayload(pub u32); + +impl From for IconPayload { + fn from(icon: u32) -> Self { + Self(icon) + } +} + +impl Decode for IconPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let icon = Self::read_integer(reader)?; + Ok(Self(icon)) + } +} + +impl Encode for IconPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let index = Self::make_integer(self.0); + let chunk_len = index.len() + 1; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Icon.as_u8())) + .chain(once(chunk_len as u8)) + .chain(index.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/item.rs b/src/payload/item.rs new file mode 100644 index 0000000..7850a15 --- /dev/null +++ b/src/payload/item.rs @@ -0,0 +1,76 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::io::{Read, Seek, SeekFrom}; +use crate::payload::{SeStringChunkKind, SeInteractableKind}; + +#[derive(Debug, PartialEq)] +pub struct ItemPayload { + pub id: u32, + pub hq: bool, + pub name: Option, +} + +impl ItemPayload { + const HQ_THRESHOLD: u32 = 1_000_000; +} + +impl Decode for ItemPayload { + fn decode(mut reader: R, chunk_len: usize) -> Result { + let mut id = Self::read_integer(&mut reader)?; + + let hq = id > Self::HQ_THRESHOLD; + if hq { + id -= Self::HQ_THRESHOLD; + } + + let name = if reader.stream_position().map_err(Error::from)? + 3 < chunk_len as u64 { + reader.seek(SeekFrom::Current(3)).map_err(Error::from)?; // unk + + let name_len = Self::read_integer(&mut reader)?; + let mut name_bytes = vec![0; name_len as usize]; + reader.read_exact(&mut name_bytes).map_err(Error::from)?; + + Some(String::from_utf8(name_bytes).map_err(Error::from)?) + } else { + None + }; + + Ok(Self { + id, + hq, + name, + }) + } +} + +impl Encode for ItemPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let actual_id = if self.hq { self.id + Self::HQ_THRESHOLD } else { self.id }; + let id = Self::make_integer(actual_id); + let name_bytes = self.name.as_ref().map(|x| x.as_bytes()); + let mut chunk_len = 4 + id.len(); + if let Some(name_bytes) = &name_bytes { + chunk_len += 1 + 1 + name_bytes.len(); + } + + let name_chain: Vec = match name_bytes { + Some(bs) => once(0xFF) + .chain(once((bs.len() + 1) as u8)) + .chain(bs.iter().copied()) + .collect(), + None => Vec::new(), + }; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::ItemLink.as_u8())) + .chain(id.into_iter()) + .chain(once(0x02)) + .chain(once(0x01)) + .chain(name_chain.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/map_link.rs b/src/payload/map_link.rs new file mode 100644 index 0000000..2f718f9 --- /dev/null +++ b/src/payload/map_link.rs @@ -0,0 +1,51 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::io::{Read, Seek, SeekFrom}; +use crate::payload::{SeStringChunkKind, SeInteractableKind}; + +#[derive(Debug, PartialEq)] +pub struct MapLinkPayload { + pub territory_type: u32, + pub map: u32, + pub raw_x: i32, + pub raw_y: i32, +} + +impl Decode for MapLinkPayload { + fn decode(mut reader: R, _chunk_len: usize) -> Result { + let (territory_type, map) = Self::read_packed_integers(&mut reader)?; + let raw_x = Self::read_integer(&mut reader)? as i32; + let raw_y = Self::read_integer(&mut reader)? as i32; + + reader.seek(SeekFrom::Current(2)).map_err(Error::from)?; + + Ok(Self { + territory_type, + map, + raw_x, + raw_y, + }) + } +} + +impl Encode for MapLinkPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let territory_map_packed = Self::make_packed_integers(self.territory_type, self.map); + let x = Self::make_integer(self.raw_x as u32); + let y = Self::make_integer(self.raw_y as u32); + let chunk_len = 4 + territory_map_packed.len() + x.len() + y.len(); + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::MapPositionLink.as_u8())) + .chain(territory_map_packed.into_iter()) + .chain(x.into_iter()) + .chain(y.into_iter()) + .chain(once(0xFF)) + .chain(once(0x01)) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/new_line.rs b/src/payload/new_line.rs new file mode 100644 index 0000000..949ee0a --- /dev/null +++ b/src/payload/new_line.rs @@ -0,0 +1,16 @@ +use crate::payload::{Encode, SeStringChunkKind}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct NewLinePayload; + +impl Encode for NewLinePayload { + fn encode(&self) -> Vec { + vec![ + Payload::START_BYTE, + SeStringChunkKind::NewLine.as_u8(), + 0x01, + Payload::END_BYTE, + ] + } +} diff --git a/src/payload/player.rs b/src/payload/player.rs new file mode 100644 index 0000000..f9056d6 --- /dev/null +++ b/src/payload/player.rs @@ -0,0 +1,53 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::io::{Read, Seek, SeekFrom}; +use crate::payload::{SeStringChunkKind, SeInteractableKind}; + +#[derive(Debug, PartialEq)] +pub struct PlayerPayload { + pub server_id: u32, + pub name: String, +} + +impl Decode for PlayerPayload { + fn decode(mut reader: R, _chunk_len: usize) -> Result { + reader.seek(SeekFrom::Current(1)).map_err(Error::from)?; // unk + + let server_id = Self::read_integer(&mut reader)?; + + reader.seek(SeekFrom::Current(2)).map_err(Error::from)?; // unk + + let name_len = Self::read_integer(&mut reader)?; + let mut name_bytes = vec![0; name_len as usize]; + reader.read_exact(&mut name_bytes).map_err(Error::from)?; + let name = String::from_utf8(name_bytes).map_err(Error::from)?; + + Ok(Self { + server_id, + name, + }) + } +} + +impl Encode for PlayerPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let name = self.name.as_bytes(); + let name_len = Self::make_integer(name.len() as u32); + let server = Self::make_integer(self.server_id); + let chunk_len = name.len() + server.len() + name_len.len() + 5; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::PlayerName.as_u8())) + .chain(once(0x01)) + .chain(server.into_iter()) + .chain(once(0x01)) + .chain(once(0xFF)) + .chain(name_len.into_iter()) + .chain(name.iter().copied()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/quest.rs b/src/payload/quest.rs new file mode 100644 index 0000000..9195271 --- /dev/null +++ b/src/payload/quest.rs @@ -0,0 +1,42 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind, SeInteractableKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct QuestPayload(pub u32); + +impl QuestPayload { + const OFFSET: u32 = 65536; +} + +impl From for QuestPayload { + fn from(id: u32) -> Self { + Self(id) + } +} + +impl Decode for QuestPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let id = Self::read_integer(reader)? + Self::OFFSET; + Ok(Self(id)) + } +} + +impl Encode for QuestPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let id = Self::make_integer(self.0 - Self::OFFSET); + let chunk_len = id.len() + 4; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::QuestLink.as_u8())) + .chain(id.into_iter()) + .chain(once(0x01)) + .chain(once(0x01)) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/raw.rs b/src/payload/raw.rs new file mode 100644 index 0000000..ecbcfae --- /dev/null +++ b/src/payload/raw.rs @@ -0,0 +1,31 @@ +use crate::{Error, payload::{Decode, Encode}, Payload}; +use std::io::{Read, Seek}; + +#[derive(Debug, PartialEq)] +pub struct RawPayload(pub Vec); + +impl AsRef<[u8]> for RawPayload { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl Decode for RawPayload { + fn decode(mut reader: R, chunk_len: usize) -> Result { + let mut data = vec![0; chunk_len]; + reader.read_exact(&mut data).map_err(Error::from)?; + + Ok(Self(data)) + } +} + +impl Encode for RawPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + once(Payload::START_BYTE) + .chain(self.0.iter().copied()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/se_hyphen.rs b/src/payload/se_hyphen.rs new file mode 100644 index 0000000..2b93956 --- /dev/null +++ b/src/payload/se_hyphen.rs @@ -0,0 +1,16 @@ +use crate::payload::{Encode, SeStringChunkKind}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct SeHyphenPayload; + +impl Encode for SeHyphenPayload { + fn encode(&self) -> Vec { + vec![ + Payload::START_BYTE, + SeStringChunkKind::SeHyphen.as_u8(), + 0x01, + Payload::END_BYTE, + ] + } +} diff --git a/src/payload/status.rs b/src/payload/status.rs new file mode 100644 index 0000000..411bbbb --- /dev/null +++ b/src/payload/status.rs @@ -0,0 +1,41 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind, SeInteractableKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct StatusPayload(pub u32); + +impl From for StatusPayload { + fn from(id: u32) -> Self { + Self(id) + } +} + +impl Decode for StatusPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let id = Self::read_integer(reader)?; + Ok(Self(id)) + } +} + +impl Encode for StatusPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let id = Self::make_integer(self.0); + let chunk_len = id.len() + 7; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::Interactable.as_u8())) + .chain(once(chunk_len as u8)) + .chain(once(SeInteractableKind::Status.as_u8())) + .chain(id.into_iter()) + .chain(once(0x01)) + .chain(once(0x01)) + .chain(once(0xFF)) + .chain(once(0x02)) + .chain(once(0x20)) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/text.rs b/src/payload/text.rs new file mode 100644 index 0000000..5a8809b --- /dev/null +++ b/src/payload/text.rs @@ -0,0 +1,72 @@ +use crate::{ + Error, + payload::{Decode, Encode}, +}; +use std::io::{Read, Seek, SeekFrom}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct TextPayload(pub String); + +impl> From for TextPayload { + fn from(text: T) -> Self { + Self(text.into()) + } +} + +impl AsRef for TextPayload { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Decode for TextPayload { + fn decode(mut reader: R, _chunk_len: usize) -> Result { + let mut text = Vec::with_capacity(32); + + let mut buf = [0; 32]; + let mut read: usize; + loop { + read = reader.read(&mut buf).map_err(Error::from)?; + // end of stream + if read == 0 { + break; + } + + let start = buf[..read].iter().position(|&b| b == Payload::START_BYTE); + if let Some(start) = start { + // 5 4 3 5 4 5 = 6 bytes read + // ^ - start byte at idx 2 + // seek backwards until start byte is next to read: read - idx = 4 bytes + let offset = (read - start) as i64; + reader.seek(SeekFrom::Current(-offset)).map_err(Error::from)?; + + // tell the rest of the loop that we only read up to the start byte + read = start as usize; + } + + for &byte in &buf[..read] { + text.push(byte); + } + + // we encountered a real payload, so break now + if start.is_some() { + break; + } + } + + let text = if text.len() > 0 { + String::from_utf8(text).map_err(Error::from)? + } else { + String::new() + }; + + Ok(Self(text)) + } +} + +impl Encode for TextPayload { + fn encode(&self) -> Vec { + self.0.as_bytes().to_vec() + } +} diff --git a/src/payload/ui_foreground.rs b/src/payload/ui_foreground.rs new file mode 100644 index 0000000..f5af57d --- /dev/null +++ b/src/payload/ui_foreground.rs @@ -0,0 +1,35 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct UiForegroundPayload(pub u32); + +impl From for UiForegroundPayload { + fn from(colour: u32) -> Self { + Self(colour) + } +} + +impl Decode for UiForegroundPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let colour = Self::read_integer(reader)?; + Ok(Self(colour)) + } +} + +impl Encode for UiForegroundPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let colour = Self::make_integer(self.0); + let chunk_len = colour.len() + 1; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::UiForeground.as_u8())) + .chain(once(chunk_len as u8)) + .chain(colour.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/payload/ui_glow.rs b/src/payload/ui_glow.rs new file mode 100644 index 0000000..f7291aa --- /dev/null +++ b/src/payload/ui_glow.rs @@ -0,0 +1,35 @@ +use crate::payload::{Decode, Encode, SeStringChunkKind}; +use std::io::{Read, Seek}; +use crate::Payload; + +#[derive(Debug, PartialEq)] +pub struct UiGlowPayload(pub u32); + +impl From for UiGlowPayload { + fn from(colour: u32) -> Self { + Self(colour) + } +} + +impl Decode for UiGlowPayload { + fn decode(reader: R, _chunk_len: usize) -> Result { + let colour = Self::read_integer(reader)?; + Ok(Self(colour)) + } +} + +impl Encode for UiGlowPayload { + fn encode(&self) -> Vec { + use std::iter::once; + + let colour = Self::make_integer(self.0); + let chunk_len = colour.len() + 1; + + once(Payload::START_BYTE) + .chain(once(SeStringChunkKind::UiGlow.as_u8())) + .chain(once(chunk_len as u8)) + .chain(colour.into_iter()) + .chain(once(Payload::END_BYTE)) + .collect() + } +} diff --git a/src/serde.rs b/src/serde.rs new file mode 100644 index 0000000..31c33d9 --- /dev/null +++ b/src/serde.rs @@ -0,0 +1,19 @@ +use serde::{Serialize, Serializer, Deserialize, Deserializer}; +use crate::SeString; + +impl Serialize for SeString { + fn serialize(&self, serializer: S) -> Result + where S: Serializer + { + self.encode().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SeString { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + let bytes = Vec::::deserialize(deserializer)?; + SeString::parse(&bytes).map_err(|e| serde::de::Error::custom(format!("invalid sestring: {:?}", e))) + } +} diff --git a/src/test/auto_translate.rs b/src/test/auto_translate.rs new file mode 100644 index 0000000..cd6318d --- /dev/null +++ b/src/test/auto_translate.rs @@ -0,0 +1,14 @@ +use crate::{Payload, SeString}; +use crate::payload::AutoTranslatePayload; + +super::basic_tests! { + auto_translate { + payload = &[2, 46, 5, 5, 242, 2, 12, 3]; + expected = SeString(vec![ + Payload::AutoTranslate(AutoTranslatePayload { + group: 5, + key: 524, + }), + ]); + } +} diff --git a/src/test/dalamud_link.rs b/src/test/dalamud_link.rs new file mode 100644 index 0000000..cde09fd --- /dev/null +++ b/src/test/dalamud_link.rs @@ -0,0 +1,16 @@ +use crate::{Payload, SeString}; +use crate::payload::{DalamudLinkPayload, RawPayload, TextPayload}; + +super::basic_tests! { + dalamud_link { + payload = &[2, 39, 22, 15, 18, 83, 105, 109, 112, 108, 101, 84, 119, 101, 97, 107, 115, 80, 108, 117, 103, 105, 110, 2, 3, 104, 116, 116, 112, 115, 58, 47, 47, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109, 47, 2, 39, 7, 207, 1, 1, 1, 255, 1, 3]; + expected = SeString(vec![ + Payload::DalamudLink(DalamudLinkPayload { + plugin: "SimpleTweaksPlugin".to_string(), + command: 1, + }), + Payload::Text(TextPayload::from("https://example.com/")), + Payload::Raw(RawPayload(vec![39, 7, 207, 1, 1, 1, 255, 1])), + ]); + } +} diff --git a/src/test/emphasis.rs b/src/test/emphasis.rs new file mode 100644 index 0000000..e6f7567 --- /dev/null +++ b/src/test/emphasis.rs @@ -0,0 +1,15 @@ +use crate::{Payload, SeString}; +use crate::payload::{EmphasisPayload, TextPayload}; + +super::basic_tests! { + emphasis { + payload = &[78, 111, 119, 32, 112, 108, 97, 121, 105, 110, 103, 32, 2, 26, 2, 2, 3, 87, 97, 105, 108, 101, 114, 115, 32, 97, 110, 100, 32, 87, 97, 116, 101, 114, 119, 104, 101, 101, 108, 115, 2, 26, 2, 1, 3, 46]; + expected = SeString(vec![ + Payload::Text(TextPayload::from("Now playing ")), + Payload::Emphasis(EmphasisPayload::enable()), + Payload::Text(TextPayload::from("Wailers and Waterwheels")), + Payload::Emphasis(EmphasisPayload::disable()), + Payload::Text(TextPayload::from(".")), + ]); + } +} diff --git a/src/test/icon.rs b/src/test/icon.rs new file mode 100644 index 0000000..eda2518 --- /dev/null +++ b/src/test/icon.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::IconPayload; + +super::basic_tests! { + icon { + payload = &[2, 18, 2, 67, 3]; + expected = SeString(vec![ + Payload::Icon(IconPayload::from(66)), + ]); + } +} diff --git a/src/test/item.rs b/src/test/item.rs new file mode 100644 index 0000000..e08e23d --- /dev/null +++ b/src/test/item.rs @@ -0,0 +1,15 @@ +use crate::{Payload, SeString}; +use crate::payload::ItemPayload; + +super::basic_tests! { + item { + payload = &[2, 39, 7, 3, 242, 19, 9, 2, 1, 3]; + expected = SeString(vec![ + Payload::Item(ItemPayload { + id: 4873, + hq: false, + name: None, + }), + ]); + } +} diff --git a/src/test/map_link.rs b/src/test/map_link.rs new file mode 100644 index 0000000..4119f0f --- /dev/null +++ b/src/test/map_link.rs @@ -0,0 +1,18 @@ +use crate::{Payload, SeString}; +use crate::payload::MapLinkPayload; + +super::basic_tests! { + map { + payload = &[2, 39, 15, 4, 244, 132, 2, 246, 1, 16, 42, 246, 1, 130, 161, 255, 1, 3]; + expected = SeString(vec!{ + Payload::MapLink(MapLinkPayload { + territory_type: 132, + map: 2, + raw_x: 69674, + // x: 12.644841 + raw_y: 98977, + // y: 13.231473 + }), + }); + } +} diff --git a/src/test/mod.rs b/src/test/mod.rs new file mode 100644 index 0000000..2350db5 --- /dev/null +++ b/src/test/mod.rs @@ -0,0 +1,52 @@ +mod auto_translate; +mod dalamud_link; +mod emphasis; +mod icon; +mod item; +mod map_link; +mod new_line; +mod player; +mod quest; +mod raw; +mod se_hyphen; +mod status; +mod text; +mod ui_foreground; +mod ui_glow; + +macro_rules! basic_tests { + ($prefix:ident { payload = $payload:expr; expected = $expected:expr; }) => { + const PAYLOAD: &[u8] = $payload; + lazy_static::lazy_static! { + static ref EXPECTED: crate::SeString = $expected; + } + + paste::paste! { + #[test] + fn [<$prefix _parse>]() { + assert_eq!( + crate::SeString::parse(PAYLOAD).unwrap(), + *EXPECTED, + ); + } + + #[test] + fn [<$prefix _encode>]() { + assert_eq!( + EXPECTED.encode(), + PAYLOAD, + ); + } + + #[test] + fn [<$prefix _round_trip>]() { + assert_eq!( + crate::SeString::parse(PAYLOAD).unwrap().encode(), + PAYLOAD, + ); + } + } + }; +} + +pub(crate) use basic_tests; diff --git a/src/test/new_line.rs b/src/test/new_line.rs new file mode 100644 index 0000000..b9f8438 --- /dev/null +++ b/src/test/new_line.rs @@ -0,0 +1,13 @@ +use crate::{SeString, Payload}; +use crate::payload::{TextPayload, NewLinePayload}; + +super::basic_tests! { + new_line { + payload = &[66, 101, 102, 111, 114, 101, 2, 16, 1, 3, 65, 102, 116, 101, 114]; + expected = SeString(vec![ + Payload::Text(TextPayload::from("Before")), + Payload::NewLine(NewLinePayload), + Payload::Text(TextPayload::from("After")), + ]); + } +} diff --git a/src/test/player.rs b/src/test/player.rs new file mode 100644 index 0000000..8bb2297 --- /dev/null +++ b/src/test/player.rs @@ -0,0 +1,14 @@ +use crate::{Payload, SeString}; +use crate::payload::PlayerPayload; + +super::basic_tests! { + player { + payload = &[2, 39, 23, 1, 1, 74, 1, 255, 17, 69, 108, 111, 114, 97, 32, 65, 98, 121, 115, 115, 105, 110, 105, 97, 110, 3]; + expected = SeString(vec![ + Payload::Player(PlayerPayload { + server_id: 73, + name: "Elora Abyssinian".to_string(), + }), + ]); + } +} diff --git a/src/test/quest.rs b/src/test/quest.rs new file mode 100644 index 0000000..9b2d6cf --- /dev/null +++ b/src/test/quest.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::QuestPayload; + +super::basic_tests! { + quest { + payload = &[2, 39, 7, 5, 242, 2, 12, 1, 1, 3]; + expected = SeString(vec![ + Payload::Quest(QuestPayload::from(66060)), + ]); + } +} diff --git a/src/test/raw.rs b/src/test/raw.rs new file mode 100644 index 0000000..2298ee8 --- /dev/null +++ b/src/test/raw.rs @@ -0,0 +1,11 @@ +use crate::{SeString, Payload}; +use crate::payload::RawPayload; + +super::basic_tests! { + raw { + payload = &[2, 39, 7, 207, 1, 1, 1, 255, 1, 3]; + expected = SeString(vec![ + Payload::Raw(RawPayload(vec![39, 7, 207, 1, 1, 1, 255, 1])), + ]); + } +} diff --git a/src/test/se_hyphen.rs b/src/test/se_hyphen.rs new file mode 100644 index 0000000..24f678b --- /dev/null +++ b/src/test/se_hyphen.rs @@ -0,0 +1,13 @@ +use crate::{SeString, Payload}; +use crate::payload::{TextPayload, SeHyphenPayload}; + +super::basic_tests! { + se_hyphen { + payload = &[66, 101, 102, 111, 114, 101, 2, 31, 1, 3, 65, 102, 116, 101, 114]; + expected = SeString(vec![ + Payload::Text(TextPayload::from("Before")), + Payload::SeHyphen(SeHyphenPayload), + Payload::Text(TextPayload::from("After")), + ]); + } +} diff --git a/src/test/status.rs b/src/test/status.rs new file mode 100644 index 0000000..e3df344 --- /dev/null +++ b/src/test/status.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::StatusPayload; + +super::basic_tests! { + status { + payload = &[2, 39, 10, 9, 242, 1, 41, 1, 1, 255, 2, 32, 3]; + expected = SeString(vec!{ + Payload::Status(StatusPayload::from(297)), + }); + } +} diff --git a/src/test/text.rs b/src/test/text.rs new file mode 100644 index 0000000..e8f4ac5 --- /dev/null +++ b/src/test/text.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::TextPayload; + +super::basic_tests! { + text { + payload = b"Hello, world!"; + expected = SeString(vec!{ + Payload::Text(TextPayload::from("Hello, world!")), + }); + } +} diff --git a/src/test/ui_foreground.rs b/src/test/ui_foreground.rs new file mode 100644 index 0000000..3c12cb1 --- /dev/null +++ b/src/test/ui_foreground.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::UiForegroundPayload; + +super::basic_tests! { + ui_foreground { + payload = &[2, 72, 4, 242, 1, 244, 3]; + expected = SeString(vec!{ + Payload::UiForeground(UiForegroundPayload::from(500)), + }); + } +} diff --git a/src/test/ui_glow.rs b/src/test/ui_glow.rs new file mode 100644 index 0000000..9651b52 --- /dev/null +++ b/src/test/ui_glow.rs @@ -0,0 +1,11 @@ +use crate::{Payload, SeString}; +use crate::payload::UiGlowPayload; + +super::basic_tests! { + ui_glow { + payload = &[2, 73, 4, 242, 1, 245, 3]; + expected = SeString(vec!{ + Payload::UiGlow(UiGlowPayload::from(501)), + }); + } +}