diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 937f263..bfec2d6 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -1553,6 +1553,7 @@ pub struct WeaponRegistry { world_catalog_identity: u64, fpv_catalog_identity: u64, + loadout_only: bool, iw5_attachments: HashMap, diff --git a/crates/asset_game/src/weapon_catalog/publication.rs b/crates/asset_game/src/weapon_catalog/publication.rs index 135a5ff..82783e6 100644 --- a/crates/asset_game/src/weapon_catalog/publication.rs +++ b/crates/asset_game/src/weapon_catalog/publication.rs @@ -11,6 +11,11 @@ impl WeaponCatalog { } impl WeaponBuild { + pub fn publish_for_loadout(mut self) -> WeaponRegistry { + self.registry.loadout_only = true; + self.publish() + } + pub fn publish(self) -> WeaponRegistry { let mut registry = self.registry; registry.revision = mint_weapon_revision(); @@ -451,6 +456,7 @@ impl WeaponBuild { rows, world_catalog_identity: 0, fpv_catalog_identity: 0, + loadout_only: false, iw5_attachments, configurations: HashMap::new(), by_name: index_of, diff --git a/crates/asset_game/src/weapon_catalog/registry.rs b/crates/asset_game/src/weapon_catalog/registry.rs index 59d2dcf..4ec6252 100644 --- a/crates/asset_game/src/weapon_catalog/registry.rs +++ b/crates/asset_game/src/weapon_catalog/registry.rs @@ -303,7 +303,7 @@ impl WeaponRegistry { }); } } - if row.gun_xmodel_edge.is_bound() { + if !self.loadout_only && row.gun_xmodel_edge.is_bound() { for (side, hands) in row.fpv_soldiers.iter().enumerate() { if hands.as_ref().is_none_or(|hands| hands.is_err()) { gaps.push(WeaponDependencyGap { diff --git a/crates/asset_material/src/lib.rs b/crates/asset_material/src/lib.rs index 1dbd062..13e751e 100644 --- a/crates/asset_material/src/lib.rs +++ b/crates/asset_material/src/lib.rs @@ -11,6 +11,10 @@ pub mod iw5_tech_map; pub mod material_catalog; pub mod material_draw; pub mod material_images; +mod ui_material_images; +pub use ui_material_images::{ + retain_ui_material_images, retain_ui_preview_fallback, ui_material_image, ui_preview_fallback, +}; pub mod t5_code_remap; pub mod t5_tech_map; pub mod t6_techset; diff --git a/crates/asset_material/src/material_images.rs b/crates/asset_material/src/material_images.rs index 7d322ff..51f4a71 100644 --- a/crates/asset_material/src/material_images.rs +++ b/crates/asset_material/src/material_images.rs @@ -59,25 +59,20 @@ fn texture_semantic_decodes_as_normal(semantic: u8) -> Option { } pub fn decode_material_color_maps( - zone_ff: &Path, + trees: &asset_transport::NamespaceTrees, catalog: &mut MaterialDefinitions, stage: &StageHandle, pool: &TaskPool, ) -> Result { - let main = game_main_for_zone(zone_ff)?; - let index = IwdIndex::open(&main)?; + let work = requested_color_map_slots(catalog); + let (decoded, archives) = + decode_requests_from_trees(&catalog.images, trees, &work, stage, pool)?; let mut stats = MaterialImageStats { - archives: index.archive_count(), + requested: work.len(), + archives, ..Default::default() }; - let work = requested_color_map_slots(catalog); - stats.requested = work.len(); - stage.set_total(work.len() as u64); - - let images = &catalog.images; - let decoded = decode_requests_in_parallel(images, index.as_ref(), &work, stage, pool); - for outcome in decoded { match outcome { ImageOutcome::Decoded { @@ -155,7 +150,7 @@ fn requested_color_map_slots(catalog: &MaterialDefinitions) -> Vec } pub fn decode_images_for_keys( - zone_ff: &Path, + trees: &asset_transport::NamespaceTrees, catalog: &mut MaterialDefinitions, keys: impl IntoIterator, stage: &StageHandle, @@ -165,11 +160,7 @@ pub fn decode_images_for_keys( if work.is_empty() { return Ok(0); } - let main = game_main_for_zone(zone_ff)?; - let index = IwdIndex::open(&main)?; - stage.set_total(work.len() as u64); - let images = &catalog.images; - let decoded = decode_requests_in_parallel(images, index.as_ref(), &work, stage, pool); + let (decoded, _) = decode_requests_from_trees(&catalog.images, trees, &work, stage, pool)?; let mut n = 0usize; for outcome in decoded { if let ImageOutcome::Decoded { @@ -253,7 +244,7 @@ fn requested_keyed_image_slots( } pub fn decode_catalog_images_from_iwd( - zone_ff: &Path, + trees: &asset_transport::NamespaceTrees, catalog: &mut MaterialDefinitions, images: impl IntoIterator, stage: &StageHandle, @@ -277,11 +268,7 @@ pub fn decode_catalog_images_from_iwd( if work.is_empty() { return Ok(0); } - let main = game_main_for_zone(zone_ff)?; - let index = IwdIndex::open(&main)?; - stage.set_total(work.len() as u64); - let catalog_images = &catalog.images; - let decoded = decode_requests_in_parallel(catalog_images, index.as_ref(), &work, stage, pool); + let (decoded, _) = decode_requests_from_trees(&catalog.images, trees, &work, stage, pool)?; let mut n = 0usize; for outcome in decoded { if let ImageOutcome::Decoded { @@ -368,6 +355,55 @@ enum ImageOutcome { type ImageRequest = (usize, (u8, bool, bool, bool)); +fn decode_requests_from_trees( + images: &[AuthoredImage], + trees: &asset_transport::NamespaceTrees, + work: &[ImageRequest], + stage: &StageHandle, + pool: &TaskPool, +) -> Result<(Vec, usize), String> { + stage.set_total(work.len() as u64); + let mut decoded = Vec::with_capacity(work.len()); + let mut archives = 0; + for &(slot, _) in work { + if images.get(slot).is_none() { + decoded.push(ImageOutcome::Missing { + gap: format!("catalog image index {slot} is out of bounds"), + }); + stage.advance(1); + } + } + for namespace in [ + crate::AssetNamespace::Iw4, + crate::AssetNamespace::T5, + crate::AssetNamespace::Iw5, + crate::AssetNamespace::T6, + ] { + let requests: Vec<_> = work + .iter() + .copied() + .filter(|(slot, _)| { + images + .get(*slot) + .is_some_and(|image| image.namespace == namespace) + }) + .collect(); + if requests.is_empty() { + continue; + } + let index = match trees.main_for(namespace) { + Some(main) => IwdIndex::open(main) + .map_err(|error| format!("{} image archives: {error}", namespace.as_str()))?, + None => Arc::new(IwdIndex::empty()), + }; + archives += index.archive_count(); + decoded.extend(decode_requests_in_parallel( + images, &index, &requests, stage, pool, + )); + } + Ok((decoded, archives)) +} + /// Decode this work on the pool the caller names. /// /// The pool is a parameter because the right one is the loader's, and @@ -808,7 +844,18 @@ pub fn decode_ui_image( let Some(main) = trees.main_for(key.namespace) else { return Ok(None); }; - return decode_ui_image_from_main(main, &key.name); + if let Some(image) = crate::ui_material_image(key.namespace, &key.name) + && let Some(decoded) = decode_ui_image_from_main(main, &image)? + { + return Ok(Some(decoded)); + } + if let Some(decoded) = decode_ui_image_from_main(main, &key.name)? { + return Ok(Some(decoded)); + } + if let Some(image) = crate::ui_preview_fallback(key.namespace, &key.name) { + return decode_ui_image_from_main(main, &image); + } + return Ok(None); } let name = crate::AssetRef::bare_name(image_name); for main in ui_decode_mains(games_root) { diff --git a/crates/asset_material/src/ui_material_images.rs b/crates/asset_material/src/ui_material_images.rs new file mode 100644 index 0000000..d180d6c --- /dev/null +++ b/crates/asset_material/src/ui_material_images.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +use asset_core::AssetNamespace; + +static IMAGES: LazyLock>> = + LazyLock::new(Default::default); +static PREVIEW_FALLBACKS: LazyLock>> = + LazyLock::new(Default::default); + +pub fn retain_ui_preview_fallback(namespace: AssetNamespace, material: &str, image: &str) { + PREVIEW_FALLBACKS + .write() + .unwrap_or_else(|poison| poison.into_inner()) + .insert((namespace, material.to_ascii_lowercase()), image.to_owned()); +} + +pub fn ui_preview_fallback(namespace: AssetNamespace, material: &str) -> Option { + PREVIEW_FALLBACKS + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .get(&( + namespace, + asset_core::AssetRef::bare_name(material).to_ascii_lowercase(), + )) + .cloned() +} + +pub fn retain_ui_material_images(materials: &crate::MaterialCatalog) { + let mut images = IMAGES.write().unwrap_or_else(|poison| poison.into_inner()); + for material in &materials.materials { + if let Some(image) = materials.hud_image_name(material) { + images.insert( + ( + material.namespace, + material.name.as_str().to_ascii_lowercase(), + ), + image.to_owned(), + ); + } + } +} + +pub fn ui_material_image(namespace: AssetNamespace, material: &str) -> Option { + IMAGES + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .get(&( + namespace, + asset_core::AssetRef::bare_name(material).to_ascii_lowercase(), + )) + .cloned() +} diff --git a/crates/assets/src/lane/iw4.rs b/crates/assets/src/lane/iw4.rs index c7ddba5..2a4ff6e 100644 --- a/crates/assets/src/lane/iw4.rs +++ b/crates/assets/src/lane/iw4.rs @@ -1237,7 +1237,9 @@ fn decode_map_material_images( glass_names: Vec, ) -> Vec { let mut report = Vec::new(); - match decode_material_color_maps(path, catalog, &stage, crate::session_load::load_pool()) { + let mut trees = asset_transport::NamespaceTrees::default(); + trees.adopt_zone(path); + match decode_material_color_maps(&trees, catalog, &stage, crate::session_load::load_pool()) { Ok(stats) => { report.push(format!( "IWD color/normal maps: {}/{} decoded, {} missing, {} unsupported from {} archives", @@ -1258,7 +1260,7 @@ fn decode_map_material_images( name: name.clone(), })); match asset_material::material_images::decode_images_for_keys( - path, + &trees, catalog, keys, &stage, diff --git a/crates/assets/src/lane/t5.rs b/crates/assets/src/lane/t5.rs index fc932da..2a9fd9f 100644 --- a/crates/assets/src/lane/t5.rs +++ b/crates/assets/src/lane/t5.rs @@ -238,8 +238,10 @@ impl ZoneLane for T5Lane { let map_xmodels = std::mem::take(&mut sink.map_xmodels); let bodies = std::mem::take(&mut sink.bodies); let fpv_meshes = std::mem::take(&mut sink.fpv_meshes); + let mut trees = asset_transport::NamespaceTrees::default(); + trees.adopt_zone(path); match decode_material_color_maps( - path, + &trees, &mut materials, &stage, crate::session_load::load_pool(), diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index 80362f7..8460170 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -235,7 +235,15 @@ pub async fn load_shell_common(games: asset_transport::GamesRoot) -> ShellCommon let mut report = Vec::new(); let key = CommonKey::shell(&games, &mut report); let (common, reach) = ensure_common(key).await; - let weapons = common.products.weapons.clone().publish(); + let weapons = common.products.weapons.clone().publish_for_loadout(); + for family in weapons.weapon_families().families() { + if let Some(id) = family.base + && let Some(image) = weapons.hud_icon_image_of(id) + && !family.image.is_empty() + { + asset_material::retain_ui_preview_fallback(family.key.namespace, &family.image, image); + } + } report.push(format!( "CAC: {reach} common set {}; weapons={} (iw4={} iw5={} t5={} t6={}) tables={}", common.key, @@ -774,6 +782,8 @@ async fn prepare_common(key: CommonKey) -> Arc { material_seed.mark_images_common_owned(); let mut iw5_materials = iw5_materials; iw5_materials.mark_images_common_owned(); + asset_material::retain_ui_material_images(&material_seed); + asset_material::retain_ui_material_images(&iw5_materials); let set = Arc::new(CommonSet { id: NEXT_COMMON_PROFILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), key, diff --git a/crates/assets/src/session_load/match_walk.rs b/crates/assets/src/session_load/match_walk.rs index c68d669..d89a62b 100644 --- a/crates/assets/src/session_load/match_walk.rs +++ b/crates/assets/src/session_load/match_walk.rs @@ -7,6 +7,15 @@ pub(super) async fn walk_prepared_match( common_mp: Result, progress: LoadProgress, ) -> (MatchLoadOutcome, Option>) { + let mut image_trees = games_root_from_env() + .map(|root| asset_transport::NamespaceTrees::discover(&root)) + .unwrap_or_default(); + if let Ok(path) = &common_mp { + image_trees.adopt_zone(path); + } + if let Ok(path) = &zone_ff { + image_trees.adopt_zone(path); + } let zone_name = zone_ff .as_ref() .ok() @@ -174,6 +183,7 @@ pub(super) async fn walk_prepared_match( } = loaded; world.source_namespace = map_namespace; report.append(&mut common_report); + report.extend(image_trees.report_lines()); if facts.team_settings.allies.is_none() && facts.team_settings.axis.is_none() { if let Some(name) = facts.t5_teamset.as_ref() { @@ -494,16 +504,28 @@ pub(super) async fn walk_prepared_match( )); merge_image_batch(&mut global, label, batch, job, &mut report); } - if let Ok(path) = &zone_ff { + if zone_ff.is_ok() { let stage = progress.begin_scoped(StageId::Images, "merged", None); - let decoded = - asset_material::decode_material_color_maps(path, &mut global, &stage, load_pool()); + let decoded = asset_material::decode_material_color_maps( + &image_trees, + &mut global, + &stage, + load_pool(), + ); stage.finish_from(&decoded); match decoded { - Ok(stats) => report.push(format!( - "merged material images: {}/{} decoded, {} missing, {} unsupported", - stats.decoded, stats.requested, stats.missing, stats.unsupported - )), + Ok(stats) => { + report.push(format!( + "merged material images: {}/{} decoded, {} missing, {} unsupported", + stats.decoded, stats.requested, stats.missing, stats.unsupported + )); + if let Some(gap) = stats.first_gap { + report.push(format!("merged material image missing: {gap}")); + } + if let Some(gap) = stats.first_unsupported { + report.push(format!("merged material image unsupported: {gap}")); + } + } Err(error) => report.push(format!("merged material images gap: {error}")), } } @@ -513,7 +535,7 @@ pub(super) async fn walk_prepared_match( &mut global, &common_light_defs, map_namespace, - &zone_ff, + &image_trees, &progress, &mut report, ); @@ -524,10 +546,10 @@ pub(super) async fn walk_prepared_match( "in-zone builtin images: decoded {builtins} leftover $ 2D loadDefs after absorb" )); } - if let Ok(path) = &zone_ff { + if zone_ff.is_ok() { let stage = progress.begin_scoped(StageId::Images, "tracers", None); let decoded = asset_material::material_images::decode_images_for_keys( - path, + &image_trees, &mut global, common_tracers.material_keys(), &stage, @@ -648,7 +670,7 @@ pub(super) async fn walk_prepared_match( "fx color maps handoff: n=0 bytes=0 (Bound GPU bind at spawn; no CPU clone sidecar; stub_aliases=0)" .into(), ); - decode_fx_colour_maps(&world.fx, &mut global, &zone_ff, &progress, &mut report); + decode_fx_colour_maps(&world.fx, &mut global, &image_trees, &progress, &mut report); if world.impact_fx.is_none() { world.impact_fx = if map_namespace == Some(asset_core::AssetNamespace::T5) { t5_impact_fx.or(common_impact) @@ -774,7 +796,7 @@ fn resolve_world_lights( global: &mut asset_material::MaterialDefinitions, common_light_defs: &[asset_world::CapturedLightDef], map_namespace: Option, - zone_ff: &Result, + image_trees: &asset_transport::NamespaceTrees, progress: &LoadProgress, report: &mut Vec, ) -> Option { @@ -840,7 +862,7 @@ fn resolve_world_lights( .map(|def| def.attenuation_image_name.as_deref()) .collect::>(), )); - if let Ok(path) = &zone_ff { + { let mut requested: Vec<(usize, u8)> = draw .primary_lights .iter() @@ -856,7 +878,7 @@ fn resolve_world_lights( let want = want.len(); let stage = progress.begin_scoped(StageId::Images, "attenuation", None); let decoded = asset_material::decode_catalog_images_from_iwd( - path, + image_trees, global, requested, &stage, @@ -1030,7 +1052,7 @@ fn link_materials_after_absorb( fn decode_fx_colour_maps( fx: &FxCatalog, global: &mut asset_material::MaterialDefinitions, - zone_ff: &Result, + image_trees: &asset_transport::NamespaceTrees, progress: &LoadProgress, report: &mut Vec, ) { @@ -1048,20 +1070,18 @@ fn decode_fx_colour_maps( }) .collect(); if !missing.is_empty() { - if let Ok(path) = zone_ff { - let stage = progress.begin_scoped(StageId::Images, "fx_elem", None); - let decoded = asset_material::material_images::decode_images_for_keys( - path, - global, - missing, - &stage, - load_pool(), - ); - stage.finish_from(&decoded); - match decoded { - Ok(n) => report.push(format!("fx elem 2d images after absorb: {n} decoded")), - Err(error) => report.push(format!("fx elem 2d images after absorb: {error}")), - } + let stage = progress.begin_scoped(StageId::Images, "fx_elem", None); + let decoded = asset_material::material_images::decode_images_for_keys( + image_trees, + global, + missing, + &stage, + load_pool(), + ); + stage.finish_from(&decoded); + match decoded { + Ok(n) => report.push(format!("fx elem 2d images after absorb: {n} decoded")), + Err(error) => report.push(format!("fx elem 2d images after absorb: {error}")), } } let nocolor: Vec<(usize, String)> = fx diff --git a/crates/console/src/class_menu.rs b/crates/console/src/class_menu.rs index 1a6473d..21cc766 100644 --- a/crates/console/src/class_menu.rs +++ b/crates/console/src/class_menu.rs @@ -1,7 +1,7 @@ use asset_core::AssetNamespace; use bevy::prelude::*; use frame::{UiMenuDvars, UiMenuRequest}; -use ui::classes::display::{category_label, label, localized, preview_image}; +use ui::classes::display::{category_label, class_name, label, localized, preview_image}; use ui::{ClassEditRow, ClassLoadoutCatalog, ClassPickerFolder, SessionClassStore}; use crate::{CommandSpec, ConsoleCommand, ConsoleRegistry}; @@ -197,8 +197,10 @@ pub(crate) fn route( mut menus: MessageWriter, mut echo: crate::feature_dispatch::ConsoleEcho, ) { - let (loc, prepared_loc) = loc; - let camo_loc = prepared_loc.as_ref().map_or(&*loc, |strings| &strings.0); + let (shell_loc, prepared_loc) = loc; + let loc = prepared_loc + .as_ref() + .map_or(&*shell_loc, |strings| &strings.0); for command in events .read() .filter(|command| command.name.starts_with("ui_class_")) @@ -383,7 +385,7 @@ pub(crate) fn route( "menu: class {} {} camouflage = {}", selected + 1, row.label(), - localized_camo_label(&catalog, camo_loc, slot.row_value(row), camo) + localized_camo_label(&catalog, loc, slot.row_value(row), camo) )); } else { echo.write(format!( @@ -452,7 +454,7 @@ pub(crate) fn route( .collect(); if name.is_empty() { return Err(localized( - &loc, + loc, "MENU_IWNET_CREATE_BADNAME", "Enter a class name", )); @@ -486,7 +488,7 @@ pub(crate) fn route( store .slots .get(at) - .map(|slot| slot.name.clone()) + .map(|slot| class_name(&slot.name)) .unwrap_or_default(), ); } @@ -496,13 +498,42 @@ pub(crate) fn route( { dvars.set("ui_class_status", format!("Class unavailable: {reason}")); } - dvars.set("ui_class_title", &slot.name); + dvars.set("ui_class_title", class_name(&slot.name)); dvars.set("ui_class_saved_name", &slot.name); dvars.set("ui_class_index", store.selected.to_string()); + for (at, row, weapon) in [ + (0, ClassEditRow::Primary, &slot.primary), + (1, ClassEditRow::Secondary, &slot.secondary), + ] { + let caption = localized(loc, row.loc_key().trim_start_matches('@'), row.label()); + for (kind, key, fallback, available) in [ + ( + "attachments", + "MENU_ATTACHMENTS_CAPS", + "Attachments", + !catalog.attachments(row, weapon).is_empty(), + ), + ( + "camo", + "MENU_CAMO_CAPS", + "Camouflage", + !camo_names(&catalog, weapon).is_empty(), + ), + ] { + dvars.set( + &format!("ui_class_has_{kind}_{at}"), + if available { "1" } else { "0" }, + ); + dvars.set( + &format!("ui_class_{kind}_button_{at}"), + format!("{caption}: {}", localized(loc, key, fallback)), + ); + } + } for row in ClassEditRow::ALL { dvars.set( &format!("ui_class_value_{}", row.as_u8()), - label(slot.row_value(row), &catalog, &loc), + label(slot.row_value(row), &catalog, loc), ); dvars.set( &format!("ui_class_image_{}", row.as_u8()), @@ -554,14 +585,14 @@ pub(crate) fn route( if key.is_empty() { String::new() } else { - label(&key, &catalog, &loc) + label(&key, &catalog, loc) }, ); } dvars.set( &format!("ui_class_{name}_attachments"), if selected.is_empty() { - label("", &catalog, &loc) + label("", &catalog, loc) } else { selected .iter() @@ -576,7 +607,7 @@ pub(crate) fn route( } ), &catalog, - &loc, + loc, ) }) .collect::>() @@ -616,14 +647,15 @@ pub(crate) fn route( }; dvars.set("ui_class_picker_depth", picker_depth.to_string()); let mut caption = state.row.map_or_else( - || localized(&loc, "MENU_CLASSES", "Classes"), - |row| localized(&loc, row.loc_key(), row.label()), + || localized(loc, "MENU_CLASSES", "Classes"), + |row| localized(loc, row.loc_key(), row.label()), ); if state.attachments { caption.push_str(" / "); - caption.push_str(&localized(&loc, "MENU_ATTACHMENTS_CAPS", "Attachments")); + caption.push_str(&localized(loc, "MENU_ATTACHMENTS_CAPS", "Attachments")); } else if state.camo { - caption.push_str(" / Camouflage"); + caption.push_str(" / "); + caption.push_str(&localized(loc, "MENU_CAMO_CAPS", "Camouflage")); } dvars.set("ui_class_caption", caption); dvars.set( @@ -637,7 +669,7 @@ pub(crate) fn route( "ui_class_category_label", state .folder - .map(|folder| category_label(folder, &loc)) + .map(|folder| category_label(folder, loc)) .unwrap_or_default(), ); state.page = state.page.min(choices.len().saturating_sub(1) / PAGE_SIZE); @@ -653,7 +685,7 @@ pub(crate) fn route( &format!("ui_class_category_{at}"), folders .get(at) - .map(|folder| category_label(*folder, &loc)) + .map(|folder| category_label(*folder, loc)) .unwrap_or_default(), ); dvars.set( @@ -671,7 +703,7 @@ pub(crate) fn route( if selected { "* " } else { "" }, localized_camo_label( &catalog, - camo_loc, + loc, slot.zip(state.row) .map_or("", |(slot, row)| slot.row_value(row)), value @@ -704,26 +736,26 @@ pub(crate) fn route( format!( "{}{}", if selected { "* " } else { "" }, - label(&key, &catalog, &loc) + label(&key, &catalog, loc) ) }) .unwrap_or_default(), ); } let title = if state.camo { - "Camouflage".to_owned() + localized(loc, "MENU_CAMO_CAPS", "Camouflage") } else if state.attachments { - localized(&loc, "MENU_ATTACHMENTS_CAPS", "Attachments") + localized(loc, "MENU_ATTACHMENTS_CAPS", "Attachments") } else if let Some(folder) = state.folder { format!( "{} / {}", folder.namespace.as_str().to_uppercase(), - category_label(folder, &loc) + category_label(folder, loc) ) } else { state.row.map_or_else( - || localized(&loc, "MENU_WEAPON_CLASSES_CAPS", "Classes"), - |row| localized(&loc, row.loc_key(), row.label()), + || localized(loc, "MENU_WEAPON_CLASSES_CAPS", "Classes"), + |row| localized(loc, row.loc_key(), row.label()), ) }; dvars.set("ui_class_picker_title", title); @@ -786,8 +818,22 @@ pub(crate) fn route( ); dvars.set( "ui_class_preview_title", - preview - .map(|(_, p)| localized(&loc, &p.name_key, &p.reference)) + choices + .get(state.page * PAGE_SIZE + state.hover) + .map(|key| { + let weapon = store + .slots + .get(store.selected) + .zip(state.row) + .map_or("", |(slot, row)| slot.row_value(row)); + if state.camo { + localized_camo_label(&catalog, loc, weapon, key) + } else if state.attachments && !key.is_empty() { + label(&format!("{weapon}+{key}"), &catalog, loc) + } else { + label(key, &catalog, loc) + } + }) .unwrap_or_default(), ); for (at, bar) in asset_game::CacStatBar::ALL.into_iter().enumerate() { diff --git a/crates/hud/src/images.rs b/crates/hud/src/images.rs index 45c7b9b..9b98bf7 100644 --- a/crates/hud/src/images.rs +++ b/crates/hud/src/images.rs @@ -510,17 +510,22 @@ impl HudImages { return Some((width, height, rgba.as_ref().clone())); } let main = self.trees.main_for(ns)?; - let mapped = (ns == HUD_CHROME_NAMESPACE) - .then(|| { - self.material_images - .get(&cache_key(name)) - .map(String::as_str) - }) - .flatten(); + let authored = asset_material::ui_material_image(ns, name); + let fallback = asset_material::ui_preview_fallback(ns, name); + let mapped = authored.as_deref().or_else(|| { + (ns == HUD_CHROME_NAMESPACE) + .then(|| { + self.material_images + .get(&cache_key(name)) + .map(String::as_str) + }) + .flatten() + }); for image_name in mapped .filter(|image| *image != name) .into_iter() .chain(std::iter::once(name)) + .chain(fallback.as_deref()) { match asset_material::decode_ui_image_from_main(main, image_name) { Ok(Some(image)) => return Some(image), diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index c046ba5..3de65a6 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -460,7 +460,18 @@ fn offhand_stand_in(world: &mut World, weapon: u32) -> Option { let stand_in = frame .weapon_setup(weapon) .filter(|setup| setup.realm != realm) - .and_then(|setup| setup.stand_in.as_deref()); + .and_then(|setup| { + setup.stand_in.as_deref().or_else(|| { + (realm == crate::script::Realm::Iw4).then(|| match setup.base.as_str() { + "willy_pete" => "smoke_grenade_mp", + "tabun_gas" => "concussion_grenade_mp", + "sticky_grenade" => "semtex_mp", + "hatchet" => "throwingknife_mp", + "satchel_charge" | "c4death" => "c4_mp", + _ => frame.weapon_script_name(weapon), + }) + }) + }); frame .weapon_index_by_script_name(stand_in.unwrap_or(frame.weapon_script_name(weapon))) .filter(|&named| weapon != 0 && named != weapon) diff --git a/crates/ui/menus/classes.json b/crates/ui/menus/classes.json index 5a42e2d..8be8ea6 100644 --- a/crates/ui/menus/classes.json +++ b/crates/ui/menus/classes.json @@ -367,7 +367,8 @@ "index": 57, "fields": { "text_exp": "op 38 s:75695f636c6173735f646573635f34 op 1", - "vis_exp": "1" + "vis_exp": "1", + "text_scale": 0.25 } }, { @@ -381,7 +382,8 @@ "index": 63, "fields": { "text_exp": "op 38 s:75695f636c6173735f646573635f35 op 1", - "vis_exp": "1" + "vis_exp": "1", + "text_scale": 0.25 } }, { @@ -395,7 +397,8 @@ "index": 69, "fields": { "text_exp": "op 38 s:75695f636c6173735f646573635f36 op 1", - "vis_exp": "1" + "vis_exp": "1", + "text_scale": 0.25 } }, { @@ -409,14 +412,16 @@ "index": 78, "fields": { "text_exp": "op 38 s:75695f636c6173735f76616c75655f32 op 1", - "vis_exp": "1" + "vis_exp": "1", + "text_scale": 0.3 } }, { "index": 80, "fields": { "text_exp": "op 38 s:75695f636c6173735f76616c75655f33 op 1", - "vis_exp": "1" + "vis_exp": "1", + "text_scale": 0.3 } }, { @@ -624,6 +629,206 @@ "text_align_y": 12, "static_flags": 9437184, "vis_exp": "op 7 op 57 s:636c6173735f72656e616d65 op 1 op 14 op 7 op 57 s:636c6173735f7069636b6572 op 1" + }, + { + "name": "class_attachments_0", + "item_type": 1, + "text_exp": "op 38 s:75695f636c6173735f6174746163686d656e74735f627574746f6e5f30 op 1", + "rect": { + "x": -64, + "y": 238, + "w": 336, + "h": 20, + "horz_align": 1, + "vert_align": 1 + }, + "background": ",menu_button_selection_bar", + "style": 1, + "fore_color": [ + 1, + 1, + 1, + 1 + ], + "font_enum": 3, + "text_scale": 0.3, + "text_align_mode": 6, + "text_align_x": -60, + "vis_exp": "op 57 s:636c6173735f656469746f72 op 1 op 14 op 35 s:75695f636c6173735f6861735f6174746163686d656e74735f30 op 1", + "handlers": { + "action": [ + { + "SetLocalVar": { + "kind": 5, + "name": "ui_class_anchor", + "expr": "op 32 op 150 op 17 140 op 1" + } + }, + { + "Script": "play mouse_click; exec \"ui_class_attachments 0\";" + } + ], + "focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 1;" + } + ], + "leave_focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 0;" + } + ] + } + }, + { + "name": "class_camo_0", + "item_type": 1, + "text_exp": "op 38 s:75695f636c6173735f63616d6f5f627574746f6e5f30 op 1", + "rect": { + "x": -64, + "y": 258, + "w": 336, + "h": 20, + "horz_align": 1, + "vert_align": 1 + }, + "background": ",menu_button_selection_bar", + "style": 1, + "fore_color": [ + 1, + 1, + 1, + 1 + ], + "font_enum": 3, + "text_scale": 0.3, + "text_align_mode": 6, + "text_align_x": -60, + "vis_exp": "op 57 s:636c6173735f656469746f72 op 1 op 14 op 35 s:75695f636c6173735f6861735f63616d6f5f30 op 1", + "handlers": { + "action": [ + { + "SetLocalVar": { + "kind": 5, + "name": "ui_class_anchor", + "expr": "op 32 op 150 op 17 140 op 1" + } + }, + { + "Script": "play mouse_click; exec \"ui_class_camo 0\";" + } + ], + "focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 1;" + } + ], + "leave_focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 0;" + } + ] + } + }, + { + "name": "class_attachments_1", + "item_type": 1, + "text_exp": "op 38 s:75695f636c6173735f6174746163686d656e74735f627574746f6e5f31 op 1", + "rect": { + "x": -64, + "y": 278, + "w": 336, + "h": 20, + "horz_align": 1, + "vert_align": 1 + }, + "background": ",menu_button_selection_bar", + "style": 1, + "fore_color": [ + 1, + 1, + 1, + 1 + ], + "font_enum": 3, + "text_scale": 0.3, + "text_align_mode": 6, + "text_align_x": -60, + "vis_exp": "op 57 s:636c6173735f656469746f72 op 1 op 14 op 35 s:75695f636c6173735f6861735f6174746163686d656e74735f31 op 1", + "handlers": { + "action": [ + { + "SetLocalVar": { + "kind": 5, + "name": "ui_class_anchor", + "expr": "op 32 op 150 op 17 140 op 1" + } + }, + { + "Script": "play mouse_click; exec \"ui_class_attachments 1\";" + } + ], + "focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 1;" + } + ], + "leave_focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 0;" + } + ] + } + }, + { + "name": "class_camo_1", + "item_type": 1, + "text_exp": "op 38 s:75695f636c6173735f63616d6f5f627574746f6e5f31 op 1", + "rect": { + "x": -64, + "y": 298, + "w": 336, + "h": 20, + "horz_align": 1, + "vert_align": 1 + }, + "background": ",menu_button_selection_bar", + "style": 1, + "fore_color": [ + 1, + 1, + 1, + 1 + ], + "font_enum": 3, + "text_scale": 0.3, + "text_align_mode": 6, + "text_align_x": -60, + "vis_exp": "op 57 s:636c6173735f656469746f72 op 1 op 14 op 35 s:75695f636c6173735f6861735f63616d6f5f31 op 1", + "handlers": { + "action": [ + { + "SetLocalVar": { + "kind": 5, + "name": "ui_class_anchor", + "expr": "op 32 op 150 op 17 140 op 1" + } + }, + { + "Script": "play mouse_click; exec \"ui_class_camo 1\";" + } + ], + "focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 1;" + } + ], + "leave_focus": [ + { + "Script": "setItemColor self backcolor 0 0 0 0;" + } + ] + } } ] }, @@ -3480,7 +3685,12 @@ "fields": { "vis_exp": "op 38 s:75695f636c6173735f70726576696577 op 1 op 13 s:", "material_exp": "op 38 s:75695f636c6173735f70726576696577 op 1", - "float_exp": [[2, "op 35 s:75695f636c6173735f707265766965775f7769647468 op 1"]] + "float_exp": [ + [ + 2, + "op 35 s:75695f636c6173735f707265766965775f7769647468 op 1" + ] + ] } }, { diff --git a/crates/ui/src/classes/display.rs b/crates/ui/src/classes/display.rs index 97f6396..3d81703 100644 --- a/crates/ui/src/classes/display.rs +++ b/crates/ui/src/classes/display.rs @@ -6,6 +6,17 @@ pub fn localized(loc: &asset_game::LocalizeCatalog, key: &str, fallback: &str) - .to_owned() } +pub fn class_name(name: &str) -> String { + if frame::showcase_classes() + .iter() + .any(|preset| preset.name == name) + { + name.replace('_', " ").to_ascii_uppercase() + } else { + name.to_owned() + } +} + pub fn category_label(folder: ClassPickerFolder, loc: &asset_game::LocalizeCatalog) -> String { folder.category.map_or_else( || localized(loc, "MENU_EQUIPMENT_CAPS", "Equipment"), @@ -32,7 +43,13 @@ pub fn label( .unwrap_or(presentation.fallback_label()) .to_owned(); } - key.to_owned() + if key.contains('+') { + asset_game::CacItemPresentation::prepare_attachment(key) + } else { + asset_game::CacItemPresentation::prepare_weapon(key) + } + .fallback_label() + .to_owned() } pub fn preview_image(key: &str, catalog: &ClassLoadoutCatalog) -> String { diff --git a/crates/ui/src/classes/equip_txn.rs b/crates/ui/src/classes/equip_txn.rs index 79e6464..145e850 100644 --- a/crates/ui/src/classes/equip_txn.rs +++ b/crates/ui/src/classes/equip_txn.rs @@ -230,6 +230,7 @@ fn consume_class_select_handoff( mut catalog: ResMut, mut store: ResMut, weapons: Option>, + menus: Res, ) { if !handoff.pending { return; @@ -237,12 +238,25 @@ fn consume_class_select_handoff( let Some(weapons) = weapons else { return; }; - if catalog.resolver.0.is_none() { - *catalog = crate::ClassLoadoutCatalog::from_weapon_registry(weapons.registry().clone()); - } else { - catalog.resolver.0 = Some(weapons.registry().clone()); + let revision = catalog.revision.wrapping_add(1); + let mut updated = crate::ClassLoadoutCatalog::from_weapon_registry(weapons.registry().clone()); + if let Some(table) = menus.string_table("mp/perkTable.csv") { + updated = updated.with_perk_table(table); } - catalog.revision = catalog.revision.wrapping_add(1); + updated.perks = catalog.perks.clone(); + updated.deathstreak = catalog.deathstreak.clone(); + for (key, preview) in &catalog.previews { + if asset_core::AssetKey::parse(key).is_err() { + updated.previews.insert(key.clone(), preview.clone()); + } else if let Some(current) = updated.previews.get_mut(key) { + if current.desc_key.is_empty() { + current.desc_key.clone_from(&preview.desc_key); + } + current.bars.clone_from(&preview.bars); + } + } + *catalog = updated; + catalog.revision = revision; catalog.primary = std::mem::take(&mut handoff.primary); catalog.secondary = std::mem::take(&mut handoff.secondary); catalog.lethal = std::mem::take(&mut handoff.lethal); diff --git a/crates/ui/src/classes/setup.rs b/crates/ui/src/classes/setup.rs index 8aab53a..a2b1310 100644 --- a/crates/ui/src/classes/setup.rs +++ b/crates/ui/src/classes/setup.rs @@ -143,8 +143,10 @@ impl Default for ClassLoadoutCatalog { impl ClassLoadoutCatalog { pub fn from_weapon_registry(registry: std::sync::Arc) -> Self { let families = registry.weapon_families(); + let offered: std::collections::HashSet<_> = + families.offered().map(|family| &family.key).collect(); let mut catalog = Self::default(); - for family in families.offered() { + for family in families.families() { let key = family.key.asset_key(); catalog .presentation @@ -183,6 +185,9 @@ impl ClassLoadoutCatalog { ), ); } + if !offered.contains(&family.key) { + continue; + } let offer = frame::CacWeaponOffer { key, item_group: Some(family.item_group.clone()), diff --git a/docs/WEAPONS.md b/docs/WEAPONS.md index 518d440..2ac0e17 100644 --- a/docs/WEAPONS.md +++ b/docs/WEAPONS.md @@ -5,8 +5,8 @@ those operations; `configuration/{iw4,iw5,t5,t6}` own source selection rules. `WeaponFamilies` normalizes selections and checks attachments and host limits. -Resolution and UI toggles return a private registry-issued `WeaponHandle` plus -read-only canonical selection. Console/UI use this resolver. Publication iteration +Frontend loadout publication defers map-selected hands admission; matches validate soldiers and skeletons. Saved class previews include unavailable captured families. +Resolution and UI toggles return a private registry-issued `WeaponHandle` plus read-only canonical selection. Console/UI use this resolver. Publication iteration includes the last valid row; unarmed, unknown and unsupported remain distinct. `WeaponRegistry::bind` rejects foreign revisions before row access. Exact clones @@ -47,4 +47,4 @@ Body/tree/script/clip replacement resets animation and preserves corpse occupati Publication compiles appearance plans once; `SelectedWeaponAppearance` binds models, overrides and UI metadata. Raw hand/camouflage data stays on `WeaponBuild`; runtime consumers select appearances. -Native previews are optional; known IW4 model choices compile their own preview keys. +The editor reopens attachments/camouflage; previews retain namespace material/image bindings and fall back to authored HUD icons. IW4 scripts bridge foreign smoke, gas, sticky, knife and C4 equipment names.