the doodad sun law, read straight from the real client's own draw calls

- Why fences glowed noon-warm at midnight: our sun lane multiplied the
  day/night colour by the client's documented 2.5 for doodads on lit ground,
  which pushes past white and clips to a daylit face. Two days of binary
  archaeology confirmed that multiply is real — on a render path the
  reference machine turns out not to run.
- The settle was an API trace of the actual 1.12.1 client standing at the
  same fences: on its Direct3D path the sun arrives at the day/night colour
  clamped to at most full strength — one for lit ground, half in baked
  shadow, never brighter than the palette. Our lane now commits exactly
  that. Midnight is moonlit blue, and adjacent lit/shaded fences blend the
  way the original does.
- Along the way: a capture-window override for era-resolution A/Bs, and a
  calibration dial that lived exactly one day — the by-eye level it found
  matched the traced law to a few parts in 255, then retired.
- Separately, the $-macro text expander now runs everywhere the original
  runs it: chat lines, mail bodies and item text, not just the UI strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-29 19:53:10 +07:00
co-authored by Claude Fable 5
parent 73646cbeaf
commit ac4d859a1b
17 changed files with 344 additions and 85 deletions
+40 -3
View File
@@ -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<String>,
@@ -106,6 +137,10 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result<ChatMessage> {
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<ChatMessage> {
| 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<ChatMessage> {
chat_type,
language,
sender_guid,
target_guid,
sender_name,
channel,
text,
@@ -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,
+35
View File
@@ -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"
);
}
}
+25 -45
View File
@@ -107,7 +107,8 @@ struct WowLight {
sh_c13_g: vec4<f32>,
sh_c13_b: vec4<f32>,
// 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<f32>,
_water: array<vec4<f32>, 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<f32>(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<f32>(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<f32>(0.0),
vec3<f32>(1.0),
);
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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],
+15 -1
View File
@@ -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
+27 -4
View File
@@ -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<u64, Option<String>>,
/// `(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<u64, (u8, u8, u8)>,
/// 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)));
+7 -3
View File
@@ -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)
}
+12 -4
View File
@@ -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.
+1 -1
View File
@@ -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);
}
+40 -1
View File
@@ -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<Subject> {
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};
@@ -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
+7 -5
View File
@@ -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
}
+39 -4
View File
@@ -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<crate::chat_bubble::BubbleQueue>,
commands: Res<NetCommands>,
time: Res<Time>,
// The `$`-macro subject seam: monster/BG lines expand against the guid the line is ADDRESSED to,
// which needs the object index + the streamed unit's descriptors. See [`macro_subject`].
guids: Res<GuidIndex>,
stores: Query<&ObjectStore>,
states: Res<crate::world_state::WorldStates>,
) {
let Some(mut script) = script else {
return;
@@ -316,9 +321,36 @@ pub(super) fn feed_chat(
None => c,
})
.unwrap_or_default();
// `$`-macro expansion (decision 0754): the reference runs its one server-text
// expander over monster + BG-system chat and nothing else, against the guid the
// line is ADDRESSED to. Every other type reaches the frame verbatim.
let text = if MACRO_EXPANDED_TYPES.contains(&msg.chat_type) {
// The addressee where the shape carries one, else the only guid it has.
let subject_guid = if msg.target_guid != 0 {
msg.target_guid
} else {
msg.sender_guid
};
let subject = crate::npc_text::subject_for_guid(
subject_guid,
&guids,
&stores,
&mut names,
&commands,
);
crate::npc_text::substitute(
&msg.text,
&crate::npc_text::MacroContext {
subject: subject.as_ref(),
states: &states,
},
)
} else {
msg.text.clone()
};
let event = ChatEvent {
kind,
text: msg.text.clone(),
text: text.clone(),
sender: name.unwrap_or_else(|| {
if needs_name(msg.chat_type) && msg.sender_guid != 0 {
"Unknown".to_string()
@@ -340,7 +372,10 @@ pub(super) fn feed_chat(
// The speech bubble spawns the moment the line routes — the reference's
// `0x49acd9` sits in the same SMSG display path ([`crate::chat_bubble`]).
if let Some(kind) = event.kind {
bubbles.push(msg.sender_guid, kind, &msg.text);
// The bubble shows the same expanded line the feed does — the reference's
// bubble spawn sits inside this same SMSG display path, downstream of the
// expander, so a `$n` must never survive into it either.
bubbles.push(msg.sender_guid, kind, &text);
}
}
Pending::Notice {
+13
View File
@@ -85,6 +85,9 @@ fn feed_item_text(
mut items: ResMut<Items>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
// The `$`-macro subject for the page body: the local player, as at every panel seam.
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
states: Res<crate::world_state::WorldStates>,
) {
let Some(mut script) = script else {
return;
@@ -144,6 +147,16 @@ fn feed_item_text(
let Some(text) = body else {
return; // body query in flight
};
// Page/book text is server-authored, so it runs the `$`-macro expander — the reference does it
// from two sites in `ItemTextFrame.cpp` (decision 0754), subject = the local player.
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let text = crate::npc_text::substitute(
&text,
&crate::npc_text::MacroContext {
subject: subject.as_ref(),
states: &states,
},
);
script.set_item_text(Some(ItemTextState {
item: title,
creator,
+73 -5
View File
@@ -263,6 +263,7 @@ pub(crate) fn mail_error_text(error: u32) -> String {
/// item-template cache + `ItemDisplayInfo.dbc`, the body from the cache, the stationery basename
/// from `Stationery.dbc`. `None`s stay `None` while a query is in flight — the row shows a
/// placeholder and fills in when the answer lands (the merchant/loot pattern).
#[allow(clippy::too_many_arguments)] // one resolve per ask-once cache the row reads
fn resolve_row(
entry: &MailListEntry,
bodies: &HashMap<u32, String>,
@@ -271,6 +272,7 @@ fn resolve_row(
stationery: Option<&Stationery>,
names: &mut NameCache,
commands: &NetCommands,
macros: &crate::npc_text::MacroContext,
) -> MailInboxRow {
let is_gm = entry.stationery == STATIONERY_GM;
let returned = entry.checked & CHECKED_RETURNED != 0;
@@ -328,9 +330,13 @@ fn resolve_row(
text_created: entry.checked & CHECKED_COPIED != 0,
can_reply,
is_gm,
// The letter body is server-authored text, so it runs the `$`-macro expander — the
// reference does it from two sites in `GetInboxText` (decision 0754). Subject is the
// local player, as it is for every panel seam.
body: (entry.item_text_id != 0)
.then(|| bodies.get(&entry.item_text_id).cloned())
.flatten(),
.flatten()
.map(|b| crate::npc_text::substitute(&b, macros)),
stationery_texture,
is_invoice: entry.message_type == mail_message_type::AUCTION,
has_body: entry.item_text_id != 0,
@@ -343,6 +349,7 @@ fn resolve_row(
}
/// Build the Lua-facing snapshot from [`MailOpen`] — `None` when no mailbox is open.
#[allow(clippy::too_many_arguments)] // mirrors `resolve_row`'s cache set
fn snapshot(
mail: &MailOpen,
items: &mut Items,
@@ -350,13 +357,25 @@ fn snapshot(
stationery: Option<&Stationery>,
names: &mut NameCache,
commands: &NetCommands,
macros: &crate::npc_text::MacroContext,
) -> Option<MailState> {
mail.mailbox?;
Some(MailState {
inbox: mail
.mails
.iter()
.map(|e| resolve_row(e, &mail.bodies, items, icons, stationery, names, commands))
.map(|e| {
resolve_row(
e,
&mail.bodies,
items,
icons,
stationery,
names,
commands,
macros,
)
})
.collect(),
})
}
@@ -378,6 +397,9 @@ fn feed_mail(
mut last: Local<Option<MailState>>,
mut last_mailbox: Local<Option<u64>>,
mut last_has_new_mail: Local<bool>,
// The `$`-macro subject for the letter body: the local player, as at every panel seam.
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
states: Res<crate::world_state::WorldStates>,
) {
let Some(mut script) = script else {
return;
@@ -399,6 +421,14 @@ fn feed_mail(
}
}
// The macro subject, resolved before the row walk borrows the name cache again. `None` until
// the player is streamed and named — the feed diffs on the substituted text, so a letter opened
// that early re-substitutes as soon as it lands.
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let macros = crate::npc_text::MacroContext {
subject: subject.as_ref(),
states: &states,
};
let fresh = snapshot(
&mail,
&mut items,
@@ -406,6 +436,7 @@ fn feed_mail(
stationery.as_deref(),
&mut names,
&commands,
&macros,
);
let opened = last_mailbox.is_none() && mail.mailbox.is_some();
let closed = last_mailbox.is_some() && mail.mailbox.is_none();
@@ -583,6 +614,16 @@ mod tests {
use super::*;
use benilla_protocol::messages::MailAttachment;
/// The no-subject macro context these row tests run under: none of them exercises a `$` token,
/// and a subject-less context leaves plain text untouched (the expander only rewrites at a `$`).
/// The macro path's own behaviour is covered in [`crate::npc_text`]'s tests.
fn no_macros(states: &crate::world_state::WorldStates) -> crate::npc_text::MacroContext<'_> {
crate::npc_text::MacroContext {
subject: None,
states,
}
}
fn entry(
message_id: u32,
sender_guid: Option<u64>,
@@ -656,6 +697,7 @@ mod tests {
#[test]
fn resolve_row_derives_the_reply_and_delete_law() {
let states = crate::world_state::WorldStates::default();
let mut items = Items::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -672,6 +714,7 @@ mod tests {
None,
&mut names,
&commands,
&no_macros(&states),
);
assert!(row.can_reply);
assert!(row.can_delete);
@@ -689,6 +732,7 @@ mod tests {
None,
&mut names,
&commands,
&no_macros(&states),
);
assert!(row.can_reply);
assert!(!row.can_delete);
@@ -696,7 +740,14 @@ mod tests {
// A returned mail → not replyable, deletable.
let returned = entry(2, Some(0xA), CHECKED_RETURNED, 30.0);
let row = resolve_row(
&returned, &bodies, &mut items, None, None, &mut names, &commands,
&returned,
&bodies,
&mut items,
None,
None,
&mut names,
&commands,
&no_macros(&states),
);
assert!(!row.can_reply);
assert!(row.can_delete);
@@ -704,13 +755,21 @@ mod tests {
// A system mail (no sender guid) → deletable.
let system = entry(3, None, 0, 30.0);
let row = resolve_row(
&system, &bodies, &mut items, None, None, &mut names, &commands,
&system,
&bodies,
&mut items,
None,
None,
&mut names,
&commands,
&no_macros(&states),
);
assert!(row.can_delete);
}
#[test]
fn resolve_row_reads_the_item_and_body_caches() {
let states = crate::world_state::WorldStates::default();
let mut items = Items::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -730,7 +789,16 @@ mod tests {
durability_max: 0,
durability: 0,
});
let row = resolve_row(&e, &bodies, &mut items, None, None, &mut names, &commands);
let row = resolve_row(
&e,
&bodies,
&mut items,
None,
None,
&mut names,
&commands,
&no_macros(&states),
);
assert_eq!(row.item_id, 2589);
assert_eq!(row.item_count, 5);
assert_eq!(row.body.as_deref(), Some("the letter body"));