From d4a5bb6e7b7be3925a3186f7756249afbf9c22ac Mon Sep 17 00:00:00 2001 From: Maxime Coirault Date: Fri, 6 Mar 2026 22:59:51 +0200 Subject: [PATCH] Add support for linebreak in icon.sys title + polish icon.sys viewer UI --- crates/ps2-filetypes/src/common/sjis.rs | 2 +- crates/ps2-filetypes/src/parser/icon_sys.rs | 46 +++- crates/suitcase/src/tabs/icon_sys_viewer.rs | 228 +++++++++++-------- crates/suitcase/src/tabs/title_cfg_viewer.rs | 1 + 4 files changed, 174 insertions(+), 103 deletions(-) diff --git a/crates/ps2-filetypes/src/common/sjis.rs b/crates/ps2-filetypes/src/common/sjis.rs index d4c6300..ad23781 100644 --- a/crates/ps2-filetypes/src/common/sjis.rs +++ b/crates/ps2-filetypes/src/common/sjis.rs @@ -3,7 +3,7 @@ pub fn encode_sjis(input: &str) -> Vec { .as_bytes() .iter() .flat_map(|b| match *b { - b' ' => [0x80, 0x3F], + b' ' => [0x81, 0x40], b':' => [0x81, 0x46], b'/' => [0x81, 0x5E], b'(' => [0x81, 0x69], diff --git a/crates/ps2-filetypes/src/parser/icon_sys.rs b/crates/ps2-filetypes/src/parser/icon_sys.rs index 1d0ae1b..ece8117 100644 --- a/crates/ps2-filetypes/src/parser/icon_sys.rs +++ b/crates/ps2-filetypes/src/parser/icon_sys.rs @@ -62,13 +62,14 @@ impl Vector { #[derive(Clone, Debug)] pub struct IconSys { pub flags: u16, - pub linebreak_pos: u16, + pub linebreak_pos: u8, pub background_transparency: u32, pub background_colors: [Color; 4], pub light_directions: [Vector; 3], pub light_colors: [ColorF; 3], pub ambient_color: ColorF, - pub title: String, + pub title_line1: String, + pub title_line2: String, pub icon_file: String, pub icon_copy_file: String, pub icon_delete_file: String, @@ -84,7 +85,8 @@ impl IconSys { bytes.extend_from_slice(b"PS2D"); bytes.extend(self.flags.to_le_bytes()); - bytes.extend(self.linebreak_pos.to_le_bytes()); + bytes.extend(convert_linepos(self.linebreak_pos).to_le_bytes()); + bytes.extend(0u8.to_le_bytes()); // Reserved bytes.extend(0u32.to_le_bytes()); // Reserved bytes.extend(self.background_transparency.to_le_bytes()); @@ -102,7 +104,7 @@ impl IconSys { bytes.extend_from_slice(&self.ambient_color.to_bytes()); - let title_bytes = encode_sjis(&self.title); + let title_bytes = encode_sjis(&join_title_lines(self.title_line1.clone(), self.title_line2.clone())); let title_len = title_bytes.len(); if title_len > 68 { return Err(std::io::Error::new( @@ -153,7 +155,8 @@ fn parse_icon_sys(bytes: Vec) -> Result { c.read_exact(&mut magic)?; let flags = c.read_u16::()?; - let linebreak_pos = c.read_u16::()?; + let linebreak_pos = convert_linepos(c.read_u8()?); + _ = c.read_u8(); // Reserved, always 0 _ = c.read_u32::(); // Reserved, always 0 let background_transparency = c.read_u32::()?; @@ -188,6 +191,9 @@ fn parse_icon_sys(bytes: Vec) -> Result { let mut icon_delete_file_buf = vec![0u8; 64]; c.read_exact(&mut icon_delete_file_buf)?; + let title = parse_sjis_string(&title_buf); + let (title_line1, title_line2) = split_title(linebreak_pos, title); + Ok(IconSys { flags, linebreak_pos, @@ -196,7 +202,8 @@ fn parse_icon_sys(bytes: Vec) -> Result { light_directions, light_colors, ambient_color, - title: parse_sjis_string(&title_buf), + title_line1, + title_line2, icon_file: parse_cstring(&icon_file_buf), icon_copy_file: parse_cstring(&icon_copy_file_buf), icon_delete_file: parse_cstring(&icon_delete_file_buf), @@ -235,3 +242,30 @@ fn parse_direction(c: &mut Cursor>) -> Result { Ok(Vector { x, y, z, w }) } + +/** + * The linebreak is stored at byte 0x06 in the 4 lower bits, but with the least significant bit first. + * For example a linebreak on the 5rd (0b 0000 0101) character will be represented as 0b 0000 1010. + * The 4 higher bits are seemingly ignored in the context of OSDMENU. + */ +fn convert_linepos(source: u8) -> u8 { + // zero out the 4 most significant bits + let low_half = source & 0b00001111; + low_half.reverse_bits() >> 4 +} + +fn split_title(linebreak_pos: u8, title: String) -> (String, String) { + if linebreak_pos >= title.len() as u8 { + return (title, "".to_string()); + } + let (title_line1, title_line2) = title.split_at(linebreak_pos as usize); + // trim the leading space on line2 necessary for proper linebreak + (title_line1.into(), title_line2.trim_start().into()) +} + +fn join_title_lines(title_line1: String, title_line2: String) -> String { + if title_line2.len() == 0 { + return title_line1; + } + format!("{} {}", title_line1, title_line2) +} diff --git a/crates/suitcase/src/tabs/icon_sys_viewer.rs b/crates/suitcase/src/tabs/icon_sys_viewer.rs index dfdb78d..129d1ae 100644 --- a/crates/suitcase/src/tabs/icon_sys_viewer.rs +++ b/crates/suitcase/src/tabs/icon_sys_viewer.rs @@ -1,9 +1,7 @@ use crate::tabs::Tab; use crate::{AppState, VirtualFile}; use eframe::egui; -use eframe::egui::{ - vec2, Color32, CornerRadius, Grid, Id, PopupCloseBehavior, Response, Rgba, TextEdit, Ui, -}; +use eframe::egui::{menu, vec2, Color32, CornerRadius, Grid, Id, PopupCloseBehavior, Response, Rgba, RichText, TextEdit, Ui}; use ps2_filetypes::color::Color; use ps2_filetypes::{ColorF, IconSys, Vector}; use relative_path::PathExt; @@ -82,7 +80,8 @@ impl Light { } pub struct IconSysViewer { - title: String, + title_line1: String, + title_line2: String, file: String, pub icon_file: String, pub icon_copy_file: String, @@ -102,7 +101,8 @@ impl IconSysViewer { let sys = IconSys::new(buf); Self { - title: sys.title.clone(), + title_line1: sys.title_line1.clone(), + title_line2: sys.title_line2.clone(), icon_file: sys.icon_file.clone(), icon_copy_file: sys.icon_copy_file.clone(), icon_delete_file: sys.icon_delete_file.clone(), @@ -150,21 +150,45 @@ impl IconSysViewer { }) .collect(); + const MIN_COL_WIDTH: f32 = 160.0; + const SEPARATOR_MARGIN: f32 = 10.0; + ui.vertical(|ui| { - // eframe::egui::Grid::new(Id::from("IconSysEditor")) - // .num_columns(2) - // .show(ui, |ui| { + menu::bar(ui, |ui| { + ui.set_height(25.0); + ui.add_space(10.0); + ui.button("Save").clicked().then(|| self.save()); + }); + ui.separator(); + ui.add_space(SEPARATOR_MARGIN); + ui.heading("Icon Configuration"); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label("Title"); - ui.add(TextEdit::singleline(&mut self.title)); + ui.add_space(SEPARATOR_MARGIN); + + Grid::new(Id::from("IconSysEditor")).num_columns(2).min_col_width(MIN_COL_WIDTH).show(ui, |ui| { + ui.label("Title first line"); + ui.add(TextEdit::singleline(&mut self.title_line1)); + if (self.title_line1.len() > 15) { + ui.label(format!("OSDMENU can only handle linebreak before 15 characters. {}/15", self.title_line1.len())); + } + ui.end_row(); + ui.label("Title second line"); + ui.add(TextEdit::singleline(&mut self.title_line2)); + if self.title_len() > 34 { + ui.end_row(); + ui.end_row(); + ui.label(""); + ui.label(RichText::new(format!("Title too long {}/34", self.title_len())).color(Color32::RED)); + } }); + ui.add_space(SEPARATOR_MARGIN); + ui.separator(); + ui.add_space(SEPARATOR_MARGIN); ui.heading("Icons"); - ui.add_space(4.0); + ui.add_space(SEPARATOR_MARGIN); - Grid::new("icons").num_columns(2).show(ui, |ui| { + Grid::new("icons").num_columns(2).min_col_width(MIN_COL_WIDTH).show(ui, |ui| { ui.label("List"); file_select(ui, "list_icon", &mut self.icon_file, &files); ui.end_row(); @@ -174,96 +198,105 @@ impl IconSysViewer { ui.label("Delete"); file_select(ui, "delete_icon", &mut self.icon_delete_file, &files); }); + ui.add_space(SEPARATOR_MARGIN); + ui.separator(); + ui.add_space(SEPARATOR_MARGIN); ui.heading("Background"); - ui.add_space(4.0); - - const SPACING: f32 = 40.0; - - ui.add_sized(vec2(SPACING * 3.0, SPACING * 3.0), |ui: &mut Ui| { - draw_background(ui, &self.background_colors); - ui.spacing_mut().interact_size = vec2(SPACING, SPACING); - ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - - ui.columns(3, |cols| { - egui::widgets::color_picker::color_edit_button_rgb( - &mut cols[0], - &mut self.background_colors[0].rgb, - ); - cols[1].add_space(SPACING); - egui::widgets::color_picker::color_edit_button_rgb( - &mut cols[2], - &mut self.background_colors[1].rgb, - ); - - cols[0].add_space(SPACING); - cols[1].add_space(SPACING); - cols[2].add_space(SPACING); - - egui::widgets::color_picker::color_edit_button_rgb( - &mut cols[0], - &mut self.background_colors[2].rgb, - ); - cols[1].add_space(SPACING); - egui::widgets::color_picker::color_edit_button_rgb( - &mut cols[2], - &mut self.background_colors[3].rgb, - ); + ui.add_space(SEPARATOR_MARGIN); + + Grid::new("background").num_columns(2).min_col_width(MIN_COL_WIDTH).show(ui, |ui| { + const SPACING: f32 = 40.0; + ui.add_sized(vec2(SPACING * 3.0, SPACING * 3.0), |ui: &mut Ui| { + draw_background(ui, &self.background_colors); + ui.spacing_mut().interact_size = vec2(SPACING, SPACING); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + + ui.columns(3, |cols| { + egui::widgets::color_picker::color_edit_button_rgb( + &mut cols[0], + &mut self.background_colors[0].rgb, + ); + cols[1].add_space(SPACING); + egui::widgets::color_picker::color_edit_button_rgb( + &mut cols[2], + &mut self.background_colors[1].rgb, + ); + + cols[0].add_space(SPACING); + cols[1].add_space(SPACING); + cols[2].add_space(SPACING); + + egui::widgets::color_picker::color_edit_button_rgb( + &mut cols[0], + &mut self.background_colors[2].rgb, + ); + cols[1].add_space(SPACING); + egui::widgets::color_picker::color_edit_button_rgb( + &mut cols[2], + &mut self.background_colors[3].rgb, + ); + }); + ui.response() }); - ui.response() - }); - Grid::new("background").num_columns(2).show(ui, |ui| { - ui.label("Background Transparency").on_hover_ui(|ui| { - ui.label( - "This is the opposite of opacity, so a value of 100 will make \ - the background completely transparent", - ); + Grid::new("background_ambient").num_columns(2).show(ui, |ui| { + ui.label("Background Transparency").on_hover_ui(|ui| { + ui.label( + "This is the opposite of opacity, so a value of 100 will make \ + the background completely transparent", + ); + }); + ui.add(egui::Slider::new( + &mut self.background_transparency, + 0..=100, + )); + ui.end_row(); + ui.label("Ambient Color"); + egui::widgets::color_picker::color_edit_button_rgb(ui, &mut self.ambient_color.rgb); + ui.end_row(); }); - ui.add(egui::Slider::new( - &mut self.background_transparency, - 0..=100, - )); - ui.end_row(); - ui.label("Ambient Color"); - egui::widgets::color_picker::color_edit_button_rgb(ui, &mut self.ambient_color.rgb); - ui.end_row(); }); + ui.add_space(SEPARATOR_MARGIN); + ui.separator(); + ui.add_space(SEPARATOR_MARGIN); ui.heading("Lights"); - ui.add_space(4.0); - - for (index, light) in self.lights.iter_mut().enumerate() { - let human_readable_index = index + 1; - ui.label(format!("Light {human_readable_index}")); - ui.end_row(); - ui.label("Color"); - egui::widgets::color_picker::color_edit_button_rgb(ui, &mut light.color.rgb); - ui.end_row(); - - ui.label("X"); - ui.add(egui::Slider::new(&mut light.direction.x, 0.0..=1.0)); - ui.end_row(); - ui.label("Y"); - ui.add(egui::Slider::new(&mut light.direction.y, 0.0..=1.0)); - ui.end_row(); - ui.label("Z"); - ui.add(egui::Slider::new(&mut light.direction.z, 0.0..=1.0)); - ui.end_row(); - - Ui::separator(ui); - ui.end_row(); - } - - // }); - ui.button("Save") - .on_hover_text("Save changes") - .clicked() - .then(|| { - self.save(); - }); + ui.add_space(SEPARATOR_MARGIN); + + Grid::new("lights").num_columns(3).min_col_width(250.0).show(ui, |ui| { + for (index, light) in self.lights.iter_mut().enumerate() { + let human_readable_index = index + 1; + Grid::new(format!("light {index}")).num_columns(2).min_col_width(50.0).show(ui, |ui| { + ui.label(format!("Light {human_readable_index}")); + ui.end_row(); + + ui.label("Color"); + egui::widgets::color_picker::color_edit_button_rgb(ui, &mut light.color.rgb); + ui.end_row(); + + ui.label("X"); + ui.add(egui::Slider::new(&mut light.direction.x, 0.0..=1.0)); + ui.end_row(); + + ui.label("Y"); + ui.add(egui::Slider::new(&mut light.direction.y, 0.0..=1.0)); + ui.end_row(); + + ui.label("Z"); + ui.add(egui::Slider::new(&mut light.direction.z, 0.0..=1.0)); + }); + } + }); }); } + + fn title_len(&self) -> usize { + if self.title_line2.len() == 0 { + return self.title_line1.len(); + } + self.title_line1.len() + self.title_line2.len() + 1 // 1 extra char for the linebreak space + } } impl Tab for IconSysViewer { @@ -276,7 +309,8 @@ impl Tab for IconSysViewer { } fn get_modified(&self) -> bool { - self.sys.title != self.title + self.sys.title_line1 != self.title_line1 + || self.sys.title_line2 != self.title_line2 || self.sys.icon_file != self.icon_file || self.sys.icon_copy_file != self.icon_copy_file || self.sys.icon_delete_file != self.icon_delete_file @@ -284,7 +318,9 @@ impl Tab for IconSysViewer { fn save(&mut self) { let new_sys = IconSys { - title: self.title.clone(), + title_line1: self.title_line1.clone(), + title_line2: self.title_line2.clone(), + linebreak_pos: self.title_line1.len() as u8, icon_file: self.icon_file.clone(), icon_copy_file: self.icon_copy_file.clone(), icon_delete_file: self.icon_delete_file.clone(), diff --git a/crates/suitcase/src/tabs/title_cfg_viewer.rs b/crates/suitcase/src/tabs/title_cfg_viewer.rs index a577f82..1d2188b 100644 --- a/crates/suitcase/src/tabs/title_cfg_viewer.rs +++ b/crates/suitcase/src/tabs/title_cfg_viewer.rs @@ -44,6 +44,7 @@ impl TitleCfgViewer { ui.vertical(|ui| { menu::bar(ui, |ui| { ui.set_height(25.0); + ui.add_space(10.0); ui.button("Save").clicked().then(|| self.save()); ui.button("Toggle Raw Editor").clicked().then(|| { self.toggle_editors();