diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 271f1b12a..efc1c6cac 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -311,10 +311,19 @@ const ROCK_SNAP: f32 = 1.0; /// larger source is crisper up close. const BAKE_PX: f32 = 36.0; +/// The face of the overhead names: the Lua global `UNIT_NAME_FONT`, resolved once per world +/// session. `PlayerNameInitialize` (`0x6c7470`) reads it through `FrameScript::GetVariable` +/// (`0x704350`) at world entry and creates the name font from it (`GxuFontCreateFont`, `0x5c1ae0`, +/// size 0.99, flags 4) into `s_nameFont` (`0xce8738`); each locale's `Fonts.xml` assigns it, so a +/// zhCN install names a CJK face. `None` draws in the default face, as `DamageTextFont` does. +#[derive(Resource, Default)] +pub(crate) struct UnitNameFont(pub(crate) Option); + /// Build one name-block mesh in pitch units: x centered, y up, line `i`'s baseline at /// `lineCount - i`, one pitch being the font size. The baseline sits `TextEngine::ascent_ratio` -/// into the layout cell (the `[0x17c]` load param), as in the 2-D path. -fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { +/// into the layout cell (the `[0x17c]` load param), as in the 2-D path. `font` is the +/// `UNIT_NAME_FONT` face, for the baseline and the glyphs both. +fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String], font: Option<&str>) -> Mesh { use bevy::asset::RenderAssetUsages; use bevy::mesh::{Indices, PrimitiveTopology}; @@ -329,7 +338,7 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { // The size the glyphs are laid out at: `BAKE_PX` rounded to whole device pixels and back. let src = e.drawn_size(BAKE_PX); let baseline_frac = - ((f64::from(src) * f64::from(e.ascent_ratio(None)) + 0.5).floor() / f64::from(src)) as f32; + ((f64::from(src) * f64::from(e.ascent_ratio(font)) + 0.5).floor() / f64::from(src)) as f32; for (i, line) in lines.iter().enumerate() { let glyphs = layout_text_quads( &mut e, @@ -342,7 +351,7 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { }, 0, FontSpec { - path: None, // UNIT_NAME_FONT = Friz Quadrata, the default face + path: font, // UNIT_NAME_FONT height: Some(BAKE_PX), outline: Outline::None, monochrome: false, @@ -448,6 +457,7 @@ pub(crate) fn drive_nameplates( mirror: Res, // The show-gate inputs, one tuple param for Bevy's 16-param ceiling. gates: ( + Res, Res, Res, Option>, @@ -480,12 +490,17 @@ pub(crate) fn drive_nameplates( Query<(), With>, ), ) { - let (selection, flash, rig, vplates, bubbles, name_cfg, pvp_strings, self_guid) = gates; + let (unit_font, selection, flash, rig, vplates, bubbles, name_cfg, pvp_strings, self_guid) = + gates; let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else { return; }; // A glyph-sheet reset moves every cached UV. drop_stale_glyph_caches(&mut plates, atlas.generation, &mut commands); + // The cached meshes were laid out in the old name face. + if unit_font.is_changed() { + drop_name_meshes(&mut plates, &mut commands); + } // The palette materials, once (they need the atlas image). if plates.materials.is_empty() { let image = atlas.image(); @@ -633,7 +648,9 @@ pub(crate) fn drive_nameplates( let mesh = plates .meshes .entry(lines.clone()) - .or_insert_with(|| meshes.add(build_name_mesh(atlas, &lines))) + .or_insert_with(|| { + meshes.add(build_name_mesh(atlas, &lines, unit_font.0.as_deref())) + }) .clone(); let material = plates.materials[&color].clone(); // The spawn-frame seat: placement can miss a plate whose unit despawns later @@ -798,6 +815,7 @@ impl Plugin for NameplatesPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() // After targeting, V-key plates and chat bubbles: the gate reads their verdicts. After the // VM pass too, whose script calls change the selection a plate marks. .add_systems( @@ -831,17 +849,22 @@ fn drop_stale_glyph_caches(plates: &mut Nameplates, generation: u64, commands: & plates.meshes.len(), plates.live.len(), ); - for live in plates.live.values() { - if let Ok(mut e) = commands.get_entity(live.plate) { - e.despawn(); - } - } - plates.live.clear(); - plates.meshes.clear(); + drop_name_meshes(plates, commands); } plates.baked_from = Some(generation); } +/// Empty the mesh cache and despawn the live plates, which hold the old `Mesh3d`. +fn drop_name_meshes(plates: &mut Nameplates, commands: &mut Commands) { + for live in plates.live.values() { + if let Ok(mut e) = commands.get_entity(live.plate) { + e.despawn(); + } + } + plates.live.clear(); + plates.meshes.clear(); +} + /// Drop the line-stack mesh dedup on a cross-map transition: the old map's names never return. fn evict_name_meshes( mut changes: MessageReader, @@ -1391,6 +1414,56 @@ mod tests { assert!(world.get_entity(plate).is_ok()); } + /// A changed `UNIT_NAME_FONT` drops every mesh laid out in the old face, and the live plates. + #[test] + fn a_new_name_face_drops_the_meshes_and_the_live_plates() { + let mut world = World::new(); + let unit = world.spawn_empty().id(); + let plate = world.spawn_empty().id(); + let mut plates = primed(Some(7), unit, plate); + + let mut queue = bevy::ecs::world::CommandQueue::default(); + { + let mut commands = Commands::new(&mut queue, &world); + drop_name_meshes(&mut plates, &mut commands); + } + queue.apply(&mut world); + + assert!(plates.meshes.is_empty()); + assert!(plates.live.is_empty()); + assert_eq!(plates.materials.len(), 1, "materials hold no glyphs"); + assert!(world.get_entity(plate).is_err()); + } + + /// The face reaches the layout: the same line is wider in Friz Quadrata than in Arial Narrow. + #[test] + fn the_name_mesh_lays_out_in_the_unit_name_font() { + use bevy::mesh::VertexAttributeValues; + benilla_formats::wow_data_or_skip!(); + let mut atlas = UiFontAtlas::for_test(1.0).expect("the client faces open"); + let lines = vec!["Magister Kalendris".to_string()]; + let span = |mesh: &Mesh| { + let Some(VertexAttributeValues::Float32x3(p)) = + mesh.attribute(Mesh::ATTRIBUTE_POSITION) + else { + panic!("positions"); + }; + let xs = p.iter().map(|v| v[0]); + xs.clone().fold(f32::MIN, f32::max) - xs.fold(f32::MAX, f32::min) + }; + let friz = span(&build_name_mesh(&mut atlas, &lines, None)); + let arial = span(&build_name_mesh( + &mut atlas, + &lines, + Some("Fonts\\ARIALN.TTF"), + )); + assert!(friz > 0.0 && arial > 0.0); + assert!( + (friz - arial).abs() > 0.05, + "the face is honoured: {friz} vs {arial}" + ); + } + /// The block goes through `0x7716f0` whole: a plain block is left as it is, and a `|2` rewrites /// its word on its own line. #[test] diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index e81a3dc0b..ccefe4cef 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -747,6 +747,9 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { // `0x495669`), so the face the login read stays until the next login. if seat == LoadSeat::Roster { world.insert_resource(crate::combat_text::read_damage_text_font(&script)); + // `UNIT_NAME_FONT` binds at the same point: `0x6c7470` runs at world entry beside + // `0x6c8470`, so a `/reload` keeps the face the login read. + world.insert_resource(read_unit_name_font(&script)); } // The load is over: disarm its instruction bound, so no handler is killed for being slow. script.clear_instruction_budget(); @@ -1387,6 +1390,14 @@ pub(crate) fn is_emote_token_line(line: &str) -> bool { .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') } +/// Read `UNIT_NAME_FONT` at the tail of the world-entry UI load (`0x6c7470`, reached with +/// `0x6c8470` at world entry). `0x704350` takes a string or number and leaves `""` otherwise, +/// which the font factory rejects; empty is `None`. +fn read_unit_name_font(script: &benilla_ui::script::UiScript) -> crate::nameplates::UnitNameFont { + let value: Option = script.lua().globals().get("UNIT_NAME_FONT").ok(); + crate::nameplates::UnitNameFont(value.filter(|v| !v.is_empty())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index 7d2ae65a4..2e110076a 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -3246,6 +3246,43 @@ fn a_reload_keeps_the_damage_text_font_the_login_read() { let _ = std::fs::remove_dir_all(&tmp); } +/// The overhead names' face is read at world entry (`0x6c7470` reads `UNIT_NAME_FONT` through +/// `0x704350`): an addon's assignment reaches the resource; an unset global leaves `None`. +#[test] +fn world_entry_reads_unit_name_font() { + benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let face = |world: &World| { + world + .get_resource::() + .and_then(|f| f.0.clone()) + }; + let (tmp, _c, _h) = hermetic_addon( + "unitnamefont", + "FontProbe", + "## Interface: 11200\nFontProbe.lua\n", + "UNIT_NAME_FONT = \"Fonts\\\\ARIALN.TTF\"\n", + ); + let mut world = booted_world(); + log_in_as(&mut world, "Onehunter", 1); + assert_eq!(face(&world).as_deref(), Some("Fonts\\ARIALN.TTF")); + + let file = tmp + .join("benilla-config") + .join("AddOns") + .join("FontProbe") + .join("FontProbe.lua"); + std::fs::write(&file, "UNIT_NAME_FONT = \"\"\n").expect("the addon's empty face"); + super::end_ui_session(&mut world); + log_in_as(&mut world, "Onehunter", 1); + assert_eq!(face(&world), None, "an empty face is None"); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + /// A `/reload` releases every command: its shutdown's leave clears the once-per-world latch /// (`0x490c20`, `0x490a8d`), so the re-init's world-enter cascade runs its release of every /// command (`0x490168` → `0x4908c0` → `0x49093c` → `0x5144c0`). A right held and a mouselook diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index d632ab7a5..e17a4d941 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -174,7 +174,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - `AlphaMode::Blend` writes no depth where the reference does, because overlapping names resolving by sort order look the same. ([`src/nameplates.rs:4`](../crates/benilla-app/src/nameplates.rs#L4)) - A mounted plate keeps only this fraction of its seat's oscillation about a slow mean, because the raw seat, which the reference also rides, read more intense than the reference side by side. `1.0` is the raw seat; on-foot plates are untouched. ([`src/nameplates.rs:300`](../crates/benilla-app/src/nameplates.rs#L300)) - Larger than the reference's 32 px `UNIT_NAME_FONT` raster, which it magnifies (`0x6c749b`), because a larger source is crisper up close. ([`src/nameplates.rs:309`](../crates/benilla-app/src/nameplates.rs#L309)) -- `ShouldShowName`'s first leg is `IsSelectable` (`0x60be60`), which passes our own unit; the avatar's fade stands in because what hides the own name in first person in 1.12.1 is untraced. ([`src/nameplates.rs:541`](../crates/benilla-app/src/nameplates.rs#L541)) +- `ShouldShowName`'s first leg is `IsSelectable` (`0x60be60`), which passes our own unit; the avatar's fade stands in because what hides the own name in first person in 1.12.1 is untraced. ([`src/nameplates.rs:556`](../crates/benilla-app/src/nameplates.rs#L556)) #### names