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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,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)));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
¯os,
|
||||
);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user