From 99aecaefde94c9f3dd28bf02dbb5fd96178bc77b Mon Sep 17 00:00:00 2001 From: Connor Date: Fri, 16 Jan 2026 13:31:48 +0000 Subject: [PATCH] images in database --- .claude/settings.local.json | 3 +- .gitignore | 1 + .../down.sql | 3 + .../up.sql | 24 + cursebreaker-parser/src/bin/image-parser.rs | 27 +- cursebreaker-parser/src/bin/scene-parser.rs | 419 ++++++++++------ cursebreaker-parser/src/bin/xml-parser.rs | 2 +- .../src/databases/icon_database.rs | 450 ++++++++++++++++++ cursebreaker-parser/src/databases/mod.rs | 2 + cursebreaker-parser/src/image_processor.rs | 10 - cursebreaker-parser/src/lib.rs | 11 + cursebreaker-parser/src/schema.rs | 30 ++ .../src/types/cursebreaker/icon_models.rs | 87 ++++ .../src/types/cursebreaker/mod.rs | 10 + 14 files changed, 909 insertions(+), 170 deletions(-) create mode 100644 cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/down.sql create mode 100644 cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/up.sql create mode 100644 cursebreaker-parser/src/databases/icon_database.rs create mode 100644 cursebreaker-parser/src/types/cursebreaker/icon_models.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f4274d0..055dc2b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -44,7 +44,8 @@ "Bash(timeout 10 cargo run:*)", "Bash(timeout 60 cargo run:*)", "Bash(DATABASE_URL=../cursebreaker.db diesel print-schema:*)", - "Bash(DATABASE_URL=../cursebreaker.db diesel database:*)" + "Bash(DATABASE_URL=../cursebreaker.db diesel database:*)", + "Bash(DATABASE_URL=cursebreaker.db CB_ASSETS_PATH=/home/connor/repos/CBAssets cargo run:*)" ], "additionalDirectories": [ "/home/connor/repos/CBAssets/" diff --git a/.gitignore b/.gitignore index 7b0fb7f..80ccf24 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ target/ # Test data (cloned Unity projects for integration tests) test_data/ cursebreaker.db +**/cursebreaker.db diff --git a/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/down.sql b/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/down.sql new file mode 100644 index 0000000..fdedcc6 --- /dev/null +++ b/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS icons; +DROP TABLE IF EXISTS achievement_icons; +DROP TABLE IF EXISTS general_icons; diff --git a/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/up.sql b/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/up.sql new file mode 100644 index 0000000..84a51d9 --- /dev/null +++ b/cursebreaker-parser/migrations/2026-01-16-103751-0000_0000_create_icon_tables/up.sql @@ -0,0 +1,24 @@ +-- Simple icons table (abilities, buffs, traits, player houses, stat icons) +CREATE TABLE IF NOT EXISTS icons ( + category TEXT NOT NULL, + name TEXT NOT NULL, + icon BLOB NOT NULL, + PRIMARY KEY (category, name) +); + +-- Achievement icons table (filtered, no _0 suffix) +CREATE TABLE IF NOT EXISTS achievement_icons ( + name TEXT PRIMARY KEY NOT NULL, + icon BLOB NOT NULL +); + +-- General icons table (multiple sizes) +CREATE TABLE IF NOT EXISTS general_icons ( + name TEXT PRIMARY KEY NOT NULL, + original_width INTEGER NOT NULL, + original_height INTEGER NOT NULL, + icon_original BLOB, + icon_256 BLOB, + icon_64 BLOB, + icon_32 BLOB +); diff --git a/cursebreaker-parser/src/bin/image-parser.rs b/cursebreaker-parser/src/bin/image-parser.rs index a5bd8d5..02133a9 100644 --- a/cursebreaker-parser/src/bin/image-parser.rs +++ b/cursebreaker-parser/src/bin/image-parser.rs @@ -8,7 +8,7 @@ //! - Storing all tiles in the SQLite database //! - Generating statistics about storage and compression -use cursebreaker_parser::MinimapDatabase; +use cursebreaker_parser::{MinimapDatabase, IconDatabase}; use log::{info, error, LevelFilter}; use unity_parser::log::DedupLogger; use std::env; @@ -26,7 +26,7 @@ fn main() -> Result<(), Box> { // Process minimap tiles info!("šŸ—ŗļø Processing minimap tiles..."); let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string()); - let minimap_db = MinimapDatabase::new(database_url); + let minimap_db = MinimapDatabase::new(database_url.clone()); let cb_assets_path = env::var("CB_ASSETS_PATH") .unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string()); @@ -70,6 +70,29 @@ fn main() -> Result<(), Box> { } } + // Process game icons + info!("\n=== Processing Game Icons ==="); + let icon_db = IconDatabase::new(database_url); + + match icon_db.load_all_icons(&cb_assets_path) { + Ok(stats) => { + info!("\n=== Icon Statistics ==="); + info!("Ability icons: {}", stats.abilities); + info!("Buff icons: {}", stats.buffs); + info!("Trait icons: {}", stats.traits); + info!("Player house icons: {}", stats.player_houses); + info!("Stat icons: {}", stats.stat_icons); + info!("Achievement icons: {}", stats.achievement_icons); + info!("General icons: {}", stats.general_icons); + info!("Total icons: {}", stats.total_icons()); + info!("Total size: {} KB", stats.total_bytes / 1024); + } + Err(e) => { + error!("Failed to process icons: {}", e); + return Err(Box::new(e)); + } + } + log::logger().flush(); Ok(()) diff --git a/cursebreaker-parser/src/bin/scene-parser.rs b/cursebreaker-parser/src/bin/scene-parser.rs index 4b5cf06..59dd5dc 100644 --- a/cursebreaker-parser/src/bin/scene-parser.rs +++ b/cursebreaker-parser/src/bin/scene-parser.rs @@ -7,6 +7,13 @@ //! - Computing world transforms //! - Saving resource locations to the database //! - Processing and saving item icons for resources +//! +//! Usage: +//! scene-parser [min_x max_x min_y max_y] +//! +//! Examples: +//! scene-parser # Parse all scenes +//! scene-parser 0 10 0 10 # Parse scenes from (0,0) to (10,10) use cursebreaker_parser::{ InteractableResource, InteractableTeleporter, InteractableWorkbench, @@ -20,6 +27,94 @@ use std::env; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use std::collections::HashMap; +use std::fs; + +/// Bounds for filtering which scene tiles to parse +#[derive(Debug, Clone)] +struct Bounds { + min_x: i32, + max_x: i32, + min_y: i32, + max_y: i32, +} + +impl Bounds { + fn contains(&self, x: i32, y: i32) -> bool { + x >= self.min_x && x <= self.max_x && y >= self.min_y && y <= self.max_y + } +} + +/// Parse scene filename to extract tile coordinates (e.g., "10_3.unity" -> (10, 3)) +fn parse_scene_coords(filename: &str) -> Option<(i32, i32)> { + let stem = filename.strip_suffix(".unity")?; + let parts: Vec<&str> = stem.split('_').collect(); + if parts.len() == 2 { + let x = parts[0].parse().ok()?; + let y = parts[1].parse().ok()?; + Some((x, y)) + } else { + None + } +} + +/// Find all scene files matching the *_*.unity pattern +fn find_scene_files(scenes_dir: &Path, bounds: Option<&Bounds>) -> Vec { + let mut scenes = Vec::new(); + + if let Ok(entries) = fs::read_dir(scenes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + if filename.ends_with(".unity") { + if let Some((x, y)) = parse_scene_coords(filename) { + // Check bounds if specified + if let Some(b) = bounds { + if !b.contains(x, y) { + continue; + } + } + scenes.push(path); + } + } + } + } + } + + // Sort by coordinates for consistent ordering + scenes.sort_by(|a, b| { + let a_coords = a.file_name() + .and_then(|n| n.to_str()) + .and_then(parse_scene_coords) + .unwrap_or((0, 0)); + let b_coords = b.file_name() + .and_then(|n| n.to_str()) + .and_then(parse_scene_coords) + .unwrap_or((0, 0)); + a_coords.cmp(&b_coords) + }); + + scenes +} + +/// Parse command line arguments for bounds +fn parse_bounds_args() -> Option { + let args: Vec = env::args().collect(); + + if args.len() == 5 { + let min_x = args[1].parse().ok()?; + let max_x = args[2].parse().ok()?; + let min_y = args[3].parse().ok()?; + let max_y = args[4].parse().ok()?; + Some(Bounds { min_x, max_x, min_y, max_y }) + } else if args.len() == 1 { + None // No bounds specified, parse all + } else { + eprintln!("Usage: {} [min_x max_x min_y max_y]", args[0]); + eprintln!(" No arguments: parse all scenes"); + eprintln!(" 4 arguments: parse scenes within bounds (inclusive)"); + std::process::exit(1); + } +} fn main() -> Result<(), Box> { let logger = DedupLogger::new(); @@ -29,6 +124,14 @@ fn main() -> Result<(), Box> { info!("šŸŽ® Cursebreaker - Scene Parser"); + // Parse bounds from command line + let bounds = parse_bounds_args(); + if let Some(ref b) = bounds { + info!("šŸ“ Bounds: x=[{}, {}], y=[{}, {}]", b.min_x, b.max_x, b.min_y, b.max_y); + } else { + info!("šŸ“ Bounds: none (parsing all scenes)"); + } + let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string()); // Initialize Unity project once - scans entire project for GUID mappings @@ -37,6 +140,16 @@ fn main() -> Result<(), Box> { let project = UnityProject::from_path(project_root)?; + // Find all scene files + let scenes_dir = project_root.join("_GameAssets/Scenes/Tiles"); + let scene_files = find_scene_files(&scenes_dir, bounds.as_ref()); + info!("šŸ” Found {} scene files to parse", scene_files.len()); + + if scene_files.is_empty() { + warn!("No scene files found matching criteria"); + return Ok(()); + } + // Create type filter to only parse GameObject, Transform, and InteractableResource MonoBehaviour info!("šŸ” Setting up type filter:"); info!(" • Unity types: GameObject, Transform"); @@ -46,117 +159,146 @@ fn main() -> Result<(), Box> { vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"] ); - // Now parse the scene using the pre-built GUID resolvers with filtering - let scene_path = "_GameAssets/Scenes/Tiles/10_3.unity"; - info!("šŸ“ Parsing scene: {}", scene_path); + // Setup database connection + let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string()); + let mut conn = SqliteConnection::establish(&database_url)?; + + // Clear all tables before processing (they're regenerated each run) + { + use cursebreaker_parser::schema::{ + world_resources, world_teleporters, world_workbenches, + world_loot, world_map_icons, world_map_name_changers, resource_icons + }; + diesel::delete(world_resources::table).execute(&mut conn)?; + diesel::delete(world_teleporters::table).execute(&mut conn)?; + diesel::delete(world_workbenches::table).execute(&mut conn)?; + diesel::delete(world_loot::table).execute(&mut conn)?; + diesel::delete(world_map_icons::table).execute(&mut conn)?; + diesel::delete(world_map_name_changers::table).execute(&mut conn)?; + diesel::delete(resource_icons::table).execute(&mut conn)?; + } + + // Collect unique harvestables across all scenes for icon processing + let mut all_unique_harvestables: HashMap = HashMap::new(); + + // Track totals + let mut total_resources = 0; + let mut total_teleporters = 0; + let mut total_workbenches = 0; + let mut total_loot = 0; + let mut total_map_icons = 0; + let mut total_map_name_changers = 0; + let mut scenes_processed = 0; + let mut scenes_failed = 0; + + // Process each scene + for (idx, scene_path) in scene_files.iter().enumerate() { + let relative_path = scene_path.strip_prefix(project_root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| scene_path.to_string_lossy().to_string()); + + info!("\nšŸ“ [{}/{}] Parsing scene: {}", idx + 1, scene_files.len(), relative_path); + log::logger().flush(); + + match project.parse_scene_filtered(&relative_path, Some(&type_filter)) { + Ok(mut scene) => { + info!(" āœ“ Parsed ({} entities)", scene.entity_map.len()); + + // Post-processing: Compute world transforms + unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map); + + // Save resources + let resource_count = save_resources(&mut conn, &scene)?; + total_resources += resource_count; + + // Collect unique harvestables for icon processing later + scene.world + .query_all::<(&InteractableResource, &unity_parser::GameObject)>() + .for_each(|(resource, object)| { + all_unique_harvestables.entry(resource.type_id as i32) + .or_insert_with(|| object.name.to_string()); + }); + + // Save other world objects (append mode - tables already cleared) + total_teleporters += save_teleporters_append(&mut conn, &scene)?; + total_workbenches += save_workbenches_append(&mut conn, &scene)?; + total_loot += save_loot_spawners_append(&mut conn, &scene)?; + total_map_icons += save_map_icons_append(&mut conn, &scene)?; + total_map_name_changers += save_map_name_changers_append(&mut conn, &scene)?; + + scenes_processed += 1; + } + Err(e) => { + error!(" āœ— Parse error: {}", e); + scenes_failed += 1; + } + } + } log::logger().flush(); - // Parse the scene using the project with type filtering - match project.parse_scene_filtered(scene_path, Some(&type_filter)) { - Ok(mut scene) => { - info!("āœ… Scene parsed successfully!"); - info!(" Total entities: {}", scene.entity_map.len()); + // Process icons for all unique harvestables + info!("\nšŸŽØ Processing item icons for {} unique harvestable types...", all_unique_harvestables.len()); + process_item_icons_from_map(&cb_assets_path, &mut conn, &all_unique_harvestables)?; - // Post-processing: Compute world transforms - info!("šŸ”„ Computing world transforms..."); - unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map); - info!(" āœ“ World transforms computed"); - - // Save resources to database - info!("šŸ’¾ Saving resources to database..."); - let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string()); - let mut conn = SqliteConnection::establish(&database_url)?; - - // Use diesel schema - use cursebreaker_parser::schema::world_resources; - - // Clear the entire table (it's regenerated each run) - diesel::delete(world_resources::table).execute(&mut conn)?; - - let mut resource_count = 0; - - // Insert all resources in a transaction - conn.transaction::<_, diesel::result::Error, _>(|conn| { - scene.world - .query_all::<(&InteractableResource, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(resource, transform, object)| { - let world_pos = transform.position(); - - info!(" šŸ“¦ Resource: \"{}\"", object.name); - info!(" • typeId: {}", resource.type_id); - info!(" • Position: ({:.2}, {:.2})", world_pos.x, world_pos.z); - - // Insert: store x and z (as y) coordinates only - let _ = diesel::insert_into(world_resources::table) - .values(( - world_resources::item_id.eq(resource.type_id as i32), - world_resources::pos_x.eq(world_pos.x as f32), - world_resources::pos_y.eq(world_pos.z as f32), - )) - .execute(conn); - - resource_count += 1; - }); - Ok(()) - })?; - - info!("āœ… Saved {} resources to database", resource_count); - log::logger().flush(); - - // Process and save item icons - info!("šŸŽØ Processing item icons..."); - process_item_icons(&cb_assets_path, &mut conn, &scene)?; - - // Save other world objects - info!("šŸ—ŗļø Saving teleporters..."); - save_teleporters(&mut conn, &scene)?; - - info!("šŸ”Ø Saving workbenches..."); - save_workbenches(&mut conn, &scene)?; - - info!("šŸ’° Saving loot spawners..."); - save_loot_spawners(&mut conn, &scene)?; - - info!("šŸ“ Saving map icons..."); - save_map_icons(&mut conn, &scene)?; - - info!("šŸ·ļø Saving map name changers..."); - save_map_name_changers(&mut conn, &scene)?; - } - Err(e) => { - error!("Parse error: {}", e); - return Err(Box::new(e)); - } - } + // Print summary + info!("\n=================================================="); + info!("šŸ“Š SUMMARY"); + info!("=================================================="); + info!(" Scenes processed: {} ({} failed)", scenes_processed, scenes_failed); + info!(" Resources: {}", total_resources); + info!(" Teleporters: {}", total_teleporters); + info!(" Workbenches: {}", total_workbenches); + info!(" Loot spawners: {}", total_loot); + info!(" Map icons: {}", total_map_icons); + info!(" Map name changers:{}", total_map_name_changers); + info!("=================================================="); log::logger().flush(); Ok(()) } -/// Process item icons for all resources in the scene -fn process_item_icons( - cb_assets_path: &str, +/// Save resources from a scene (append mode) +fn save_resources( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, +) -> Result> { + use cursebreaker_parser::schema::world_resources; + + let mut count = 0; + + conn.transaction::<_, diesel::result::Error, _>(|conn| { + scene.world + .query_all::<(&InteractableResource, &unity_parser::WorldTransform, &unity_parser::GameObject)>() + .for_each(|(resource, transform, _object)| { + let world_pos = transform.position(); + + let _ = diesel::insert_into(world_resources::table) + .values(( + world_resources::item_id.eq(resource.type_id as i32), + world_resources::pos_x.eq(world_pos.x as f32), + world_resources::pos_y.eq(world_pos.z as f32), + )) + .execute(conn); + + count += 1; + }); + Ok(()) + })?; + + Ok(count) +} + +/// Process item icons from a pre-collected map of harvestables +fn process_item_icons_from_map( + cb_assets_path: &str, + conn: &mut SqliteConnection, + unique_harvestables: &HashMap, ) -> Result<(), Box> { use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops}; - // Collect unique harvestable IDs from resources - let mut unique_harvestables: HashMap = HashMap::new(); - - scene.world - .query_all::<(&InteractableResource, &unity_parser::GameObject)>() - .for_each(|(resource, object)| { - unique_harvestables.entry(resource.type_id as i32) - .or_insert_with(|| object.name.to_string()); - }); - - info!(" Found {} unique harvestable types", unique_harvestables.len()); - - // Clear existing resource icons (regenerated each run) - diesel::delete(resource_icons::table).execute(conn)?; + info!(" Processing {} unique harvestable types", unique_harvestables.len()); // Create image processor with white outline let processor = ImageProcessor::default(); @@ -249,22 +391,19 @@ fn process_item_icons( Ok(()) } -/// Save teleporter data to database -fn save_teleporters( +/// Save teleporter data to database (append mode - doesn't clear table) +fn save_teleporters_append( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, -) -> Result<(), Box> { +) -> Result> { use cursebreaker_parser::schema::world_teleporters; - // Clear existing teleporters - diesel::delete(world_teleporters::table).execute(conn)?; - let mut count = 0; // Query all teleporters scene.world .query_all::<(&InteractableTeleporter, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(teleporter, transform, object)| { + .for_each(|(teleporter, transform, _object)| { let world_pos = transform.position(); // Get the tp_transform position if it exists @@ -279,9 +418,6 @@ fn save_teleporters( (None, None) }; - info!(" šŸ—ŗļø Teleporter: \"{}\" at ({:.2}, {:.2}) -> ({:?}, {:?})", - object.name, world_pos.x, world_pos.z, tp_x, tp_y); - let _ = diesel::insert_into(world_teleporters::table) .values(( world_teleporters::pos_x.eq(world_pos.x as f32), @@ -294,31 +430,24 @@ fn save_teleporters( count += 1; }); - info!("āœ… Saved {} teleporters to database", count); - Ok(()) + Ok(count) } -/// Save workbench data to database -fn save_workbenches( +/// Save workbench data to database (append mode - doesn't clear table) +fn save_workbenches_append( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, -) -> Result<(), Box> { +) -> Result> { use cursebreaker_parser::schema::world_workbenches; - // Clear existing workbenches - diesel::delete(world_workbenches::table).execute(conn)?; - let mut count = 0; // Query all workbenches scene.world .query_all::<(&InteractableWorkbench, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(workbench, transform, object)| { + .for_each(|(workbench, transform, _object)| { let world_pos = transform.position(); - info!(" šŸ”Ø Workbench: \"{}\" (ID: {}) at ({:.2}, {:.2})", - object.name, workbench.workbench_id, world_pos.x, world_pos.z); - let _ = diesel::insert_into(world_workbenches::table) .values(( world_workbenches::pos_x.eq(world_pos.x as f32), @@ -330,31 +459,24 @@ fn save_workbenches( count += 1; }); - info!("āœ… Saved {} workbenches to database", count); - Ok(()) + Ok(count) } -/// Save loot spawner data to database -fn save_loot_spawners( +/// Save loot spawner data to database (append mode - doesn't clear table) +fn save_loot_spawners_append( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, -) -> Result<(), Box> { +) -> Result> { use cursebreaker_parser::schema::world_loot; - // Clear existing loot spawners - diesel::delete(world_loot::table).execute(conn)?; - let mut count = 0; // Query all loot spawners scene.world .query_all::<(&LootSpawner, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(loot, transform, object)| { + .for_each(|(loot, transform, _object)| { let world_pos = transform.position(); - info!(" šŸ’° Loot: \"{}\" (Item: {}, Amount: {}, Respawn: {}s) at ({:.2}, {:.2})", - object.name, loot.item_id, loot.amount, loot.respawn_time, world_pos.x, world_pos.z); - let _ = diesel::insert_into(world_loot::table) .values(( world_loot::pos_x.eq(world_pos.x as f32), @@ -369,31 +491,24 @@ fn save_loot_spawners( count += 1; }); - info!("āœ… Saved {} loot spawners to database", count); - Ok(()) + Ok(count) } -/// Save map icon data to database -fn save_map_icons( +/// Save map icon data to database (append mode - doesn't clear table) +fn save_map_icons_append( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, -) -> Result<(), Box> { +) -> Result> { use cursebreaker_parser::schema::world_map_icons; - // Clear existing map icons - diesel::delete(world_map_icons::table).execute(conn)?; - let mut count = 0; // Query all map icons scene.world .query_all::<(&MapIcon, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(icon, transform, object)| { + .for_each(|(icon, transform, _object)| { let world_pos = transform.position(); - info!(" šŸ“ MapIcon: \"{}\" (Type: {:?}, Text: \"{}\") at ({:.2}, {:.2})", - object.name, icon.icon_type, icon.text, world_pos.x, world_pos.z); - let _ = diesel::insert_into(world_map_icons::table) .values(( world_map_icons::pos_x.eq(world_pos.x as f32), @@ -410,31 +525,24 @@ fn save_map_icons( count += 1; }); - info!("āœ… Saved {} map icons to database", count); - Ok(()) + Ok(count) } -/// Save map name changer data to database -fn save_map_name_changers( +/// Save map name changer data to database (append mode - doesn't clear table) +fn save_map_name_changers_append( conn: &mut SqliteConnection, scene: &unity_parser::UnityScene, -) -> Result<(), Box> { +) -> Result> { use cursebreaker_parser::schema::world_map_name_changers; - // Clear existing map name changers - diesel::delete(world_map_name_changers::table).execute(conn)?; - let mut count = 0; // Query all map name changers scene.world .query_all::<(&MapNameChanger, &unity_parser::WorldTransform, &unity_parser::GameObject)>() - .for_each(|(changer, transform, object)| { + .for_each(|(changer, transform, _object)| { let world_pos = transform.position(); - info!(" šŸ·ļø MapNameChanger: \"{}\" -> \"{}\" at ({:.2}, {:.2})", - object.name, changer.map_name, world_pos.x, world_pos.z); - let _ = diesel::insert_into(world_map_name_changers::table) .values(( world_map_name_changers::pos_x.eq(world_pos.x as f32), @@ -446,6 +554,5 @@ fn save_map_name_changers( count += 1; }); - info!("āœ… Saved {} map name changers to database", count); - Ok(()) + Ok(count) } diff --git a/cursebreaker-parser/src/bin/xml-parser.rs b/cursebreaker-parser/src/bin/xml-parser.rs index b93c2c5..f6251a1 100644 --- a/cursebreaker-parser/src/bin/xml-parser.rs +++ b/cursebreaker-parser/src/bin/xml-parser.rs @@ -66,7 +66,7 @@ fn main() -> Result<(), Box> { // Save to SQLite database info!("\nšŸ’¾ Saving game data to SQLite database..."); - let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string()); + let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string()); let mut conn = SqliteConnection::establish(&database_url)?; // Process and save items with icons diff --git a/cursebreaker-parser/src/databases/icon_database.rs b/cursebreaker-parser/src/databases/icon_database.rs new file mode 100644 index 0000000..e53d3d5 --- /dev/null +++ b/cursebreaker-parser/src/databases/icon_database.rs @@ -0,0 +1,450 @@ +use crate::types::{IconCategory, NewIcon, NewAchievementIcon, NewGeneralIcon}; +use crate::image_processor::ImageProcessor; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use std::path::{Path, PathBuf}; +use std::fs; +use thiserror::Error; +use log::{info, warn}; + +#[derive(Debug, Error)] +pub enum IconDatabaseError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + + #[error("Image load error: {0}")] + ImageLoadError(#[from] image::ImageError), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Connection pool error: {0}")] + ConnectionError(String), +} + +/// Statistics for icon loading +#[derive(Debug, Default)] +pub struct IconStats { + pub abilities: usize, + pub buffs: usize, + pub traits: usize, + pub player_houses: usize, + pub stat_icons: usize, + pub achievement_icons: usize, + pub general_icons: usize, + pub total_bytes: usize, +} + +impl IconStats { + pub fn total_icons(&self) -> usize { + self.abilities + self.buffs + self.traits + self.player_houses + + self.stat_icons + self.achievement_icons + self.general_icons + } +} + +/// Database for managing game icons +pub struct IconDatabase { + database_url: String, +} + +impl IconDatabase { + /// Create new database connection + pub fn new(database_url: String) -> Self { + Self { database_url } + } + + /// Establish database connection + fn establish_connection(&self) -> Result { + SqliteConnection::establish(&self.database_url) + .map_err(|e| IconDatabaseError::ConnectionError(e.to_string())) + } + + /// Load all icons from the CBAssets directory + pub fn load_all_icons>( + &self, + cb_assets_path: P, + ) -> Result { + let base = cb_assets_path.as_ref(); + let textures = base.join("Data/Textures"); + let mut stats = IconStats::default(); + + // 1. Simple icons (original size only) + info!("Loading ability icons..."); + stats.abilities = self.load_simple_icons( + &textures.join("Abilities"), + IconCategory::Ability, + &mut stats.total_bytes, + )?; + + info!("Loading buff icons..."); + stats.buffs = self.load_simple_icons( + &textures.join("Buffs"), + IconCategory::Buff, + &mut stats.total_bytes, + )?; + + info!("Loading trait icons..."); + stats.traits = self.load_simple_icons( + &textures.join("Traits"), + IconCategory::Trait, + &mut stats.total_bytes, + )?; + + info!("Loading player house icons..."); + stats.player_houses = self.load_simple_icons( + &textures.join("PlayerHouses/Houses"), + IconCategory::PlayerHouse, + &mut stats.total_bytes, + )?; + + info!("Loading stat icons..."); + stats.stat_icons = self.load_simple_icons( + &textures.join("StatIcons"), + IconCategory::StatIcon, + &mut stats.total_bytes, + )?; + + // 2. Achievement icons (filtered, no _0 suffix) + info!("Loading achievement icons..."); + stats.achievement_icons = self.load_achievement_icons( + &textures.join("Achievements/Icons"), + &mut stats.total_bytes, + )?; + + // 3. General icons (multi-size) + info!("Loading general icons..."); + stats.general_icons = self.load_general_icons(&textures, &mut stats.total_bytes)?; + + Ok(stats) + } + + /// Load simple icons from a directory (original size only, converted to WebP) + fn load_simple_icons>( + &self, + dir: P, + category: IconCategory, + total_bytes: &mut usize, + ) -> Result { + use crate::schema::icons; + + let dir_path = dir.as_ref(); + if !dir_path.exists() { + warn!("Directory does not exist: {}", dir_path.display()); + return Ok(0); + } + + let mut conn = self.establish_connection()?; + let mut count = 0; + + let image_files = self.find_image_files(dir_path)?; + + for path in image_files { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + if name.is_empty() { + continue; + } + + // Load and encode as lossless WebP + let img = image::open(&path)?; + let rgba = img.to_rgba8(); + let webp_data = ImageProcessor::encode_webp_lossless(&rgba) + .map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?; + + *total_bytes += webp_data.len(); + + let new_icon = NewIcon { + category: category.as_str(), + name: &name, + icon: &webp_data, + }; + + diesel::replace_into(icons::table) + .values(&new_icon) + .execute(&mut conn)?; + + count += 1; + } + + info!(" Loaded {} {} icons", count, category.as_str()); + Ok(count) + } + + /// Load achievement icons, filtering out files ending with _0 + fn load_achievement_icons>( + &self, + dir: P, + total_bytes: &mut usize, + ) -> Result { + use crate::schema::achievement_icons; + + let dir_path = dir.as_ref(); + if !dir_path.exists() { + warn!("Directory does not exist: {}", dir_path.display()); + return Ok(0); + } + + let mut conn = self.establish_connection()?; + let mut count = 0; + + let image_files = self.find_image_files(dir_path)?; + + for path in image_files { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + if name.is_empty() { + continue; + } + + // Skip files ending with _0 + if name.ends_with("_0") { + continue; + } + + // Load and encode as lossless WebP + let img = image::open(&path)?; + let rgba = img.to_rgba8(); + let webp_data = ImageProcessor::encode_webp_lossless(&rgba) + .map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?; + + *total_bytes += webp_data.len(); + + let new_icon = NewAchievementIcon { + name: &name, + icon: &webp_data, + }; + + diesel::replace_into(achievement_icons::table) + .values(&new_icon) + .execute(&mut conn)?; + + count += 1; + } + + info!(" Loaded {} achievement icons", count); + Ok(count) + } + + /// Load general icons with multiple sizes + fn load_general_icons>( + &self, + textures_dir: P, + total_bytes: &mut usize, + ) -> Result { + let textures = textures_dir.as_ref(); + let mut count = 0; + + // Collect all general icon paths + let mut icon_paths: Vec<(String, PathBuf)> = Vec::new(); + + // Directory-based icons + let directories = [ + ("Achievements/Trophies", true), // PNG only + ("BottomRightTabs", false), + ("MinimapIcons", false), + ("Notifications", false), + ("OverheadIcons", false), + ("Skills", false), + ]; + + for (subdir, png_only) in directories { + let dir = textures.join(subdir); + if dir.exists() { + let files = if png_only { + self.find_png_files(&dir)? + } else { + self.find_image_files(&dir)? + }; + + for path in files { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .map(|s| format!("{}_{}", subdir.replace('/', "_"), s)) + .unwrap_or_default(); + + if !name.is_empty() { + icon_paths.push((name, path)); + } + } + } + } + + // Individual file icons + let individual_files = [ + ("Common/Book.png", "Common_Book"), + ("Common/Hourglass.png", "Common_Hourglass"), + ("Common/Mana.png", "Common_Mana"), + ("Common/QuestCompleteTrophy.png", "Common_QuestCompleteTrophy"), + ("Common/Tick.png", "Common_Tick"), + ("Common/TutorialTip.png", "Common_TutorialTip"), + ("Common/Zoom_Minus.png", "Common_Zoom_Minus"), + ("Common/Zoom_Plus.png", "Common_Zoom_Plus"), + ("Inventory/Banknote.png", "Inventory_Banknote"), + ("Minimap/ShowCoordinates.png", "Minimap_ShowCoordinates"), + ("SplashScreens/Olipa.png", "SplashScreens_Olipa"), + ]; + + for (file, name) in individual_files { + let path = textures.join(file); + if path.exists() { + icon_paths.push((name.to_string(), path)); + } else { + warn!("File not found: {}", path.display()); + } + } + + // Process all collected icons + let mut conn = self.establish_connection()?; + + for (name, path) in icon_paths { + if let Ok(bytes) = self.process_general_icon(&path, &name, &mut conn) { + *total_bytes += bytes; + count += 1; + } + } + + info!(" Loaded {} general icons", count); + Ok(count) + } + + /// Process a single general icon at multiple sizes + fn process_general_icon( + &self, + path: &Path, + name: &str, + conn: &mut SqliteConnection, + ) -> Result { + use crate::schema::general_icons; + + // Load image + let img = image::open(path)?; + let (width, height) = (img.width(), img.height()); + let rgba = img.to_rgba8(); + + let mut total_bytes = 0; + + // Original size (lossless) + let icon_original = ImageProcessor::encode_webp_lossless(&rgba) + .map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?; + total_bytes += icon_original.len(); + + // Generate smaller sizes only if image is large enough (no upscaling) + let processor = ImageProcessor::new(90.0); + + let icon_256 = if width >= 256 && height >= 256 { + Some(self.resize_and_encode(&img, 256, &processor)?) + } else { + None + }; + if let Some(ref data) = icon_256 { + total_bytes += data.len(); + } + + let icon_64 = if width >= 64 && height >= 64 { + Some(self.resize_and_encode(&img, 64, &processor)?) + } else { + None + }; + if let Some(ref data) = icon_64 { + total_bytes += data.len(); + } + + let icon_32 = if width >= 32 && height >= 32 { + Some(self.resize_and_encode(&img, 32, &processor)?) + } else { + None + }; + if let Some(ref data) = icon_32 { + total_bytes += data.len(); + } + + let new_icon = NewGeneralIcon { + name, + original_width: width as i32, + original_height: height as i32, + icon_original: Some(&icon_original), + icon_256: icon_256.as_deref(), + icon_64: icon_64.as_deref(), + icon_32: icon_32.as_deref(), + }; + + diesel::replace_into(general_icons::table) + .values(&new_icon) + .execute(conn)?; + + Ok(total_bytes) + } + + /// Resize image and encode to WebP + fn resize_and_encode( + &self, + img: &image::DynamicImage, + size: u32, + _processor: &ImageProcessor, + ) -> Result, IconDatabaseError> { + let resized = img.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + let rgba = resized.to_rgba8(); + + // Use lossy encoding for smaller sizes + let encoder = webp::Encoder::from_rgba(rgba.as_raw(), size, size); + let webp_data = encoder.encode(90.0); + + Ok(webp_data.to_vec()) + } + + /// Find all image files (PNG, JPG, etc.) in a directory + fn find_image_files>( + &self, + dir: P, + ) -> Result, IconDatabaseError> { + let mut files = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + let ext_lower = ext.to_lowercase(); + if ext_lower == "png" || ext_lower == "jpg" || ext_lower == "jpeg" { + files.push(path); + } + } + } + } + + files.sort(); + Ok(files) + } + + /// Find only PNG files in a directory + fn find_png_files>( + &self, + dir: P, + ) -> Result, IconDatabaseError> { + let mut files = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + let ext_lower = ext.to_lowercase(); + if ext_lower == "png" { + files.push(path); + } + } + } + } + + files.sort(); + Ok(files) + } +} diff --git a/cursebreaker-parser/src/databases/mod.rs b/cursebreaker-parser/src/databases/mod.rs index 9d40b9b..5b37844 100644 --- a/cursebreaker-parser/src/databases/mod.rs +++ b/cursebreaker-parser/src/databases/mod.rs @@ -9,6 +9,7 @@ mod player_house_database; mod trait_database; mod shop_database; mod minimap_database; +mod icon_database; pub use item_database::ItemDatabase; pub use npc_database::NpcDatabase; @@ -21,3 +22,4 @@ pub use player_house_database::PlayerHouseDatabase; pub use trait_database::TraitDatabase; pub use shop_database::ShopDatabase; pub use minimap_database::{MinimapDatabase, MinimapDatabaseError, StorageStats}; +pub use icon_database::{IconDatabase, IconDatabaseError, IconStats}; diff --git a/cursebreaker-parser/src/image_processor.rs b/cursebreaker-parser/src/image_processor.rs index ebf4adc..58c5247 100644 --- a/cursebreaker-parser/src/image_processor.rs +++ b/cursebreaker-parser/src/image_processor.rs @@ -132,16 +132,6 @@ impl ImageProcessor { Ok(ProcessedImages { images }) } - /// Load PNG and generate 4 WebP sizes specifically for minimap tiles (512x512 source) - /// - /// Convenience method that generates 512, 256, 128, and 64 pixel versions - pub fn process_minimap_png>( - &self, - png_path: P, - ) -> Result { - self.process_image(png_path, &[512, 256, 128, 64], Some((512, 512)), None) - } - /// Apply outline effect to image based on alpha channel edges fn apply_outline(&self, img: RgbaImage, config: &OutlineConfig) -> RgbaImage { let (width, height) = img.dimensions(); diff --git a/cursebreaker-parser/src/lib.rs b/cursebreaker-parser/src/lib.rs index 0d5f27e..90bfe56 100644 --- a/cursebreaker-parser/src/lib.rs +++ b/cursebreaker-parser/src/lib.rs @@ -70,6 +70,9 @@ pub use databases::{ MinimapDatabase, MinimapDatabaseError, StorageStats, + IconDatabase, + IconDatabaseError, + IconStats, }; pub use types::{ // Items @@ -124,6 +127,14 @@ pub use types::{ MinimapTile, MinimapTileRecord, NewMinimapTile, + // Icons + IconCategory, + IconRecord, + NewIcon, + AchievementIconRecord, + NewAchievementIcon, + GeneralIconRecord, + NewGeneralIcon, }; pub use xml_parser::XmlParseError; pub use image_processor::{ImageProcessor, ImageProcessingError, ProcessedImages, OutlineConfig}; diff --git a/cursebreaker-parser/src/schema.rs b/cursebreaker-parser/src/schema.rs index 964e453..050339c 100644 --- a/cursebreaker-parser/src/schema.rs +++ b/cursebreaker-parser/src/schema.rs @@ -1,5 +1,12 @@ // @generated automatically by Diesel CLI. +diesel::table! { + achievement_icons (name) { + name -> Text, + icon -> Binary, + } +} + diesel::table! { crafting_recipe_items (recipe_id, item_id) { recipe_id -> Integer, @@ -30,6 +37,18 @@ diesel::table! { } } +diesel::table! { + general_icons (name) { + name -> Text, + original_width -> Integer, + original_height -> Integer, + icon_original -> Nullable, + icon_256 -> Nullable, + icon_64 -> Nullable, + icon_32 -> Nullable, + } +} + diesel::table! { harvestable_drops (id) { id -> Nullable, @@ -61,6 +80,14 @@ diesel::table! { } } +diesel::table! { + icons (category, name) { + category -> Text, + name -> Text, + icon -> Binary, + } +} + diesel::table! { item_stats (item_id, stat_type) { item_id -> Integer, @@ -251,11 +278,14 @@ diesel::joinable!(harvestable_drops -> items (item_id)); diesel::joinable!(item_stats -> items (item_id)); diesel::allow_tables_to_appear_in_same_query!( + achievement_icons, crafting_recipe_items, crafting_recipes, fast_travel_locations, + general_icons, harvestable_drops, harvestables, + icons, item_stats, items, loot_tables, diff --git a/cursebreaker-parser/src/types/cursebreaker/icon_models.rs b/cursebreaker-parser/src/types/cursebreaker/icon_models.rs new file mode 100644 index 0000000..7300077 --- /dev/null +++ b/cursebreaker-parser/src/types/cursebreaker/icon_models.rs @@ -0,0 +1,87 @@ +use diesel::prelude::*; +use crate::schema::{icons, achievement_icons, general_icons}; + +/// Icon category for the icons table +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconCategory { + Ability, + Buff, + Trait, + PlayerHouse, + StatIcon, +} + +impl IconCategory { + pub fn as_str(&self) -> &'static str { + match self { + IconCategory::Ability => "ability", + IconCategory::Buff => "buff", + IconCategory::Trait => "trait", + IconCategory::PlayerHouse => "player_house", + IconCategory::StatIcon => "stat_icon", + } + } +} + +/// Diesel queryable model for icons table +#[derive(Queryable, Selectable, Debug, Clone)] +#[diesel(table_name = icons)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct IconRecord { + pub category: String, + pub name: String, + pub icon: Vec, +} + +/// Diesel insertable model for icons table +#[derive(Insertable, Debug)] +#[diesel(table_name = icons)] +pub struct NewIcon<'a> { + pub category: &'a str, + pub name: &'a str, + pub icon: &'a [u8], +} + +/// Diesel queryable model for achievement_icons table +#[derive(Queryable, Selectable, Debug, Clone)] +#[diesel(table_name = achievement_icons)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct AchievementIconRecord { + pub name: String, + pub icon: Vec, +} + +/// Diesel insertable model for achievement_icons table +#[derive(Insertable, Debug)] +#[diesel(table_name = achievement_icons)] +pub struct NewAchievementIcon<'a> { + pub name: &'a str, + pub icon: &'a [u8], +} + +/// Diesel queryable model for general_icons table +#[derive(Queryable, Selectable, Debug, Clone)] +#[diesel(table_name = general_icons)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct GeneralIconRecord { + pub name: String, + pub original_width: i32, + pub original_height: i32, + pub icon_original: Option>, + pub icon_256: Option>, + pub icon_64: Option>, + pub icon_32: Option>, +} + +/// Diesel insertable model for general_icons table +#[derive(Insertable, Debug)] +#[diesel(table_name = general_icons)] +pub struct NewGeneralIcon<'a> { + pub name: &'a str, + pub original_width: i32, + pub original_height: i32, + pub icon_original: Option<&'a [u8]>, + pub icon_256: Option<&'a [u8]>, + pub icon_64: Option<&'a [u8]>, + pub icon_32: Option<&'a [u8]>, +} diff --git a/cursebreaker-parser/src/types/cursebreaker/mod.rs b/cursebreaker-parser/src/types/cursebreaker/mod.rs index c65f2c1..3df9e19 100644 --- a/cursebreaker-parser/src/types/cursebreaker/mod.rs +++ b/cursebreaker-parser/src/types/cursebreaker/mod.rs @@ -10,6 +10,7 @@ mod r#trait; mod shop; mod minimap_tile; mod minimap_models; +mod icon_models; pub use item::{ // Main types @@ -44,3 +45,12 @@ pub use r#trait::{Trait, TraitTrainer}; pub use shop::{Shop, ShopItem}; pub use minimap_tile::MinimapTile; pub use minimap_models::{MinimapTileRecord, NewMinimapTile}; +pub use icon_models::{ + IconCategory, + IconRecord, + NewIcon, + AchievementIconRecord, + NewAchievementIcon, + GeneralIconRecord, + NewGeneralIcon, +};