diff --git a/rust/springylib/src/lib.rs b/rust/springylib/src/lib.rs
index 4193e3e..4637171 100644
--- a/rust/springylib/src/lib.rs
+++ b/rust/springylib/src/lib.rs
@@ -1 +1,2 @@
pub mod archive;
+pub mod media;
diff --git a/rust/springylib/src/media/mod.rs b/rust/springylib/src/media/mod.rs
new file mode 100644
index 0000000..6bae95d
--- /dev/null
+++ b/rust/springylib/src/media/mod.rs
@@ -0,0 +1 @@
+pub mod ui;
diff --git a/rust/springylib/src/media/ui/image.rs b/rust/springylib/src/media/ui/image.rs
new file mode 100644
index 0000000..0fb4b4b
--- /dev/null
+++ b/rust/springylib/src/media/ui/image.rs
@@ -0,0 +1,30 @@
+use crate::media::ui::vec::deserialize_vec2;
+use crate::media::ui::FadeMode;
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiImage {
+ pub texture: String,
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub position: [i32; 2],
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub size: [i32; 2],
+ #[serde(rename = "fademode", default)]
+ pub fade_mode: FadeMode,
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::media::ui::image::UiImage;
+
+ // language=xml
+ const IMAGE: &str = "";
+
+ #[test]
+ fn it_should_read() {
+ let image: UiImage = serde_xml_rs::from_str(IMAGE).unwrap();
+ assert_eq!(image.texture, "tex".to_string());
+ assert_eq!(image.position, [1, 2]);
+ assert_eq!(image.size, [3, 4]);
+ }
+}
diff --git a/rust/springylib/src/media/ui/menu.rs b/rust/springylib/src/media/ui/menu.rs
new file mode 100644
index 0000000..3fb51eb
--- /dev/null
+++ b/rust/springylib/src/media/ui/menu.rs
@@ -0,0 +1,11 @@
+use serde::Deserialize;
+use crate::media::ui::UiTag;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiMenu {
+ pub selected: String,
+ #[serde(rename = "OnBack")]
+ pub on_back: Option,
+ #[serde(rename = "$value", default)]
+ pub children: Vec,
+}
diff --git a/rust/springylib/src/media/ui/mod.rs b/rust/springylib/src/media/ui/mod.rs
new file mode 100644
index 0000000..6ce9eb3
--- /dev/null
+++ b/rust/springylib/src/media/ui/mod.rs
@@ -0,0 +1,88 @@
+use crate::media::ui::image::UiImage;
+use crate::media::ui::menu::UiMenu;
+use crate::media::ui::static_text::UiStaticText;
+use crate::media::ui::text_area::UiTextArea;
+use crate::media::ui::text_button::UiTextButton;
+use crate::media::ui::text_field::UiTextField;
+use crate::media::ui::toggle_button::UiToggleButton;
+use serde::Deserialize;
+
+pub mod image;
+pub mod menu;
+pub mod static_text;
+pub mod text_area;
+pub mod text_button;
+pub mod text_field;
+pub mod toggle_button;
+pub mod vec;
+
+#[derive(Debug, Clone, Deserialize)]
+pub enum UiTag {
+ Menu(UiMenu),
+ Image(UiImage),
+ TextButton(UiTextButton),
+ TextArea(UiTextArea),
+ TextField(UiTextField),
+ StaticText(UiStaticText),
+ ToggleButton(UiToggleButton),
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum HorizontalAlign {
+ Left,
+ Center,
+ Right,
+}
+
+impl Default for HorizontalAlign {
+ fn default() -> HorizontalAlign {
+ HorizontalAlign::Left
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FadeMode {
+ None,
+}
+
+impl Default for FadeMode {
+ fn default() -> Self {
+ FadeMode::None
+ }
+}
+
+impl UiTag {
+ pub fn post_process(&mut self) {
+ if let UiTag::Menu(menu) = self {
+ let children: Vec = menu.children.drain(..).collect();
+ let mut area_stack: Vec> = vec![vec![]];
+
+ for mut child in children {
+ child.post_process();
+ if let UiTag::TextArea(mut area) = child {
+ let children = area_stack.pop().unwrap();
+ let opening_tag = area_stack.last_mut().map(|it| it.last_mut());
+
+ if let Some(Some(UiTag::TextArea(opening_tag))) = opening_tag {
+ opening_tag.children = children;
+ } else {
+ area_stack.push(children);
+ }
+
+ if area.position.is_some() && area.size.is_some() {
+ let children = area.children.drain(..).collect();
+ area_stack.last_mut().unwrap().push(UiTag::TextArea(area));
+ area_stack.push(children);
+ }
+ } else {
+ area_stack.last_mut().unwrap().push(child);
+ }
+ }
+
+ menu.children = area_stack.pop().unwrap();
+ debug_assert!(area_stack.is_empty());
+ }
+ }
+}
diff --git a/rust/springylib/src/media/ui/static_text.rs b/rust/springylib/src/media/ui/static_text.rs
new file mode 100644
index 0000000..dfec4b3
--- /dev/null
+++ b/rust/springylib/src/media/ui/static_text.rs
@@ -0,0 +1,29 @@
+use crate::media::ui::vec::deserialize_vec2;
+use crate::media::ui::{FadeMode, HorizontalAlign};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiStaticText {
+ pub text: String,
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub position: [i32; 2],
+ #[serde(rename = "halign", default)]
+ pub horizontal_align: HorizontalAlign,
+ #[serde(rename = "fademode", default)]
+ pub fade_mode: FadeMode,
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::media::ui::static_text::UiStaticText;
+
+ // language=xml
+ const STATIC_TEXT: &str = "";
+
+ #[test]
+ fn it_should_read() {
+ let static_text: UiStaticText = serde_xml_rs::from_str(STATIC_TEXT).unwrap();
+ assert_eq!(static_text.text, "test".to_string());
+ assert_eq!(static_text.position, [1, 2]);
+ }
+}
diff --git a/rust/springylib/src/media/ui/text_area.rs b/rust/springylib/src/media/ui/text_area.rs
new file mode 100644
index 0000000..4c157bf
--- /dev/null
+++ b/rust/springylib/src/media/ui/text_area.rs
@@ -0,0 +1,15 @@
+use crate::media::ui::vec::deserialize_vec2_opt;
+use crate::media::ui::UiTag;
+use serde::Deserialize;
+
+/// This is a really weird node, sometimes it has children and sometimes, don't ask me why,
+/// it appears as a normal tag and then gets closed by an empty tag of this kind.
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiTextArea {
+ #[serde(deserialize_with = "deserialize_vec2_opt", default)]
+ pub position: Option<[i32; 2]>,
+ #[serde(deserialize_with = "deserialize_vec2_opt", default)]
+ pub size: Option<[i32; 2]>,
+ #[serde(rename = "$value", default)]
+ pub children: Vec,
+}
diff --git a/rust/springylib/src/media/ui/text_button.rs b/rust/springylib/src/media/ui/text_button.rs
new file mode 100644
index 0000000..5ca30b8
--- /dev/null
+++ b/rust/springylib/src/media/ui/text_button.rs
@@ -0,0 +1,35 @@
+use crate::media::ui::vec::deserialize_vec2;
+use crate::media::ui::{FadeMode, HorizontalAlign};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiTextButton {
+ pub name: Option,
+ pub text: String,
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub position: [i32; 2],
+ #[serde(rename = "halign", default)]
+ pub horizontal_align: HorizontalAlign,
+ #[serde(rename = "fademode", default)]
+ pub fade_mode: FadeMode,
+ #[serde(rename = "OnSelect")]
+ pub on_select: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::media::ui::text_button::UiTextButton;
+
+ // language=xml
+ const BUTTON: &str =
+ "";
+
+ #[test]
+ fn it_should_read() {
+ let button: UiTextButton = serde_xml_rs::from_str(BUTTON).unwrap();
+ assert_eq!(button.name, Some("test".to_string()));
+ assert_eq!(button.text, "abc".to_string());
+ assert_eq!(button.position, [1, 2]);
+ assert_eq!(button.on_select, "StartGame".to_string());
+ }
+}
diff --git a/rust/springylib/src/media/ui/text_field.rs b/rust/springylib/src/media/ui/text_field.rs
new file mode 100644
index 0000000..6ec1633
--- /dev/null
+++ b/rust/springylib/src/media/ui/text_field.rs
@@ -0,0 +1,40 @@
+use crate::media::ui::vec::{deserialize_vec2, deserialize_vec4};
+use crate::media::ui::{FadeMode, HorizontalAlign};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct UiTextField {
+ pub name: Option,
+ pub text: String,
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub position: [i32; 2],
+ #[serde(rename = "bufferVar")]
+ pub buffer_var: String,
+ #[serde(deserialize_with = "deserialize_vec4")]
+ pub area: [i32; 4],
+ #[serde(rename = "halign", default)]
+ pub horizontal_align: HorizontalAlign,
+ #[serde(rename = "fademode", default)]
+ pub fade_mode: FadeMode,
+ #[serde(rename = "OnSelect")]
+ pub on_select: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::media::ui::text_field::UiTextField;
+
+ // language=xml
+ const TEXT_FIELD: &str = "";
+
+ #[test]
+ fn it_should_read() {
+ let text_field: UiTextField = serde_xml_rs::from_str(TEXT_FIELD).unwrap();
+ assert_eq!(text_field.name, Some("test".to_string()));
+ assert_eq!(text_field.text, "abc".to_string());
+ assert_eq!(text_field.position, [1, 2]);
+ assert_eq!(text_field.buffer_var, "var".to_string());
+ assert_eq!(text_field.area, [1, 2, 3, 4]);
+ assert_eq!(text_field.on_select, "click".to_string());
+ }
+}
diff --git a/rust/springylib/src/media/ui/toggle_button.rs b/rust/springylib/src/media/ui/toggle_button.rs
new file mode 100644
index 0000000..26855e8
--- /dev/null
+++ b/rust/springylib/src/media/ui/toggle_button.rs
@@ -0,0 +1,72 @@
+use crate::media::ui::vec::deserialize_vec2;
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize, PartialEq)]
+pub struct UiToggleButton {
+ pub name: Option,
+ pub text: String,
+ #[serde(deserialize_with = "deserialize_vec2")]
+ pub position: [i32; 2],
+ pub value: String,
+ #[serde(rename = "minValue")]
+ pub min_value: i32,
+ #[serde(rename = "maxValue")]
+ pub max_value: i32,
+ #[serde(rename = "valueStep")]
+ pub value_step: i32,
+ pub target: String,
+ #[serde(rename = "targetLOffset", deserialize_with = "deserialize_vec2")]
+ pub target_l_offset: [i32; 2],
+ #[serde(rename = "targetROffset", deserialize_with = "deserialize_vec2")]
+ pub target_r_offset: [i32; 2],
+ #[serde(rename = "noSound", default)]
+ pub no_sound: bool,
+ #[serde(rename = "OnChange")]
+ pub on_change: String,
+ #[serde(rename = "OnSelect")]
+ pub on_select: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::media::ui::toggle_button::UiToggleButton;
+
+ // language=xml
+ const TOGGLE_BUTTON: &str = "";
+
+ #[test]
+ fn it_should_read() {
+ let toggle_button: UiToggleButton = serde_xml_rs::from_str(TOGGLE_BUTTON).unwrap();
+ assert_eq!(
+ toggle_button,
+ UiToggleButton {
+ name: Some("test".to_string()),
+ text: "abc".to_string(),
+ position: [1, 2],
+ value: "val".to_string(),
+ min_value: 0,
+ max_value: 10,
+ value_step: 1,
+ target: "target".to_string(),
+ target_l_offset: [3, 4],
+ target_r_offset: [5, 6],
+ no_sound: false,
+ on_change: "change".to_string(),
+ on_select: "select".to_string(),
+ }
+ )
+ }
+}
diff --git a/rust/springylib/src/media/ui/vec.rs b/rust/springylib/src/media/ui/vec.rs
new file mode 100644
index 0000000..0017b3d
--- /dev/null
+++ b/rust/springylib/src/media/ui/vec.rs
@@ -0,0 +1,67 @@
+use serde::{Deserialize, Deserializer};
+use serde::de::Error;
+
+pub fn deserialize_vec2_opt<'de, D>(deserializer: D) -> Result