diff --git a/crates/benilla-protocol/src/messages/chat.rs b/crates/benilla-protocol/src/messages/chat.rs index 5917188f..3204b547 100644 --- a/crates/benilla-protocol/src/messages/chat.rs +++ b/crates/benilla-protocol/src/messages/chat.rs @@ -56,6 +56,32 @@ pub const CHAT_MSG_RAID_BOSS_EMOTE: u8 = 0x5A; pub const CHAT_MSG_BATTLEGROUND: u8 = 0x5C; pub const CHAT_MSG_BATTLEGROUND_LEADER: u8 = 0x5D; +/// The chat types the real client runs its `$`-macro expander over, and the **only** ones — every +/// other type, player chat included, reaches the frame verbatim. +/// +/// VERIFIED at the bytes in `WoW.exe` (build 5875), `0x49cf36-0x49cf5d`: a `cmp`-chain on exactly +/// these seven values guards the `call 0x506f70` at `0x49cf70`. Note what the set is and isn't — +/// the four monster shapes plus the three **battleground system** lines (`BG_SYSTEM_NEUTRAL`/ +/// `_ALLIANCE`/`_HORDE`, vmangos `SharedDefines.h:1279-1281`), which is where lines like +/// "$n has taken the flag!" come from. `RAID_BOSS_WHISPER`/`_EMOTE` (0x59/0x5A) are **not** in it, +/// despite reading as boss text. +pub const MACRO_EXPANDED_TYPES: [u8; 7] = [ + CHAT_MSG_MONSTER_SAY, // 0x0B + CHAT_MSG_MONSTER_YELL, // 0x0C + CHAT_MSG_MONSTER_EMOTE, // 0x0D + CHAT_MSG_MONSTER_WHISPER, // 0x1A + CHAT_MSG_BG_SYSTEM_NEUTRAL, // 0x52 + CHAT_MSG_BG_SYSTEM_ALLIANCE, // 0x53 + CHAT_MSG_BG_SYSTEM_HORDE, // 0x54 +]; + +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_6_1` — active for 5875. The battleground system +/// lines; they take `BuildChatPacket`'s `default:` branch, so their single guid slot IS the macro +/// subject. +pub const CHAT_MSG_BG_SYSTEM_NEUTRAL: u8 = 0x52; +pub const CHAT_MSG_BG_SYSTEM_ALLIANCE: u8 = 0x53; +pub const CHAT_MSG_BG_SYSTEM_HORDE: u8 = 0x54; + /// `PlayerChatTag` (VERIFIED vmangos `Chat/Chat.h:86-92`) — [`ChatMessage::chat_tag`]'s values. /// `Player::GetChatTag` (`Objects/Player.cpp:1757-1767`) picks GM over DND over AFK over none. pub mod chat_tag { @@ -74,6 +100,11 @@ pub struct ChatMessage { pub language: u32, /// The speaker, when the type carries a guid (0 for system messages / name-only shapes). pub sender_guid: u64, + /// The **addressee** — the trailing guid slot the monster shapes carry (`0` for the shapes that + /// have none). Not display state: this is the subject the real client's `$`-macro expander + /// resolves monster chat against, which is why it is kept rather than dropped. See + /// [`MACRO_EXPANDED_TYPES`]. + pub target_guid: u64, /// The speaker's name, for the monster shapes that carry it inline (`None` elsewhere — /// player names resolve via the name query, exactly like the real client). pub sender_name: Option, @@ -106,6 +137,10 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { let chat_type = read_u8(r)?; let language = read_u32_le(r)?; let mut sender_guid = 0u64; + // The trailing "addressee" slot. Only the monster shapes carry one; it stays 0 elsewhere, and + // the macro expander then falls back to `sender_guid` (which is all the `default:` shapes — the + // BG system lines included — have). + let mut target_guid = 0u64; let mut sender_name = None; let mut channel = None; match chat_type { @@ -114,16 +149,17 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { | CHAT_MSG_RAID_BOSS_EMOTE | CHAT_MSG_MONSTER_EMOTE => { sender_name = Some(read_len_string(r)?); - let _target = read_u64_le(r)?; + target_guid = read_u64_le(r)?; } CHAT_MSG_SAY | CHAT_MSG_PARTY | CHAT_MSG_YELL => { sender_guid = read_u64_le(r)?; - let _sender_again = read_u64_le(r)?; + // vmangos writes the sender's guid into both slots for these three. + target_guid = read_u64_le(r)?; } CHAT_MSG_MONSTER_SAY | CHAT_MSG_MONSTER_YELL => { sender_guid = read_u64_le(r)?; sender_name = Some(read_len_string(r)?); - let _target = read_u64_le(r)?; + target_guid = read_u64_le(r)?; } CHAT_MSG_CHANNEL => { channel = Some(read_cstring(r)?); @@ -143,6 +179,7 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { chat_type, language, sender_guid, + target_guid, sender_name, channel, text, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index ca73e5e4..e37f5204 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -62,11 +62,13 @@ pub use bank::{ pub use channel::{channel_notice, ChannelNoticeTail, ChannelNotify}; pub use chat::{ chat_tag, ChatMessage, CHAT_MSG_AFK, CHAT_MSG_BATTLEGROUND, CHAT_MSG_BATTLEGROUND_LEADER, + CHAT_MSG_BG_SYSTEM_ALLIANCE, CHAT_MSG_BG_SYSTEM_HORDE, CHAT_MSG_BG_SYSTEM_NEUTRAL, CHAT_MSG_CHANNEL, CHAT_MSG_DND, CHAT_MSG_EMOTE, CHAT_MSG_GUILD, CHAT_MSG_IGNORED, CHAT_MSG_MONSTER_EMOTE, CHAT_MSG_MONSTER_SAY, CHAT_MSG_MONSTER_WHISPER, CHAT_MSG_MONSTER_YELL, CHAT_MSG_OFFICER, CHAT_MSG_PARTY, CHAT_MSG_RAID, CHAT_MSG_RAID_BOSS_EMOTE, CHAT_MSG_RAID_BOSS_WHISPER, CHAT_MSG_RAID_LEADER, CHAT_MSG_RAID_WARNING, CHAT_MSG_SAY, CHAT_MSG_SYSTEM, CHAT_MSG_WHISPER, CHAT_MSG_WHISPER_INFORM, CHAT_MSG_YELL, + MACRO_EXPANDED_TYPES, }; pub use client::{ auth_session, channel_announcements, channel_ban, channel_invite, channel_kick, channel_list, diff --git a/crates/benilla-protocol/tests/chat.rs b/crates/benilla-protocol/tests/chat.rs index 98d1b52f..484b0606 100644 --- a/crates/benilla-protocol/tests/chat.rs +++ b/crates/benilla-protocol/tests/chat.rs @@ -47,6 +47,10 @@ fn message_chat_decodes_every_wire_shape() { ServerPacket::MessageChat(m) => { assert_eq!(m.chat_type, messages::CHAT_MSG_MONSTER_SAY); assert_eq!(m.sender_guid, 0xAABB_CCDD); + // The addressee — the guid slot between the name and the message. It is the subject + // the real client's `$`-macro expander resolves this line against (decision 0754), so + // it has to survive the parse instead of being read and dropped. + assert_eq!(m.target_guid, 0x9988_7766); assert_eq!(m.sender_name.as_deref(), Some("Timmy the Wolf")); assert_eq!(m.channel, None); assert_eq!(m.text, "Grr!"); @@ -78,6 +82,7 @@ fn message_chat_decodes_every_wire_shape() { ServerPacket::MessageChat(m) => { assert_eq!(m.chat_type, messages::CHAT_MSG_MONSTER_WHISPER); assert_eq!(m.sender_guid, 0, "no leading guid on this shape"); + assert_eq!(m.target_guid, 0x55, "the whispered-at player"); assert_eq!(m.sender_name.as_deref(), Some("Innkeeper Bob")); assert_eq!(m.text, "Welcome, weary traveler!"); } @@ -374,3 +379,33 @@ fn random_roll_broadcast_decodes() { }] )); } + +/// The `$`-macro gate is a byte-level fact, not a design choice — pin it. +/// +/// VERIFIED in `WoW.exe` (build 5875) at `0x49cf36-0x49cf5d`: the `cmp`-chain guarding the +/// `call 0x506f70` at `0x49cf70` tests exactly `0x0b 0x0c 0x0d 0x1a 0x52 0x53 0x54`. Two things +/// this guards against, both of which read as plausible and are wrong: adding the RAID_BOSS pair +/// (0x59/0x5A) because they look like boss text, and dropping the BG_SYSTEM trio (0x52-0x54) +/// because they look like plain system lines. Player chat is never expanded. +#[test] +fn the_macro_expanded_chat_types_are_the_reference_seven() { + assert_eq!( + messages::MACRO_EXPANDED_TYPES, + [0x0B, 0x0C, 0x0D, 0x1A, 0x52, 0x53, 0x54] + ); + for t in [ + messages::CHAT_MSG_SAY, + messages::CHAT_MSG_YELL, + messages::CHAT_MSG_WHISPER, + messages::CHAT_MSG_GUILD, + messages::CHAT_MSG_CHANNEL, + messages::CHAT_MSG_SYSTEM, + messages::CHAT_MSG_RAID_BOSS_WHISPER, + messages::CHAT_MSG_RAID_BOSS_EMOTE, + ] { + assert!( + !messages::MACRO_EXPANDED_TYPES.contains(&t), + "type {t:#04x} must reach the frame verbatim" + ); + } +} diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 146458a9..785b1571 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -107,7 +107,8 @@ struct WowLight { sh_c13_g: vec4, sh_c13_b: vec4, // xyz = the true c16 quad band (x²−y², per channel); w = a free lane (the eval never reads - // a c16.w; the retired point-light gain dial used to ride it). + // a c16.w; the retired point-light gain dial rode it, then the 0750/0751 sun calibration + // dial, retired by the 0753 trace law). sh_c16: vec4, _water: array, 4>, // rows 13-16: the liquid swatches — unread by models. // x = SIDN night fraction (1 overnight, 0 by day — scales m.sidn.rgb). @@ -527,57 +528,36 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp let quad = vec4(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z); let n1 = vec4(n_lit, 1.0); let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y; - // Exterior world doodad/entity: the VERIFIED `Model2.bls` sun response, scaled by the - // per-instance INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on - // MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`; the leg - // that force-writes 1.0 is `0x69e36b`). Lane history: the `Model2.bls` SH lobe (0354/0358) - // → the FFP matte with a hard cutoff (the SH wrap fed warm sun onto the anti-sun side; - // director's call: a sunlit character's shadow side must read the SAME as the skin standing - // in shade, so both sit exactly at the ambient level) → the same hard cutoff on an - // UNCLAMPED source (0706) → the verified closed-form curve on the sun side, cutoff kept - // (0747, below). + // Exterior world doodad/entity: the TRACE-VERIFIED committed-sun law (decision 0753). + // The CPU light animator targets the per-instance intensity `[node+0xa4]` — 2.5 on lit + // ground / 0.5 on MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, + // `0x69e280`; the force-1.0 leg is `0x69e36b`) — but the reference AS THE DIRECTOR RUNS IT + // (WoW.exe D3D → wined3d; apitrace of their own client, 2026-07-29) commits the sun to the + // device at `D · min(I, 1)`: the lit 2.5 rung arrives at ×1, the shadowed rung at ×0.5, hue + // exact, never over-gamut (the Silverpine direct commits with max channel 254/255 — both + // peak-normalization and the raw ×2.5 product are refuted by that one byte). The light + // reaches the GPU as an FFP light slot (no SH constants anywhere in the trace), so the + // response is the FFP `max(N·L, 0)` — 0747's shader-lane f(μ) curve is superseded on this + // lane, and 0706's "the reference runs the shader path" premise is refuted for the + // reference-as-run. Lane history: `Model2.bls` SH lobe (0354/0358) → FFP matte, hard cutoff + // (director's call: the shadow side sits exactly at ambient) → unclamped source (0706) → + // verified curve (0747) → peak-norm (0750) → calibration dial (0751) → this trace law + // (0753), which retires the curve, the peak-norm and the dial here. // // The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 = - // the lit-ground family (ADT doodads and every entity M2 — intensity 2.5, mixed toward 0.5 by - // the per-instance tag shade byte, which units/players/GameObjects ramp CPU-side like the - // binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior WMO - // MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically - // MCSH-shadowed (0.5). + // the lit-ground family (ADT doodads and every entity M2 — animator target 2.5, mixed toward + // 0.5 by the per-instance tag shade byte, which units/players/GameObjects ramp CPU-side like + // the binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior + // WMO MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically + // MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps, like the + // reference: a lit↔shadowed transition holds ×1 until the ramp crosses 1.0. let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop); let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5); let shade_t = max(mat_shade, inst_shade); let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85; - let intensity = select(mix(2.5, 0.5, shade_t), 1.0, mid_band); - // The VERIFIED sun response (decision 0747): `clamp01(ambient + I·D·max(0, f(μ)))` with - // `f(μ) = (3 + 16μ + 15μ²)/34` — the closed form of the shipped `Model2.bls` vertex program - // (wow-re model2-bls-vertex-sh.md §4: three independent decodes convergent to ≤2.4e-8; the - // moments are consumed UNCLAMPED, so 0706's one saturating clamp at the sum stands). Peak - // f(1) = 1 (= the FFP `N·L` peak, the 16/17 accumulate scale exists for exactly that), and - // vs the previous linear `max(N·L,0)` the curve sits up to ~15% lower through the mid - // angles (f(0.5) = 0.434), which is where a near-saturating sun (any I·D channel > 1 — all - // three at night, R at dawn) stopped clipping ~15% too early. `max(0, ·)` keeps the - // director's anti-sun ruling (the hard-cutoff history above): f crosses zero at μ = −0.243, - // so the deep shadow hemisphere sits exactly at ambient — the reference's back-side wrap - // (f(−1) = 2/34) and its small negative dip are deliberately NOT taken, only the sun-side - // curve is. μ is the RAW dot (not `ndotl`): clamping first would pin the terminator band - // μ ∈ (−0.243, 0) at f(0) = 3/34 instead of letting it fade out. - let mu = dot(n_lit, L); - let sun_curve = max((3.0 + 16.0 * mu + 15.0 * mu * mu) / 34.0, 0.0); - // PEAK-NORMALIZE the committed sun (decision 0750, provisional — the byte verdict on which - // representation the reference's moment accumulate reads is a dispatched wow-re question): - // `C = I·D / max(1, max_ch(I·D))` — hue-preserving, capped at white. The raw over-gamut - // product (night: 2.5·[97,130,162]/255 = (0.95, 1.27, 1.59)) clips every moon-facing doodad - // face to flat white — the director's "day-lit fence at midnight" / "one Silverpine fence - // blown out at every hour" reports; the normalized form keeps the night's blue hue and - // softens the ×5 MCSH lit/shadowed cliff to the smooth blend the reference shows. Gain-1 - // lanes (WMO, clutter, mid-band, shaded 0.5) never exceed white, so this touches ONLY the - // lit-intensity family. The client's own light encoder (`0x71ca80`) demonstrably produces - // exactly this peak-normalized form; whether the raw peak is re-applied on the sun's path - // is the open byte question — if the verdict says raw, this reverts on the record. - let sun_c = wow_light.light_diffuse.rgb * intensity; - let sun_peak = max(max(sun_c.r, sun_c.g), max(sun_c.b, 1.0)); + let intensity = min(select(mix(2.5, 0.5, shade_t), 1.0, mid_band), 1.0); let lit_doodad = clamp( - wow_light.light_ambient.rgb + (sun_c / sun_peak) * sun_curve, + wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * (intensity * ndotl), vec3(0.0), vec3(1.0), ); diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 8b96211c..235456e9 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -296,7 +296,7 @@ pub(super) fn seed_ui_fixture( (612, G_BANKBAG as u32), // BANK_BAG_SLOT_1 — bag button 1's icon (1176, 123_456), // PLAYER_FIELD_COINAGE ]); - names.insert_player(PLAYER_GUID, "Benilla".into()); + names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(fields), crate::net::SelfPlayer, @@ -545,7 +545,7 @@ pub(super) fn seed_ui_fixture( // A player guid + cached name ride along — the feeds resolve the player identity // (chat-macro substitution) through the same (ObjectStore, Guid) self query as live. const PLAYER_GUID: u64 = 0x51; - names.insert_player(PLAYER_GUID, "Benilla".into()); + names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(fields), crate::net::SelfPlayer, @@ -816,7 +816,7 @@ pub(super) fn seed_ui_fixture( arrows.display_info_id = DISP_STONE; items.insert_template(93_012, Some(arrows)); - names.insert_player(PLAYER_GUID, "Benilla".into()); + names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(fields), crate::net::SelfPlayer, @@ -836,7 +836,7 @@ pub(super) fn seed_ui_fixture( // The synthetic self player, at the camera eye (the 20 yd plate gate measures from // here): level 2 human — the wolf cons YELLOW, the reference screenshot's digit. const PLAYER_GUID: u64 = 0x51; - names.insert_player(PLAYER_GUID, "Benilla".into()); + names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 2), // UNIT_FIELD_LEVEL @@ -900,7 +900,7 @@ pub(super) fn seed_ui_fixture( use benilla_protocol::messages::ObjectFields; const PLAYER_GUID: u64 = 0x51; const G_SWORD: u64 = 0x1002; - names.insert_player(PLAYER_GUID, "Benilla".into()); + names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 12), // UNIT_FIELD_LEVEL @@ -987,7 +987,7 @@ pub(super) fn seed_ui_fixture( // The synthetic self player at the eye (the reaction lookup reads its store, and the // name colour is that verdict). const SELF_GUID: u64 = 0x51; - names.insert_player(SELF_GUID, "Benilla".into()); + names.insert_player(SELF_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 2), // UNIT_FIELD_LEVEL diff --git a/crates/benilla/src/lighting/global_light.rs b/crates/benilla/src/lighting/global_light.rs index ca79b72d..07d9c05d 100644 --- a/crates/benilla/src/lighting/global_light.rs +++ b/crates/benilla/src/lighting/global_light.rs @@ -266,8 +266,8 @@ fn build_light_data( // `.yzw` is the core packer's below. rows[17][0] = l.sidn_night; // 18/19 — the INTERIOR fog triple (see the layout doc above). 19.zw (and 12.w) are free - // lanes: they carried the 0273/0354-era A/B dials until those investigations byte-closed - // (0285/0358) and the knobs retired — the shaders read the faithful path unconditionally now. + // lanes: they carried retired dials (the 0273/0354-era A/Bs, the point gain, the 0750/0751 + // sun calibration) — the shaders read the faithful path unconditionally now (0753). rows[18] = [ l.wmo_fog_color[0], l.wmo_fog_color[1], diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index fe3a1539..67142156 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -291,7 +291,21 @@ fn main() -> AppExit { } .into() } else { - UVec2::new(1600, 900).into() + // `$WOW_WIN=WxH` (logical px): override the world capture/window size — + // the resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned + // in TEXELS, so its angular footprint shrinks as resolution grows and + // thin bright features (fence rails) self-amplify at 4K where the + // 1024-era reference diluted them; matching the era's pixel density + // (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates + // that term. Also the knob for any future era-resolution comparison. + std::env::var("WOW_WIN") + .ok() + .and_then(|v| { + let (w, h) = v.split_once('x')?; + Some(UVec2::new(w.parse().ok()?, h.parse().ok()?)) + }) + .unwrap_or(UVec2::new(1600, 900)) + .into() }, // `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures // true frame cost, not the vsync ceiling — the same uncap the capture probe diff --git a/crates/benilla/src/names.rs b/crates/benilla/src/names.rs index 49439e21..2cf6664e 100644 --- a/crates/benilla/src/names.rs +++ b/crates/benilla/src/names.rs @@ -27,6 +27,13 @@ pub(crate) struct NameCache { /// Player names by guid. `Some(None)`-shaped answers are stored as `None`: the server was asked /// and didn't know (an empty wire name) — cached so we never re-ask a dead guid. players: HashMap>, + /// `(race, class, gender)` from the same `SMSG_NAME_QUERY_RESPONSE` that carried the name — the + /// wire has always sent these three and we used to drop them. They exist for one reason: the + /// `$`-macro expander's non-player-subject path. The reference resolves a macro subject from the + /// object manager first and falls back to **this** cache record when the unit isn't streamed + /// (`questtext-macro-expander.md` §1), so without them `$R`/`$C`/`$G` against an off-screen + /// player would silently read race 0. + player_traits: HashMap, /// Creature template records by entry; the outer `None` = the server flagged the entry /// unknown. The subname is the overhead/tooltip title line ("Stable Master", …); the type is /// the `CreatureType.dbc` id the TAB-target critter filter reads; rank/civilian feed the @@ -146,14 +153,30 @@ impl NameCache { } } - /// Record a player-name answer (`SMSG_NAME_QUERY_RESPONSE`). An empty wire name means the server - /// doesn't know the guid — cached as a negative answer. - pub(crate) fn insert_player(&mut self, guid: u64, name: String) { + /// Record a player-name answer. An empty wire name means the server doesn't know the guid — + /// cached as a negative answer. + /// + /// `traits` is the `(race, class, gender)` triple that rides `SMSG_NAME_QUERY_RESPONSE`, kept + /// for [`Self::player_traits`]. It is `None` for the seams that learn a name **without** that + /// packet — the login-time seed of our own name, and the capture fixtures — rather than having + /// them invent a zero triple that would read as a real (and wrong) race. + pub(crate) fn insert_player(&mut self, guid: u64, name: String, traits: Option<(u8, u8, u8)>) { self.pending_players.remove(&guid); + if !name.is_empty() { + if let Some(t) = traits { + self.player_traits.insert(guid, t); + } + } self.players .insert(guid, (!name.is_empty()).then_some(name)); } + /// `(race, class, gender)` for a player guid the name query has answered for — the macro + /// expander's fallback when the unit isn't streamed. See [`NameCache::player_traits`]. + pub(crate) fn player_traits(&self, guid: u64) -> Option<(u8, u8, u8)> { + self.player_traits.get(&guid).copied() + } + /// Record a pet-name answer (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by pet number. pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) { self.pending_pets.remove(&pet_number); @@ -290,7 +313,7 @@ mod tests { rx.try_recv(), Ok(ClientCommand::NameQuery { guid }) if guid == g )); - cache.insert_player(g, String::new()); // server: unknown guid + cache.insert_player(g, String::new(), None); // server: unknown guid assert_eq!(cache.resolve(g, &cmds), None); // …and no re-ask. assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); diff --git a/crates/benilla/src/net/apply.rs b/crates/benilla/src/net/apply.rs index 5493cb09..29e3e2a5 100644 --- a/crates/benilla/src/net/apply.rs +++ b/crates/benilla/src/net/apply.rs @@ -539,9 +539,13 @@ pub(super) fn apply_net_updates( } => { proficiencies.0.insert(item_class, subclass_mask); } - SessionEvent::PlayerName { guid, name, .. } => { - names::player_name(guid, name, &mut names) - } + SessionEvent::PlayerName { + guid, + name, + race, + class, + gender, + } => names::player_name(guid, name, race, class, gender, &mut names), SessionEvent::PetName { pet_number, name } => { names::pet_name(pet_number, name, &mut names) } diff --git a/crates/benilla/src/net/apply/names.rs b/crates/benilla/src/net/apply/names.rs index 307c48b8..cb688ed7 100644 --- a/crates/benilla/src/net/apply/names.rs +++ b/crates/benilla/src/net/apply/names.rs @@ -5,10 +5,18 @@ use crate::names::{CreatureRecord, NameCache}; -/// `SMSG_NAME_QUERY_RESPONSE` — a player's name. Race/gender/class ride the same answer but have no -/// consumer yet, so the cache stays name-only until one does. -pub(super) fn player_name(guid: u64, name: String, names: &mut NameCache) { - names.insert_player(guid, name); +/// `SMSG_NAME_QUERY_RESPONSE` — a player's name, plus the race/gender/class that ride the same +/// answer. Those three now have a consumer: the `$`-macro expander's subject fallback for a player +/// who isn't streamed ([`crate::npc_text::subject_for_guid`]). +pub(super) fn player_name( + guid: u64, + name: String, + race: u32, + class: u32, + gender: u32, + names: &mut NameCache, +) { + names.insert_player(guid, name, Some((race as u8, class as u8, gender as u8))); } /// `SMSG_PET_NAME_QUERY_RESPONSE` — keyed by pet *number*, not by template entry. diff --git a/crates/benilla/src/net/apply/session.rs b/crates/benilla/src/net/apply/session.rs index a27b1f96..4e6e7708 100644 --- a/crates/benilla/src/net/apply/session.rs +++ b/crates/benilla/src/net/apply/session.rs @@ -105,7 +105,7 @@ pub(super) fn connected( status.last_reason = None; info!("net: in world as {name} (guid {guid})"); // Our own name came with the login — seed the cache so "player" never queries. - names.insert_player(guid, name); + names.insert_player(guid, name, None); entered_world.write(EnteredWorldMessage); } diff --git a/crates/benilla/src/npc_text.rs b/crates/benilla/src/npc_text.rs index 8d73d564..3915beac 100644 --- a/crates/benilla/src/npc_text.rs +++ b/crates/benilla/src/npc_text.rs @@ -23,7 +23,7 @@ use bevy::prelude::*; use crate::names::NameCache; -use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer}; +use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; /// The unit a `$`-macro expands against. Every seam we have passes the **self player** (the /// reference resolves the subject from the GUID its call site hands over, and only its four chat @@ -205,6 +205,45 @@ pub(crate) fn player_identity( }) } +/// A macro [`Subject`] for an **arbitrary** guid — the chat feed's subject, where every other seam +/// passes the local player. +/// +/// This is the reference's own two-step (`questtext-macro-expander.md` §1): look the guid up in the +/// object manager first and read the unit's descriptors, and only when it isn't streamed fall back +/// to the **name-cache** record. `None` means the subject could not be resolved at all — the +/// reference's no-subject case, which fails every person-token and re-emits a literal `$`; that is +/// also what an untargeted line (guid 0) gets, deliberately. +pub(crate) fn subject_for_guid( + guid: u64, + index: &GuidIndex, + stores: &Query<&ObjectStore>, + names: &mut NameCache, + commands: &NetCommands, +) -> Option { + if guid == 0 { + return None; + } + let name = names.resolve(guid, commands)?.to_string(); + if let Some(store) = index.0.get(&guid).and_then(|e| stores.get(*e).ok()) { + return Some(Subject { + name, + race: store.0.unit_race().unwrap_or(0), + class: store.0.unit_class().unwrap_or(0), + gender: store.0.unit_gender().unwrap_or(0), + }); + } + // Not streamed: the name answer's own race/class/gender. A creature guid has no such record and + // lands on zeros — which is right, because the reference's non-player arm never reads a + // race/class for `$R`/`$C` either; it emits the unit's name instead (§3, orchestrator ruling). + let (race, class, gender) = names.player_traits(guid).unwrap_or((0, 0, 0)); + Some(Subject { + name, + race, + class, + gender, + }) +} + #[cfg(test)] mod tests { use super::{substitute, MacroContext, Subject}; diff --git a/crates/benilla/src/portrait/glue_booth.rs b/crates/benilla/src/portrait/glue_booth.rs index 202ebf5a..5621e56d 100644 --- a/crates/benilla/src/portrait/glue_booth.rs +++ b/crates/benilla/src/portrait/glue_booth.rs @@ -528,7 +528,6 @@ pub(super) fn sync_glue_scene( let fog_w = if fog { 1.0 } else { 0.0 }; rows[4] = [fog_rgb[0], fog_rgb[1], fog_rgb[2], fog_w]; // fog color, w = enabled rows[5] = [0.0, fog_far, 0.0, 10_000.0]; // start 0, the ref's far; farclip wall inert - rows[12][3] = 1.0; // point gain: the rig's authored point lights at the committed base rows[19] = [0.0, 0.0, 0.0, 1.0]; // rig lane ignores the intensity dial; 1.0 = byte levels rows[20] = [rig.points.len() as f32, 0.0, 0.0, 0.0]; // point-table count // Header rows + the point table are contiguous in the std430 layout — one write covers diff --git a/crates/benilla/src/portrait/light.rs b/crates/benilla/src/portrait/light.rs index 040c341c..ecbf7f64 100644 --- a/crates/benilla/src/portrait/light.rs +++ b/crates/benilla/src/portrait/light.rs @@ -191,7 +191,7 @@ pub(super) fn model_pane_light_rows() -> [[f32; 4]; crate::lighting::LIGHT_HEADE rows[3] = [0.0, 0.0, 0.0, 20.0]; // spec (unused by models; w = terrain shininess convention) rows[4] = [0.0, 0.0, 0.0, 0.0]; // fog color, w = 0: fog OFF in the booth rows[5] = [0.0, 10_000.0, 0.0, 10_000.0]; // fog params (inert; farclip wall far away) - rows[19] = [0.0, 0.0, 0.0, 0.4]; // exterior-intensity dial → 1.0 (see `studio_light_rows`) + rows[19] = [0.0, 0.0, 0.0, 0.4]; // STALE dial (see `studio_light_rows` — no shader reads 19.w) rows } @@ -219,10 +219,12 @@ pub(super) fn studio_light_rows() -> [[f32; 4]; crate::lighting::LIGHT_HEADER_RO rows[3] = [0.0, 0.0, 0.0, 20.0]; // spec (unused by models; w = terrain shininess convention) rows[4] = [0.0, 0.0, 0.0, 0.0]; // fog color, w = 0: fog OFF in the booth rows[5] = [0.0, 10_000.0, 0.0, 10_000.0]; // fog params (inert; farclip wall far away) - // 19.w = the exterior-intensity dial: booth parts are untagged (shade byte 0), so the exterior - // lane lands them on the lit 2.5 rung — 0.4 brings the studio to intensity 1.0, the neutral - // front-lit portrait rather than the noon-saturated outdoor rung. Rows 12.w (point gain) and - // 17.x (SIDN night) stay 0: no scene hot-spots, no night emissive in the booth. + // 12.w / 17.x (SIDN night) stay 0: no dial lanes, no night emissive in the booth. + // 19.w — STALE: this was the retired exterior-intensity A/B dial ("0.4 brings the untagged + // booth parts from the lit 2.5 rung to intensity 1.0"), but NO shader reads 19.w today. + // Under the 0753 trace law the exterior lane commits the sun at ×1 regardless, which is the + // very level this dial once aimed for — the drift resolved itself; comment kept until a + // portrait-light pass confirms the booth look. rows[19] = [0.0, 0.0, 0.0, 0.4]; rows } diff --git a/crates/benilla/src/ui_chat/feed.rs b/crates/benilla/src/ui_chat/feed.rs index 2e6632be..2c9779d1 100644 --- a/crates/benilla/src/ui_chat/feed.rs +++ b/crates/benilla/src/ui_chat/feed.rs @@ -7,11 +7,11 @@ use bevy::prelude::*; use benilla_protocol::messages::{ - channel_notice, ChannelNoticeTail, ChatMessage, LevelUpInfo, XpGain, + channel_notice, ChannelNoticeTail, ChatMessage, LevelUpInfo, XpGain, MACRO_EXPANDED_TYPES, }; use crate::names::NameCache; -use crate::net::NetCommands; +use crate::net::{GuidIndex, NetCommands, ObjectStore}; use super::edit::ChannelState; use super::event::{flag_of_tag, kind_of_wire, language_name, ChatEvent, ChatEventKind}; @@ -244,6 +244,11 @@ pub(super) fn feed_chat( mut bubbles: ResMut, commands: Res, time: Res