nameplates: draw overhead names in the UNIT_NAME_FONT face (#3013)

Overhead unit names were laid out in Friz Quadrata whatever the install, so on a zhCN install, whose glyphs that face lacks, NPC names over heads drew nothing (#3005).

In 1.12.1 the name font is not a locale table: `PlayerNameInitialize` (`0x6c7470`) reads the Lua global `UNIT_NAME_FONT` at world entry through `FrameScript::GetVariable` (`0x704350`) and builds the name font from it (`0x5c1ae0`), so the install's own `Fonts.xml`, or an addon that reassigns the global, picks the face. `s_loadingFontPaths` (`0x82f018`) is the loading screen's tip font.

A `UnitNameFont` resource is read at the same world-entry seat as `DAMAGE_TEXT_FONT`, the name mesh lays out its baseline and glyphs in that face, and the mesh cache drops when the face changes.

Tests: `the_name_mesh_lays_out_in_the_unit_name_font`, `world_entry_reads_unit_name_font`, `a_new_name_face_drops_the_meshes_and_the_live_plates`.

Fixes #3005

Co-authored-by: hyrw <47274436+hyrw@users.noreply.github.com>

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-11-08 14:53:11 +07:00
committed by GitHub
parent 1b61c377f0
commit e5651f0ddd
4 changed files with 135 additions and 14 deletions
+86 -13
View File
@@ -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<String>);
/// 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<crate::ui_chat::AfkMirror>,
// The show-gate inputs, one tuple param for Bevy's 16-param ceiling.
gates: (
Res<UnitNameFont>,
Res<Selection>,
Res<CombatFlash>,
Option<Res<crate::player::CameraControl>>,
@@ -480,12 +490,17 @@ pub(crate) fn drive_nameplates(
Query<(), With<crate::entities::mount::MountChild>>,
),
) {
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::<Nameplates>()
.init_resource::<NameConfig>()
.init_resource::<NameLineStrings>()
.init_resource::<UnitNameFont>()
// 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<benilla_world::world_map::MapChange>,
@@ -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]
@@ -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<String> = script.lua().globals().get("UNIT_NAME_FONT").ok();
crate::nameplates::UnitNameFont(value.filter(|v| !v.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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::<crate::nameplates::UnitNameFont>()
.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
+1 -1
View File
@@ -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