swing refusals latch and repeat, and the trade arc stops being silent

- a refused auto-attack is no longer silent, and the display is a latch, not a toast: six opcodes, four arms — `0x145 SMSG_ATTACKSWING_NOTINRANGE` and `0x146 SMSG_ATTACKSWING_BADFACING` latch a code through `0x5ecdb0`, which writes `[0xc4d758]` and `[0xc4c270]` together; `0x148 DEADTARGET` and `0x149 CANT_ATTACK` share arm `0x625ab8` verbatim (StopAttack, no message), so the client cannot tell them apart and `AttackSwingError` collapses them into one `DeadOrUnattackable`; `0x147 NOTSTANDING` is never registered by the reference and `SendAttackSwingNotStanding` has zero callers in vmangos, so a test pins that it stays an unknown opcode; and the fourth arm hides: `0x14e SMSG_CANCEL_COMBAT` is registered at `0x5e3303` by the SPELL TU, not the combat one, so the eight-opcode census every note quotes did not contain it, and its handler `0x5e7dd0` is arm 4's body instruction for instruction; vmangos sends each refusal on the edge alone, so the repeat the player sees is the client's own: the tick `0x5ec950` — `CGPlayer_C` vtable `0x80af78` slot 14, dispatched once per object per rendered frame on the `CWorldFrame::OnPaint` chain, so a Bevy `Update` system is the exact cadence — prints `ERR_BADATTACKPOS`/`ERR_BADATTACKFACING` (catalog rows `0xd8`/`0xd7`, both red) at once and again every 4000 ms while an attack target is set, and a landed swing clears it: `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800`, whose first act is `0x5ecdb0(0)`, gated on the attacker being the active player AND the victim resolving (both gates kept — wow-re's round had glossed the second away, and sending that back corrected the note); the latch is one `Option<(Latched, Duration)>` because `[0xc4c270]` has no meaning while `[0xc4d758]` is 0, a second refusal packet defeats the cooldown and the re-arm precedes the print (both tested), the clear also runs once per world entry (`0x5e2510`), and both edges ride ONE `SwingRefusalEdge` stream so a refusal and a landing in the same frame stay in packet order; `SMSG_FEIGN_DEATH_RESISTED` (`0x2b4`) arrives beside them and is built differently because its handler `0x6e9800` is two instructions — a bare `DisplayError(421)` = `ERR_FEIGN_DEATH_RESISTED`, the single word "Resisted" — so it takes the plain `UiErrorKeys` route with no latch; the `--attack` world probe now reports the refusals it tallies; and two remainders are named, not built: `0x5ea800`'s post-swing leash — `R = 4.0 * max(5.0, reach(victim) + reach(self) + 1.3333333730697632)`, floor 20.0, sending a bare `CMSG_ATTACKSTOP` past it — a new packet-sending behaviour left for the director's call, and `StopAttack 0x5ecac0`'s two one-shot tutorials (10 low-health, 11 low-mana), which benilla's copy has never fired (2037)
- the trade arc stops being silent: eighteen of the twenty-three `SMSG_TRADE_STATUS` arms print where benilla printed none, all through `UiErrorKeys` so the text is the player's own `GlobalStrings.lua` and the surface is the catalog record's `+0x04`; the four-row start was the trade block's own emit-site census, which answered "which arms raise a TRADE-keyed message" — eleven of the ids come from elsewhere in the 465-row table (`ERR_PLAYER_BUSY_S` `0x3e`, `ERR_PLAYER_DEAD` `0x7e`, `ERR_GENERIC_NO_TARGET` `0xb8`, `ERR_PLAYER_WRONG_FACTION` `0xff`, `ERR_IGNORING_YOU_S` `0x13d`, the stunned pair `0x191`/`0x192`, the logout pair `0x19d`/`0x19e`, `ERR_RESTRICTED_ACCOUNT` `0x1be`, and `ERR_TRADE_WRONG_REALM` `0x1ca`, whose text is the ONLY_CONJURED sentence), and the surfaces do not follow the English — a successful trade's two lines are the yellow ones, `ERR_PLAYER_BUSY_S`/`ERR_IGNORING_YOU_S` are chat lines, the refusals red; `TRADE_REQUEST_CANCEL` fires from the top of the CANCELED arm (`0x4bf832`, the dispatcher's one `SignalEvent`) where nothing had fired it; `ERR_INITIATE_TRADE_S` prints — it is client-emitted from inside the `CMSG_INITIATE_TRADE` sender, at once on a name-cache hit (`0x5d4042`) and from the query callback on a miss (`0x5d40ca`), so clicking Trade had produced no text in either direction; the three arms that name a player (0/5/14) read the network handler's guid cell `[0xc4bed0]`/`[0xc4bed4]` behind the outbound-initiate latch `[0xc4bec8]` — `InitiateTrade 0x5d3fb0` writes the target's guid and then the latch, the `BEGIN_TRADE` arm writes the initiator's and no latch — which is what keeps a client that just ignored a trade from printing "X is ignoring you." to itself, and behind `0x468460(guid, TYPEMASK_PLAYER)`, a live-object test the initiate line does not share, so `NamedLine::needs_live_object` drops a status line the moment its player stops being streamed while an initiate line waits; a network-cell clear is not a window close: `0x4bf4e0` has four call sites in the dispatcher — one opens, `{3, 8, 12}` close — and the other fourteen run the handler's tail `0x5d490a`, which drops the latch and the pending guid and leaves the window standing, so `close()` splits into `close_window()` and `clear_pending()`; `REJECTED` drops only the partner's accept, `BACK_TO_TRADE` reaches `0x4bf230(0)` and sends `CMSG_UNACCEPT_TRADE` on a real change, `ONLY_CONJURED` bounces the offending slot out of our own offer; `CLOSE_WINDOW`'s message is deliberately not built (`0x4bfd70` maps `result` over a 61-id range with a flagged self/other polarity inconsistency, and vmangos never sends status 12); and `ERR_TRADE_BLOCKED_S`'s transcribed English literal is deleted along with `ChatLog::pending_texts` (2042)
- both halves of B373's lighting closed: `MeshTag` bits 6..=13 mean two things — the exterior payload's ground-shade byte and the low eight bits of the interior payload's 13-bit probe slot — and the overlap cannot be removed (the four mode-independent fields cost 19 bits, leaving 13 for a payload region the two modes want 21 of), so the payload question becomes a witness the accessors demand: `mesh_tag::ExteriorPayload` is a zero-sized proof only `exterior_payload(on_bake_law, own_probe)` mints, when both probe-slot populations answer no, and `with_shade`/`shade_of` take it, so no expression in the crate reaches those bits without having asked; `describe` alone reads the raw masks and prints both readings (2041); and a transport's deck cargo is lit by its own doodad def, not by the boat (B373): a transport's WMO is linked into the same global `TSExplicitList<CMapObjDef>` at `0xca7d98` the ordinary scene walk iterates, so its MODDs are ordinary doodad defs; `[def+0xa4]` has exactly four writers image-wide — the ctor `0x6a7d73` (0.0), the doodad-set commit `0x695bb3` (1.0), the ADT MDDF commit `0x6b01bb` (1.0) and the MCSH refresh `0x698cb4` (0.5) — and none is the light node, whose 2.5-producer and ramp are reachable only through `[obj+0xe0]`, a path a doodad def never takes; the MCSH sample is one-shot (`0x698c50` unlinks each entry) and its null-tile legs return lit; so we had the wrong source, the wrong cadence and the wrong mechanism: `entity_shade::DoodadDefLit` marks every WMO-gameobject prop batch from inside the placed-model assembler — the engine asserts it, not the caller; the world-API wall failed the first cut, and a following card is reachable only there — both shade write sites skip it on ownership, separately from 2041's payload question, and exterior props take one `doodad_ground_shade` at their own footprint at spawn, frozen, which answers lit for a non-resident tile so a boat at sea needs no special case and the unverified "a boat is never MCSH-shadowed" goes; 749 prop parts under a shade root carry the marker on a live run — the same 749 B373 measured reading a foreign probe — and the ADT doodad path is deliberately untouched, since the D3D capture's measured 2.5 gain outranks the closure (2047)
- 2043's unit-predicate law is confirmed byte-exact by the round it dispatched — all 29 `Unit*` predicates push the constant double 1.0 or nil, exactly one value at every live `ret`, and a BFS from all 83 bindings in `0x850438` reaches 460 functions without hitting `lua_pushboolean` — and three of its supporting citations are corrected in the comments, no behaviour changing: `lua_pushboolean 0x6f39f0` EXISTS with seven call sites, one of them the registered FrameScript binding `IsPetAttackActive 0x4be0e0`, which answers a real true/false and never nil (ours already did, and its doc already named itself the exception), so 2043's "no boolean on its binding surface at all" is withdrawn for the scoped claim 1830 actually made; `IsEnabled` is `0x7800b0`, not `0x780270` (that is `SetButtonState`), and its number-1-or-0-never-nil fact stands; and `UnitReaction 0x5167e0` pushes `0x6061e0(u1, u2)` PLUS ONE, so 0 is unreachable and its nil means only that a token did not resolve — our `reaction == 0` → nil stays, named now as a feed gap rather than a mechanism the binary does not have (2048)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:40:25 +07:00
co-authored by Claude Fable 5.1
parent eadacac998
commit f7e232b2db
26 changed files with 1910 additions and 195 deletions
+31 -6
View File
@@ -60,9 +60,9 @@ use benilla_world::lighting::{PropProbeSlot, PropProbes};
use benilla_world::model_render::ShadeSel;
use benilla_world::particles;
use benilla_world::terrain_stream::{
build_collider_task, fold_interior_probe, hex_word, m2_anim_bound, m2_fade,
placement_collider_data, point_light, spawn_model_entities, PendingCollider, PropLobeLight,
SpawnedModel,
build_collider_task, doodad_ground_shade, fold_interior_probe, hex_word, m2_anim_bound,
m2_fade, placement_collider_data, point_light, spawn_model_entities, PendingCollider,
PropLobeLight, ShadeResolve, SpawnedModel, TerrainStreamer,
};
use super::{GameObjects, ModelHandle, VisualAttached};
@@ -191,6 +191,10 @@ pub(super) fn spawn_wmo_gameobject_props(
mut tint_reg: ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
mut probes: ResMut<PropProbes>,
// The EXTERIOR prop's one-shot MCSH sample (2047) — the same resolver the terrain lane's
// placed props use, at the same one-shot cadence the reference's `0x698c50` queue drain has.
streamer: Option<Res<TerrainStreamer>>,
adt_tiles: Res<Assets<benilla_assets::AdtTile>>,
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
@@ -229,6 +233,28 @@ pub(super) fn spawn_wmo_gameobject_props(
commands.entity(entity).add_child(anchor);
anchor
});
// **An EXTERIOR prop's sun scale is the doodad law, not its host's** (2047). The
// reference samples MCSH once, at the doodad's OWN footprint, on the frame the
// pending-doodad queue drains it (`0x698c50` unlinks each entry), and freezes the
// verdict: 1.0 lit / 0.5 shadowed, never the host node's ramp. `doodad_ground_shade`
// already answers *lit* for a tile that is not resident — which is what the
// reference's own null-tile legs return, so a boat at sea needs no special case.
// An INTERIOR prop ignores the selector entirely (the probe lane reads it not at all).
let shade = if prop.interior.is_some() {
ShadeSel::Matte
} else {
let world = host_world.mul_transform(prop.local).translation;
match streamer
.as_deref()
.map(|st| doodad_ground_shade(st, &adt_tiles, world))
{
Some(ShadeResolve::Ready(true)) => ShadeSel::Shaded,
Some(ShadeResolve::Ready(false)) | None => ShadeSel::Matte,
// The tile is resident but still decoding — defer this prop a frame, exactly
// as the terrain lane does. Not reachable at sea (no tile ⇒ `Ready(false)`).
Some(ShadeResolve::Pending) => return true,
}
};
let (radius, center) = m2_fade(&m.bounds, prop.local.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
// The interior lane (0474): fold the cabin prop's committed light ONCE, composed
@@ -303,9 +329,7 @@ pub(super) fn spawn_wmo_gameobject_props(
forms.slices(&prop.handle),
prop.local, // doodad-LOCAL — the parent composes the world pose
&object,
// Deck props: plain matte, like a terrain exterior prop on lit ground (a boat is
// never MCSH-shadowed). Interior props: the selector is unread — the probe lane.
ShadeSel::Matte,
shade,
interior_slot,
radius,
center,
@@ -314,6 +338,7 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
true, // the entity-hosted lane: these props are lit by their own def (2047)
card_owner,
// Never diverted into 1417's production merge nor 1429's static-gx: these
// props parent under a MOVING gameobject, and every divert lane bakes world
+6
View File
@@ -112,6 +112,8 @@ mod name_persist;
mod shutdown;
mod smart_rect;
mod sound;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
mod textinput;
mod transport;
@@ -661,6 +663,10 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(BattlefieldPlugin)
.add_plugins(BattlefieldPositionsPlugin)
.add_plugins(TutorialPlugin)
// The melee swing refusals (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/
// `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an
// attack target stands and no swing lands.
.add_plugins(swing_refusal::SwingRefusalPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
+14 -1
View File
@@ -408,6 +408,10 @@ pub(crate) fn apply_net_updates(
// neighbours: the outer tuple is at the 16-param ceiling.
MessageWriter<PetTalkMessage>,
MessageWriter<PetDismissSoundMessage>,
// The swing-refusal seam's two edges (`crate::swing_refusal`, decision 2037): the four
// `SMSG_ATTACKSWING_*` refusals, and our own landed swing, which clears their latch.
// ONE writer for both so the drain hands them on in packet order.
MessageWriter<crate::swing_refusal::SwingRefusalEdge>,
),
),
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
@@ -1520,9 +1524,13 @@ pub(crate) fn apply_net_updates(
&mut audio.8,
&mut audio.15 .1,
&mut audio.15 .2,
&mut audio.15 .7,
play_seq.next(),
)
}
SessionEvent::AttackSwingError(e) => combat::attack_swing_error(e, &mut audio.15 .7),
SessionEvent::CancelCombat => combat::cancel_combat(&mut audio.15 .7),
SessionEvent::FeignDeathResisted => combat::feign_death_resisted(&mut ui_error_keys),
SessionEvent::SpellDamageLog(s) => {
combat_chat::spell_damage_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
combat_log::spell_damage_log(
@@ -2120,7 +2128,12 @@ pub(crate) fn apply_net_updates(
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
SessionEvent::TradeStatus { status } => trade::trade_status(status, &mut trade_session),
SessionEvent::TradeStatus { status } => trade::trade_status(
status,
&mut trade_session,
&mut ui_error_keys,
&net_commands,
),
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
+45 -1
View File
@@ -3,12 +3,14 @@
//! full-block synthesis. Each `pub(super)` fn here is exactly one arm's body; the match at the
//! call site stays the dispatcher, one call per arm.
use benilla_protocol::messages::AttackerState;
use benilla_protocol::messages::{AttackSwingError, AttackerState};
use bevy::prelude::*;
use crate::creature_anim::{
Engaged, RangedHold, SheathRequest, SwingFlush, SwingImpact, SwingMessage,
};
use crate::swing_refusal::SwingRefusalEdge;
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_unit::CombatTextEvent;
use super::super::{AiReactionMessage, GuidIndex, SelfGuid};
@@ -44,6 +46,39 @@ pub(super) fn attack_stop(
}
}
/// The server refused our melee swing (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/
/// `_CANT_ATTACK`) — forwarded verbatim to [`crate::swing_refusal`], which owns the latch, the 4 s
/// repeat, and arm 4's silent StopAttack. Nothing is decided here: the arms differ only in what
/// that module does with them, and it holds the write set for all three.
pub(super) fn attack_swing_error(
error: AttackSwingError,
edges: &mut MessageWriter<SwingRefusalEdge>,
) {
edges.write(SwingRefusalEdge::Refused(error));
}
/// `SMSG_CANCEL_COMBAT` — the server forced our attack to stop. The swing family's fourth arm,
/// and the same act as `0x148`/`0x149`: the reference's handler `0x5e7dd0` is arm 4's body
/// verbatim.
pub(super) fn cancel_combat(edges: &mut MessageWriter<SwingRefusalEdge>) {
edges.write(SwingRefusalEdge::CombatCancelled);
}
/// `SMSG_FEIGN_DEATH_RESISTED` — the target shrugged off our Feign Death.
///
/// One red line and nothing else: the reference's handler `0x6e9800` is `push 0x1a5; call
/// 0x496720`, a bare `DisplayError(421)` with no latch, no cooldown and no state — the opposite of
/// its sibling above, and the reason the two do not share a path. Catalog row 421 is
/// `ERR_FEIGN_DEATH_RESISTED`, whose 1.12 string is the single word "Resisted".
///
/// It lives beside the swing arms because vmangos sends it in the same breath as
/// `SMSG_CANCEL_COMBAT` (`Objects/Unit.cpp:9445-9451`: a resisted feign death cancels the attack
/// and says so), and finding one without the other is how this family stayed half-built.
pub(super) fn feign_death_resisted(errors: &mut UiErrorKeys) {
debug!("net: feign death resisted");
errors.0.push(UiError::key("ERR_FEIGN_DEATH_RESISTED"));
}
/// A creature flared aggro or a stealth pre-aggro alert (`SMSG_AI_REACTION`).
pub(super) fn ai_reaction(
unit: u64,
@@ -80,9 +115,18 @@ pub(super) fn attacker_state(
impacts: &mut MessageWriter<SwingImpact>,
center: &mut MessageWriter<CombatTextEvent>,
sheaths: &mut MessageWriter<SheathRequest>,
edges: &mut MessageWriter<SwingRefusalEdge>,
seq: u64,
) {
let victim = index.0.get(&s.victim).copied();
// Arm 5's `0x6259b6 call 0x5ea800`, whose first act is the swing-refusal latch clear
// (`0x5ecdb0(0)`) — gated exactly as the reference gates it: the attacker IS the active player
// (`0x5fa6d0`, a guid compare) and the victim resolves as a streamed unit. Written from here
// rather than computed downstream because this is the only place holding both guids, and it
// keeps the clear in packet order with the refusals (`crate::swing_refusal`).
if self_guid.0 == Some(s.attacker) && victim.is_some() {
edges.write(SwingRefusalEdge::Landed);
}
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
+477 -26
View File
@@ -7,40 +7,111 @@
use benilla_protocol::messages::{TradeStatus, TradeStatusExtended};
use crate::ui_trade::TradeSession;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_trade::{NamedLine, TradeSession};
/// `SessionEvent::TradeStatus` (`SMSG_TRADE_STATUS`) — the trade state machine (decision 0592 P1).
/// `SessionEvent::TradeStatus` (`SMSG_TRADE_STATUS`) — benilla's face of the reference's own
/// 23-case dispatcher `CGTradeInfo 0x4bf720` (`ecx` = the status code; jump table `0x4bfa08`;
/// sole caller `0x5d4923`, inside the `SMSG_TRADE_STATUS` handler `0x5d47c0`). Every arm below is
/// that function's, walked (wow-re `ui/scratch/incoming-trade-request-law.md` §11).
///
/// **Three different things a status can do, and they are not the same set.**
///
/// 1. **Print.** Eighteen of the twenty-three arms raise a `CGGameUI::DisplayError` message —
/// [`status_message`] carries sixteen of them, `BEGIN_TRADE`'s belongs to the ladder that
/// answers it, and `CLOSE_WINDOW`'s is the one deliberate gap (the table's own doc says why).
/// The arc raised four before this, which is the report it answers.
/// 2. **Close the window** (`0x4bf4e0(0, 0)` → `TRADE_CLOSED`). **Three** arms do: `CANCELED`,
/// `COMPLETE`, `CLOSE_WINDOW`. Two more touch the window without closing it (`OPEN_WINDOW`
/// opens it, `ONLY_CONJURED` bounces one slot out of our offer).
/// 3. **Clear the network cells** — the outbound-initiate latch and the pending guid — which is
/// the *handler's* common tail (`0x5d490a`), not the arm's, and runs on every code except
/// `{1, 2, 4, 7, 9, 22}`. [`TradeSession::clear_pending`].
///
/// benilla had (2) and (3) fused: seventeen statuses called a full window close. On vmangos that
/// is unobservable — every code in (3) that is not in (2) arrives as an *initiate* refusal, with
/// no window open — but it made the code claim `TARGET_STUNNED` tears down a trade window, and it
/// got `REJECTED` and `UNKNOWN_13` wrong in opposite directions (decision 1764 wrote both down as
/// unsettled; §11 settles them).
///
/// `BEGIN_TRADE` records the incoming request **without answering it** (decision 1764 — the reply
/// is a policy with five gates and the player's own consent, so
/// [`crate::ui_trade::answer_trade_request`] owns it); `OPEN_WINDOW` opens the window;
/// `TRADE_ACCEPT`/`BACK_TO_TRADE` drive the accept glow; the terminal and refusal codes close it.
/// The refusal statuses' red error text is decision 0592 P3; `ONLY_CONJURED` (a rejected item
/// placement, not a window close) and any out-of-range code are inert.
pub(super) fn trade_status(status: TradeStatus, trade: &mut TradeSession) {
/// is a ladder of eight gates, so [`crate::ui_trade::answer_trade_request`] owns it, and it is the
/// only arm of the reference's dispatcher that speaks to the wire).
pub(super) fn trade_status(
status: TradeStatus,
trade: &mut TradeSession,
errors: &mut UiErrorKeys,
commands: &NetCommands,
) {
bevy::log::info!(target: "trade", "SMSG_TRADE_STATUS {status:?}");
// ── The message, first, because the arms that name a player must read the guid cells BEFORE
// the tail clears them — which is exactly the reference's order (`0x5d4923` calls the
// dispatcher, `0x5d4931` clears). A `%s` line is parked for the feed to name; the rest go
// straight out, since nothing about them can block. ──────────────────────────────────────
match status_message(status) {
Some(Line::Plain(key)) => errors.0.push(UiError::key(key)),
Some(Line::Naming(key)) => {
if let Some(who) = trade.line_names() {
trade.owe_named_line(NamedLine {
key,
who,
// These arms read the name off the live `CGUnit` and print nothing when the
// object is gone — the reference's second guard, which the feed applies where
// it resolves the name.
needs_live_object: true,
});
}
}
None => {}
}
match status {
TradeStatus::BeginTrade { partner } => trade.request(partner),
TradeStatus::OpenWindow => trade.open_window(),
// Case 4 (`0x4bf80b`) sets the PARTNER's accept flag; case 9 (`0x4bf821`) clears it.
// Neither touches ours, neither closes anything, and neither is in the tail's clear set.
TradeStatus::Accept => trade.partner_accepted(),
TradeStatus::BackToTrade => trade.back_to_trade(),
// Terminal (complete/cancel/close) + every initiate/in-trade refusal → close the window.
TradeStatus::Rejected => trade.partner_unaccepted(),
// Case 7 (`0x4bf81a`) resets BOTH accept flags — and the "mine" half is `0x4bf230(0)`,
// which is idempotent and, when it really had to change the flag, **also sends
// `CMSG_UNACCEPT_TRADE`**. So the reference answers its own bounced accept on the wire,
// and vmangos's `HandleUnacceptTradeOpcode` → `SetAccepted(false, crosssend)` passes a
// `BACK_TO_TRADE` on to the partner. benilla dropped the glow locally and said nothing.
TradeStatus::BackToTrade => {
if trade.we_accepted() {
let _ = commands.0.send(ClientCommand::UnacceptTrade);
}
trade.back_to_trade();
}
// The three that really do tear the window down. `CANCELED` also signals
// `TRADE_REQUEST_CANCEL` (`0x4bf832`) — the ONE Lua event this whole dispatcher fires
// (wow-re `re/events/event-firesites.tsv` finds no other `SignalEvent` in
// `[0x4bf720, 0x4bfa08)`) — and it signals it BEFORE the close, which is why the flag
// survives [`TradeSession::close_window`].
//
// **Two statuses sit outside the reference's own clear set, and neither is reachable on
// vmangos** (decision 1764, noticed rather than chased). The reference clears its trade
// cells in the status handler's common tail on 0, 3, 5, 6, 8, 10, 11, 12, 13 and 14–21;
// this list is that set minus `Rejected` (9), which we close on and it does not, plus
// `Unknown13` (13), which it clears on and we treat as inert below. vmangos sends neither,
// so there is no observable to settle it against — which is exactly why it is written down
// here instead of being guessed at in either direction.
TradeStatus::Canceled
| TradeStatus::Complete
| TradeStatus::CloseWindow { .. }
| TradeStatus::Rejected
| TradeStatus::Busy
// Each of the three also sends `CMSG_CANCEL_TRADE` from inside `0x4bf4e0`. benilla does
// not send it here and does not need to: the window is the stock `TradeFrame.xml`, whose
// `OnHide` calls `CloseTrade()` — so the reference sends that packet TWICE on a
// server-driven close (once from the arm, once from the frame) and benilla sends the
// second one already, through the same stock file. vmangos's `HandleCancelTradeOpcode`
// no-ops once `m_trade` is gone, so the first is a redundancy, not a behaviour.
TradeStatus::Canceled => {
trade.signal_request_cancel();
trade.close_window();
}
TradeStatus::Complete | TradeStatus::CloseWindow { .. } => trade.close_window(),
// The placement is bounced, not the window (case 22, `0x4bf9ec` → `0x4bfbd0`).
TradeStatus::OnlyConjured { slot } => trade.bounce_own_offer(slot),
// Everything else: the handler's tail clear, and nothing else. These are the initiate
// refusals — each has already queued its line above.
TradeStatus::Busy
| TradeStatus::Busy2
| TradeStatus::NoTarget
| TradeStatus::TargetTooFar
| TradeStatus::WrongFaction
| TradeStatus::Unknown13
| TradeStatus::IgnoreYou
| TradeStatus::YouStunned
| TradeStatus::TargetStunned
@@ -48,13 +119,100 @@ pub(super) fn trade_status(status: TradeStatus, trade: &mut TradeSession) {
| TradeStatus::TargetDead
| TradeStatus::YouLogout
| TradeStatus::TargetLogout
| TradeStatus::TrialAccount => trade.close(),
// "You can only trade conjured items" bounces the placement, not the window; UNKNOWN_13 and
// any out-of-range code are inert.
TradeStatus::OnlyConjured { .. } | TradeStatus::Unknown13 | TradeStatus::Unknown(_) => {}
| TradeStatus::TrialAccount => trade.clear_pending(),
// A code outside `0..=0x16` takes the dispatcher's own `ja` to the bare epilogue
// `0x4bfa02` — the same target case 13 has. vmangos never sends one.
TradeStatus::Unknown(_) => {}
}
}
/// A message a status arm raises: either a plain key, or one whose `%s` names a player and so has
/// to wait for [`crate::names::NameCache`].
enum Line {
Plain(&'static str),
Naming(&'static str),
}
/// **What each status arm prints** — the `GlobalStrings` key, or `None`.
///
/// Five arms genuinely say nothing (`OPEN_WINDOW`, `TRADE_ACCEPT`, `BACK_TO_TRADE`, `REJECTED`,
/// `UNKNOWN_13`); the other eighteen print. Two of those eighteen answer `None` *here* for reasons
/// of their own: `BEGIN_TRADE`'s line is the ladder's
/// ([`crate::ui_trade::answer_trade_request`]'s leg 8), and `CLOSE_WINDOW`'s is the gap at the
/// bottom of this doc.
///
/// **Eleven of the ids are outside the trade block** `0xb9..=0xbf` — which is why the arc shipped
/// four of them and read as done: that block's own emit-site census answers "which arms print a
/// *trade-keyed* message", and that is a different question from "which arms print". The full walk
/// is wow-re §11; the tell was in the reference's own interface, where `ERR_TARGET_STUNNED`'s
/// `GlobalStrings.lua` comment reads `-- Trade failure`.
///
/// | # | status | id | key | catalog kind |
/// |---|---|---|---|---|
/// | 0 | `BUSY` | `0x3e` | `ERR_PLAYER_BUSY_S` | `0` chat, **names the player** |
/// | 5 | `BUSY_2` | `0x3e` | `ERR_PLAYER_BUSY_S` | `0` chat, names |
/// | 3 | `CANCELED` | `0xbe` | `ERR_TRADE_CANCELLED` | `1` yellow |
/// | 6 | `NO_TARGET` | `0xb8` | `ERR_GENERIC_NO_TARGET` | `2` red |
/// | 8 | `COMPLETE` | `0xbf` | `ERR_TRADE_COMPLETE` | `1` yellow |
/// | 10 | `TARGET_TOO_FAR` | `0xbd` | `ERR_TRADE_TOO_FAR` | `2` red |
/// | 11 | `WRONG_FACTION` | `0xff` | `ERR_PLAYER_WRONG_FACTION` | `2` red |
/// | 14 | `IGNORE_YOU` | `0x13d` | `ERR_IGNORING_YOU_S` | `0` chat, names |
/// | 15 | `YOU_STUNNED` | `0x191` | `ERR_GENERIC_STUNNED` | `2` red |
/// | 16 | `TARGET_STUNNED` | `0x192` | `ERR_TARGET_STUNNED` | `2` red |
/// | 17 | `YOU_DEAD` | `0x7e` | `ERR_PLAYER_DEAD` | `2` red |
/// | 18 | `TARGET_DEAD` | `0xbc` | `ERR_TRADE_TARGET_DEAD` | `2` red |
/// | 19 | `YOU_LOGOUT` | `0x19d` | `ERR_LOGGING_OUT` | `2` red |
/// | 20 | `TARGET_LOGOUT` | `0x19e` | `ERR_TARGET_LOGGING_OUT` | `2` red |
/// | 21 | `TRIAL_ACCOUNT` | `0x1be` | `ERR_RESTRICTED_ACCOUNT` | `2` red |
/// | 22 | `ONLY_CONJURED` | `0x1ca` | `ERR_TRADE_WRONG_REALM` | `2` red |
///
/// Case 1's own line (`ERR_TRADE_BLOCKED_S`, leg 8 only) is
/// [`crate::ui_trade::answer_trade_request`]'s, and case 12's is the gap below.
///
/// **Nothing here decides a surface.** Three of these are chat lines and thirteen are toasts,
/// split between yellow and red, and the split does not follow the English: the two *successful*
/// outcomes are the yellow ones. `ui_action::feed_actions` reads each row's `+0x04`.
/// `ERR_TRADE_WRONG_REALM`'s key is actively misleading — its text is the ONLY_CONJURED sentence.
///
/// **The one arm not built: `CLOSE_WINDOW` (12) prints a *computed* id**, not a fixed one —
/// `0x4bfd70` maps the packet's `result` word (our [`TradeStatus::CloseWindow::result`]) over a
/// **61-id** range, tail-jumping `0x622630` for everything it does not special-case, with `0x1d1`
/// as a suppression sentinel. vmangos never sends status 12 (`SharedDefines.h` defines it;
/// `TradeHandler.cpp` sends it nowhere), so there is no observable to build against — and wow-re
/// flagged a self/other polarity inconsistency inside that mapper that wants a live capture to
/// settle. Transcribing 61 rows nothing can reach, around a known-doubtful axis, is how a table
/// gets written wrong and believed; the window still closes on 12, it just says nothing.
fn status_message(status: TradeStatus) -> Option<Line> {
Some(match status {
TradeStatus::Busy | TradeStatus::Busy2 => Line::Naming("ERR_PLAYER_BUSY_S"),
TradeStatus::IgnoreYou => Line::Naming("ERR_IGNORING_YOU_S"),
TradeStatus::Canceled => Line::Plain("ERR_TRADE_CANCELLED"),
TradeStatus::NoTarget => Line::Plain("ERR_GENERIC_NO_TARGET"),
TradeStatus::Complete => Line::Plain("ERR_TRADE_COMPLETE"),
TradeStatus::TargetTooFar => Line::Plain("ERR_TRADE_TOO_FAR"),
TradeStatus::WrongFaction => Line::Plain("ERR_PLAYER_WRONG_FACTION"),
TradeStatus::YouStunned => Line::Plain("ERR_GENERIC_STUNNED"),
TradeStatus::TargetStunned => Line::Plain("ERR_TARGET_STUNNED"),
TradeStatus::YouDead => Line::Plain("ERR_PLAYER_DEAD"),
TradeStatus::TargetDead => Line::Plain("ERR_TRADE_TARGET_DEAD"),
TradeStatus::YouLogout => Line::Plain("ERR_LOGGING_OUT"),
TradeStatus::TargetLogout => Line::Plain("ERR_TARGET_LOGGING_OUT"),
TradeStatus::TrialAccount => Line::Plain("ERR_RESTRICTED_ACCOUNT"),
TradeStatus::OnlyConjured { .. } => Line::Plain("ERR_TRADE_WRONG_REALM"),
// Silent: `OPEN_WINDOW`, `TRADE_ACCEPT`, `BACK_TO_TRADE`, `REJECTED`, `UNKNOWN_13` — and
// `BEGIN_TRADE`, whose one line belongs to the ladder that answers it. `CLOSE_WINDOW`'s
// computed id is the gap named above.
TradeStatus::BeginTrade { .. }
| TradeStatus::OpenWindow
| TradeStatus::Accept
| TradeStatus::BackToTrade
| TradeStatus::Rejected
| TradeStatus::CloseWindow { .. }
| TradeStatus::Unknown13
| TradeStatus::Unknown(_) => return None,
})
}
/// `SessionEvent::TradeStatusExtended` (`SMSG_TRADE_STATUS_EXTENDED`) — replace one side's item/gold
/// snapshot (decision 0592 P1); the feed repaints (`TRADE_UPDATE`) on the change.
pub(super) fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut TradeSession) {
@@ -67,3 +225,296 @@ pub(super) fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut Trade
);
trade.set_offer(ext);
}
#[cfg(test)]
mod tests {
use benilla_ui::messages::{kind_of, MsgKind};
use super::*;
/// Every status code, as the wire delivers it — the tail-carrying three with a payload.
fn every_status() -> Vec<TradeStatus> {
vec![
TradeStatus::Busy,
TradeStatus::BeginTrade { partner: 0x7 },
TradeStatus::OpenWindow,
TradeStatus::Canceled,
TradeStatus::Accept,
TradeStatus::Busy2,
TradeStatus::NoTarget,
TradeStatus::BackToTrade,
TradeStatus::Complete,
TradeStatus::Rejected,
TradeStatus::TargetTooFar,
TradeStatus::WrongFaction,
TradeStatus::CloseWindow {
result: 0,
item_limit_category: 0,
},
TradeStatus::Unknown13,
TradeStatus::IgnoreYou,
TradeStatus::YouStunned,
TradeStatus::TargetStunned,
TradeStatus::YouDead,
TradeStatus::TargetDead,
TradeStatus::YouLogout,
TradeStatus::TargetLogout,
TradeStatus::TrialAccount,
TradeStatus::OnlyConjured { slot: 2 },
]
}
/// A filled trade slot — only the fields the offer model reads matter here.
fn item() -> benilla_protocol::messages::TradeItem {
benilla_protocol::messages::TradeItem {
entry: 1234,
display_id: 1,
count: 1,
wrapped: false,
gift_creator: 0,
perm_enchant: 0,
creator: 0,
charges: 0,
suffix_factor: 0,
random_prop_id: 0,
lock_id: 0,
max_durability: 0,
durability: 0,
}
}
fn sink() -> (
UiErrorKeys,
NetCommands,
crossbeam_channel::Receiver<ClientCommand>,
) {
let (tx, rx) = crossbeam_channel::unbounded();
(UiErrorKeys::default(), NetCommands(tx), rx)
}
/// A session mid-trade with a window up, both sides accepted — so a wrong close, a wrong
/// accept reset or a wrong cell clear all have something to damage.
fn open_trade() -> TradeSession {
let mut trade = TradeSession::default();
trade.initiate(0x7);
trade.open_window();
trade.accept();
trade.partner_accepted();
trade.take_named_lines_for_test();
trade
}
/// **Eighteen of the twenty-three arms print; this table carries sixteen.** The arc raised
/// four, which is the report.
///
/// Five arms are genuinely silent — the window opening, the two accept-flag pokes, the accept
/// bounce, and `UNKNOWN_13`'s bare epilogue. Two more answer `None` here on purpose:
/// `BEGIN_TRADE`'s line belongs to the ladder that answers it, and `CLOSE_WINDOW`'s is a
/// computed 61-id mapper nothing on this server can reach. Pinning the whole set in one list
/// is what stops a future edit quietly re-silencing one.
#[test]
fn sixteen_arms_print_here_and_the_seven_silent_ones_are_pinned() {
let silent: Vec<u32> = every_status()
.into_iter()
.filter(|s| status_message(*s).is_none())
.map(TradeStatus::code)
.collect();
assert_eq!(
silent,
vec![1, 2, 4, 7, 9, 12, 13],
"silent: BEGIN_TRADE (the ladder's line is its own), OPEN_WINDOW, TRADE_ACCEPT, \
BACK_TO_TRADE, REJECTED, CLOSE_WINDOW (a computed id we deliberately do not build) \
and UNKNOWN_13"
);
}
/// **Every key names a real catalog row, and the surface comes from the row.** The split is
/// not guessable and not ours: the two *successful* outcomes are the yellow ones, two of the
/// refusals are chat lines, and the rest are red.
#[test]
fn the_surfaces_are_the_catalogs_and_not_the_key_names() {
for status in every_status() {
let Some(line) = status_message(status) else {
continue;
};
let key = match line {
Line::Plain(k) | Line::Naming(k) => k,
};
assert!(
benilla_ui::messages::by_key(key).is_some(),
"{status:?} names {key}, which is not a catalog row"
);
}
// The three that are NOT red toasts — the half a re-implementation gets wrong.
assert_eq!(kind_of("ERR_PLAYER_BUSY_S"), MsgKind::Chat);
assert_eq!(kind_of("ERR_IGNORING_YOU_S"), MsgKind::Chat);
assert_eq!(kind_of("ERR_TRADE_CANCELLED"), MsgKind::Info);
assert_eq!(kind_of("ERR_TRADE_COMPLETE"), MsgKind::Info);
// …against a control from the same table.
assert_eq!(kind_of("ERR_TRADE_TARGET_DEAD"), MsgKind::Error);
}
/// **Only three arms close the window** (`0x4bf4e0` has four call sites inside the dispatcher:
/// one to open, three to close). benilla closed on seventeen — it was writing the *handler's*
/// network-cell clear as a window teardown.
#[test]
fn only_canceled_complete_and_close_window_tear_the_window_down() {
let closes: Vec<u32> = every_status()
.into_iter()
.filter(|status| {
let (mut errors, commands, _rx) = sink();
let mut trade = open_trade();
trade_status(*status, &mut trade, &mut errors, &commands);
!trade.is_open()
})
.map(TradeStatus::code)
.collect();
assert_eq!(closes, vec![3, 8, 12]);
}
/// The fourteen that clear the network cells and **leave the window alone** — the tail's set
/// minus the three that also close. Each drops the outbound-initiate latch (which is what
/// gates the `%s` lines) without touching the offer or the accepts.
#[test]
fn the_refusals_clear_the_latch_and_leave_the_window_standing() {
for status in every_status() {
let code = status.code();
if !matches!(code, 0 | 5 | 6 | 10 | 11 | 13 | 14..=21) {
continue;
}
let (mut errors, commands, _rx) = sink();
let mut trade = open_trade();
trade_status(status, &mut trade, &mut errors, &commands);
assert!(trade.is_open(), "{status:?} must not close the window");
assert!(
trade.line_names().is_none(),
"{status:?} must drop the initiate latch"
);
assert!(
trade.we_accepted(),
"{status:?} must not touch the accept glow"
);
}
}
/// `REJECTED` drops **the partner's** accept and nothing else — no close, no line, no cell
/// clear. benilla closed the whole session; 1764 wrote the disagreement down rather than
/// guessing, and the 23-arm walk settles it. vmangos never sends 9, so this is faithfulness
/// with no observable, which is exactly when it is cheapest to be right.
#[test]
fn rejected_only_drops_the_partners_accept() {
let (mut errors, commands, _rx) = sink();
let mut trade = open_trade();
trade_status(TradeStatus::Rejected, &mut trade, &mut errors, &commands);
assert!(trade.is_open());
assert!(trade.we_accepted(), "ours is untouched");
assert!(!trade.partner_has_accepted(), "theirs is dropped");
assert!(errors.0.is_empty(), "and it says nothing");
assert!(trade.line_names().is_some(), "9 is not in the clear set");
}
/// **A bounced accept answers on the wire.** `0x4bf230(0)` is idempotent and, when it really
/// changes the flag, tail-jumps the `CMSG_UNACCEPT_TRADE` sender — so the reference tells the
/// server it withdrew, and vmangos passes a `BACK_TO_TRADE` on to the partner. benilla dropped
/// the glow locally and sent nothing.
#[test]
fn back_to_trade_unaccepts_on_the_wire_but_only_if_we_had_accepted() {
let (mut errors, commands, rx) = sink();
let mut trade = open_trade(); // we accepted
trade_status(TradeStatus::BackToTrade, &mut trade, &mut errors, &commands);
assert!(matches!(rx.try_recv(), Ok(ClientCommand::UnacceptTrade)));
assert!(!trade.we_accepted());
assert!(errors.0.is_empty(), "the bounce is silent");
// Idempotent: a second bounce with no accept up sends nothing.
trade_status(TradeStatus::BackToTrade, &mut trade, &mut errors, &commands);
assert!(rx.try_recv().is_err());
}
/// `ONLY_CONJURED` bounces the offending **slot**, not the window — and `0xff` names the money
/// instead. Out-of-range is dropped: the reference indexes its 7-slot mirror with the wire
/// byte unchecked, and reproducing that is not reproducing a behaviour.
#[test]
fn only_conjured_bounces_one_slot_and_leaves_the_window_up() {
// Wire slot 1 is UI slot 2 — the one `place_own_item` fills below.
for (slot, expect_gold, expect_slot2) in
[(1u8, 700u32, false), (0xff, 0, true), (200, 700, true)]
{
let (mut errors, commands, _rx) = sink();
let mut trade = open_trade();
trade.place_own_item(2, item());
trade.set_own_gold(700);
trade_status(
TradeStatus::OnlyConjured { slot },
&mut trade,
&mut errors,
&commands,
);
assert!(trade.is_open(), "slot {slot}: the window stays up");
let (gold, filled) = trade.own_offer_for_test();
assert_eq!(gold, expect_gold, "slot {slot}");
assert_eq!(filled[1], expect_slot2, "slot {slot}");
assert_eq!(errors.0, vec![UiError::key("ERR_TRADE_WRONG_REALM")]);
}
}
/// **A `%s` line prints only for the side that asked.** Cases 0/5/14 read the network
/// handler's guid cell and are guarded on the outbound-initiate latch `[0xc4bec8]`, which
/// exactly one instruction image-wide sets — inside `InitiateTrade`.
///
/// That guard is load-bearing rather than decorative: vmangos's `HandleIgnoreTradeOpcode`
/// calls `TradeCancel(sendback = true, IGNORE_YOU)`, so the client that just *refused* a trade
/// receives `IGNORE_YOU` back at itself with the initiator's guid still in the cell. The latch
/// is what keeps that silent. A client without it would print "Grubbis is ignoring you." to
/// the person who did the ignoring — inventing a quirk, not reproducing one.
#[test]
fn the_naming_lines_are_gated_on_having_initiated() {
for status in [
TradeStatus::Busy,
TradeStatus::Busy2,
TradeStatus::IgnoreYou,
] {
// We asked: the line is owed, naming our target.
let (mut errors, commands, _rx) = sink();
let mut trade = TradeSession::default();
trade.initiate(0x7);
trade.take_named_lines_for_test();
trade_status(status, &mut trade, &mut errors, &commands);
assert_eq!(
trade.take_named_lines_for_test(),
vec![NamedLine {
key: match status {
TradeStatus::IgnoreYou => "ERR_IGNORING_YOU_S",
_ => "ERR_PLAYER_BUSY_S",
},
who: 0x7,
needs_live_object: true,
}],
"{status:?}: the asker is told, and told about its target"
);
assert!(errors.0.is_empty(), "a %s line waits for the name cache");
// We did not ask — the refuser's own echo. Silent.
let (mut errors, commands, _rx) = sink();
let mut trade = TradeSession::default();
trade.request(0x7);
trade.begin(0x7); // accepted an INCOMING request: the latch was never set
trade_status(status, &mut trade, &mut errors, &commands);
assert!(
trade.take_named_lines_for_test().is_empty() && errors.0.is_empty(),
"{status:?}: the side that did not initiate hears nothing"
);
}
}
/// The plain lines need no name and go straight out, in the same drain the packet arrived in.
#[test]
fn the_plain_refusals_are_raised_immediately() {
let (mut errors, commands, _rx) = sink();
let mut trade = TradeSession::default();
trade.initiate(0x7);
trade.take_named_lines_for_test();
trade_status(TradeStatus::TargetDead, &mut trade, &mut errors, &commands);
assert_eq!(errors.0, vec![UiError::key("ERR_TRADE_TARGET_DEAD")]);
}
}
+325
View File
@@ -0,0 +1,325 @@
//! **The melee swing refusal** — what the client does with the server's answers to a
//! `CMSG_ATTACKSWING` it will not honour.
//!
//! **Six opcodes, FOUR arms.** `0x145` NOTINRANGE and `0x146` BADFACING take one each; `0x148`
//! DEADTARGET and `0x149` CANT_ATTACK share arm 4 verbatim; `0x147` NOTSTANDING is never wired at
//! all; and `0x14e` `SMSG_CANCEL_COMBAT` — the forced cancel, registered by the *spell* TU
//! (`0x5e3308` → handler `0x5e7dd0`), which is why `0x625520`'s eight-opcode census never names it
//! — is arm 4's body **byte-identical**, down to the `__LINE__` it pushes. So the whole family is
//! two arms that latch a message and two that silently stop the attack.
//!
//! The server refuses a swing on the **edge** alone (vmangos `Unit::AttackerStateUpdate`'s
//! `swingError` switch, `Objects/Unit.cpp:405-475`: each arm sends only when
//! `GetLastSwingErrorMsg() != <result>`), so a player standing out of range gets **one** packet,
//! not one per swing timer. The repeat the player actually sees is the client's own, and it is a
//! **latch plus a cooldown**, never a per-packet toast:
//!
//! ```text
//! 0x625a8a (0x145 NOTINRANGE) ecx = 1 ; call 0x5ecdb0 ; latch code 1
//! 0x625aa1 (0x146 BADFACING) ecx = 2 ; call 0x5ecdb0 ; latch code 2
//! 0x625ab8 (0x148 DEADTARGET / 0x149 CANT_ATTACK) ; NO message — StopAttack, nothing else
//!
//! 0x5ecdb0 [0xc4d758] = code ; [0xc4c270] = OsGetAsyncTimeMs() ; the code + "show at"
//!
//! 0x5ec950 the local player's tick:
//! this is the active player (0x468550) else out
//! [this+0xc48] != 0 (0x47bf60) else out ; an attack target is set
//! now - [0xc4c270] >= 0 (5ec999 js) else out
//! [0xc4d758] == 1 -> DisplayError(0xd8) | == 2 -> DisplayError(0xd7) | else out
//! [0xc4c270] = now + 0xfa0 ; re-arm, 4000 ms
//! ```
//!
//! Three consequences, all of them visible, none of them guessable from the packet alone:
//!
//! 1. **The first line is immediate** — the latch writes `now`, and the tick's test is
//! `now - stored >= 0`, so the very next tick shows it.
//! 2. **It repeats every 4 s while the refusal stands**, because nothing about the *packet*
//! drives the display; the latch does, and it survives until something clears it.
//! 3. **A dead or unattackable target says nothing at all.** `0x148` and `0x149` share arm 4
//! verbatim — the client cannot even tell them apart — and that arm raises no message on any
//! surface: it resolves the active player and calls StopAttack
//! ([`crate::creature_anim::stop_attack_local`]) on it. Which is right: the target is dead or
//! friendly, the attack ends, and the reason is already on screen from elsewhere.
//!
//! **What clears the latch is a landed swing.** `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800`
//! (at `0x6259b6`) when the attacker **is the active player** (`0x5fa6d0`, a guid compare against
//! `0x468550`) and the **victim resolves** as a streamed unit — and `0x5ea800`'s first act, ahead
//! of everything else it does, is `0x5ecdb0(0)`. So the moment a swing connects the stale "too far
//! away" stops repeating; while none does, it keeps saying so. `0x5e2510`, a module init, clears
//! it the same way.
//!
//! Both edges ride ONE message ([`SwingRefusalEdge`]) written by two arms of the same net drain,
//! so they are consumed in **packet order** — the reference processes them in one synchronous
//! dispatch, and a refusal and a landing that arrive in the same frame must not be reordered.
//!
//! Two consequences of the latch being written **unconditionally** — both reproduced here, both
//! easy to lose in a refactor:
//!
//! - a **second** refusal packet resets the deadline to `now`, so it **defeats the 4 s cooldown**
//! and re-shows on the next tick; a NOTINRANGE→BADFACING flip shows at once for the same reason;
//! - the **re-arm precedes the print** (`0x5ec9bc` then `0x5ec9c2`), so the 4 s advance happens
//! even when the line is then swallowed by a missing GlobalString.
//!
//! `SMSG_ATTACKSWING_NOTSTANDING` (`0x147`) is in neither this module nor
//! [`benilla_protocol::messages::opcode`]: the reference never registers it, and vmangos never
//! sends it (`Player::SendAttackSwingNotStanding` has zero callers).
//!
//! Whole seam §5-verified in wow-re `object-layer/scratch/attackswing-refusal-law.md` (decision
//! 2037), which corrected three prior glosses of these same addresses.
use std::time::Duration;
use benilla_protocol::messages::AttackSwingError;
use bevy::prelude::*;
use crate::creature_anim::{AttackSeam, Engaged};
use crate::net::SelfPlayer;
use crate::ui_action::{UiError, UiErrorKeys};
/// The reference's re-arm interval — `0x5ec9b6 add ecx,0xfa0`.
const REPEAT: Duration = Duration::from_millis(0xfa0);
/// One edge of the swing-refusal seam, off the net drain in **packet order** — see the module
/// header for why they share a message rather than taking one each.
#[derive(Message, Clone, Copy, Debug)]
pub(crate) enum SwingRefusalEdge {
/// The server refused our swing (`0x145`/`0x146`/`0x148`/`0x149`).
Refused(AttackSwingError),
/// `SMSG_CANCEL_COMBAT` (`0x14e`) — the server forced our attack to stop
/// (`Unit::CombatStop`, `Unit::StopAttackFaction`, `Unit::InterruptAttacksOnMe`, a resisted
/// feign death). Its own message rather than a fifth `AttackSwingError`, because it is a
/// different opcode family; that its ARM is the same one is the finding, and it is expressed
/// where the arms are dispatched.
CombatCancelled,
/// One of OUR swings landed on a victim we can see — arm 5's `0x5ea800` call at `0x6259b6`,
/// whose first act is the latch clear. **Both** of the reference's gates are applied at the
/// write site, which is the only place holding the guids: GATE A, the attacker is the local
/// player (`0x6259a8 test al,al; je`, `0x5fa6d0`), and GATE B, the victim resolves
/// (`0x6259b1 test eax,eax; je`). The §5's first pass glossed B away ("any
/// ATTACKERSTATEUPDATE with the local player as attacker"); the disassembly it quoted did not,
/// and a re-derivation confirmed the reading and corrected the note (wow-re `fc385895`, which
/// also proved the leg is entered by fall-through alone — 0 branches into `[0x6259ac,
/// 0x6259b7]`, 1 rel32 caller of `0x5ea800`, 0 address-takes).
///
/// GATE B is exactly `0x468460(ecx = 8)`, i.e. **"resolves, as a UNIT"** — its two zero
/// returns are "guid absent from the object index" and "resolved but the type mask misses the
/// bit". benilla's index lookup covers the first and not the second, and the gap is
/// unreachable: this packet's victim is a unit or a player server-side, and a player's mask
/// carries the UNIT bit too. Stated rather than tightened, because the sibling uses of the
/// same lookup two lines away (the full-block synthesis, the impact fallback) ask the index
/// the same question, and one arm asking it differently would read as a distinction.
Landed,
}
/// The latched refusal — the reference's `[0xc4d758]` (which code) and `[0xc4c270]` (when the next
/// line is due).
///
/// **One `Option` over the pair**, not two fields, because the reference's timestamp has no meaning
/// while its code is `0`: `0x5ecdb0` writes both in the same breath, and the tick reads the second
/// only after the first has passed its `cmp eax,1`/`cmp eax,2`. `None` IS code `0` — the value both
/// clear sites push, and the value the tick falls through on. The `Duration` is an absolute time,
/// not a countdown, exactly as the reference stores it.
///
/// The §5 warns a re-implementation modelling this as `(Option<Code>, Instant)` not to assume the
/// timestamp is zero when the code is — the reference's clear writes `now`, not `0`. Pairing them
/// inside the `Option` is how that warning is answered rather than obeyed: there is no timestamp
/// to be wrong about while the code is absent, and every transition into `Some` writes a fresh
/// one.
#[derive(Resource, Default)]
struct SwingRefusal(Option<(Latched, Duration)>);
/// The two refusals that latch. [`AttackSwingError::DeadOrUnattackable`] is deliberately absent:
/// arm 4 writes no latch, so there is no third code to hold.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Latched {
/// Code `1` → `DisplayError(0xd8)` = `ERR_BADATTACKPOS`, "You are too far away!".
NotInRange,
/// Code `2` → `DisplayError(0xd7)` = `ERR_BADATTACKFACING`, "You are facing the wrong way!".
BadFacing,
}
impl Latched {
/// The `GlobalStrings.lua` key the reference's error id resolves to — catalog rows `0xd8`/
/// `0xd7` ([`benilla_ui::messages`]) — so the text and its localization come out of the
/// player's own file like every other line.
const fn key(self) -> &'static str {
match self {
Self::NotInRange => "ERR_BADATTACKPOS",
Self::BadFacing => "ERR_BADATTACKFACING",
}
}
}
impl SwingRefusal {
/// `0x5ecdb0(ecx = code)` — latch the refusal and make the next line due **now**.
fn latch(&mut self, code: Latched, now: Duration) {
self.0 = Some((code, now));
}
/// `0x5ecdb0(ecx = 0)` — the clear both of the reference's zero sites push.
fn clear(&mut self) {
self.0 = None;
}
}
/// Consume this frame's edges in packet order: latch a refusal, clear on a landing, and run the
/// shared `0x148`/`0x149` arm's one and only act.
///
/// The StopAttack lives here rather than in the net drain because [`AttackSeam`] is the whole
/// write set `0x5ecac0` needs and the drain is already at Bevy's SystemParam ceiling — and
/// because putting it beside the latch keeps arm 4's "stop, and say nothing" legible as one fact.
fn apply_swing_refusals(
mut edges: MessageReader<SwingRefusalEdge>,
time: Res<Time>,
mut refusal: ResMut<SwingRefusal>,
mut seam: AttackSeam,
engaged: Query<(), (With<SelfPlayer>, With<Engaged>)>,
) {
for edge in edges.read() {
match edge {
SwingRefusalEdge::Refused(AttackSwingError::NotInRange) => {
debug!("swing refused: out of range");
refusal.latch(Latched::NotInRange, time.elapsed());
}
SwingRefusalEdge::Refused(AttackSwingError::BadFacing) => {
debug!("swing refused: bad facing");
refusal.latch(Latched::BadFacing, time.elapsed());
}
// The two SILENT arms — arm 4 (`0x625ab8`, for `0x148`/`0x149`) and
// `SMSG_CANCEL_COMBAT`'s own `0x5e7dd0`, whose bodies are byte-identical. No latch, no
// message on any surface; only the stop, and `0x5ecac0`'s own engaged guard decides
// whether even that sends anything.
SwingRefusalEdge::Refused(AttackSwingError::DeadOrUnattackable)
| SwingRefusalEdge::CombatCancelled => {
debug!("swing stopped by the server — silently ({edge:?})");
seam.stop(!engaged.is_empty());
}
SwingRefusalEdge::Landed => refusal.clear(),
}
}
}
/// The reference's per-player tick `0x5ec950` — the whole display side of this seam.
///
/// Every gate is the reference's, in its order: the active player, an attack target set
/// ([`Engaged`] is benilla's `[+0xc48]`, see [`crate::creature_anim::stop_attack_local`]), the
/// cooldown, then the code. The re-arm happens **only when a line is actually shown** — the
/// reference reaches `0x5ec9b6` from the two code arms alone and its `else` jumps past it.
///
/// **Once per rendered frame is the exact cadence, not merely a fast-enough one.** `0x5ec950` is
/// `CGPlayer_C` vtable `0x80af78` **slot 14** (`+0x38`), and the only dispatcher of that slot
/// image-wide is `0x48160c` inside `0x4815d0`, on the `CWorldFrame::OnPaint` chain
/// (`0x483460` → `0x48361d` → `0x681070` → `0x683dd0` → `0x481540`). Culling does not skip it —
/// it only changes the pass — and neither does a first-person camera. A Bevy `Update` system is
/// that, and the 4000 ms gate makes the match exact rather than approximate.
fn show_swing_refusal(
time: Res<Time>,
mut refusal: ResMut<SwingRefusal>,
engaged: Query<(), (With<SelfPlayer>, With<Engaged>)>,
mut errors: ResMut<UiErrorKeys>,
) {
let Some((code, due)) = refusal.0 else {
return;
};
if engaged.is_empty() {
return; // `[+0xc48] == 0` — no attack target, so nothing to complain about
}
let now = time.elapsed();
if now < due {
return;
}
refusal.0 = Some((code, now + REPEAT));
errors.0.push(UiError::key(code.key()));
}
/// **Entering** the world drops the latch — the reference's `0x5e2510`, reached from
/// `ClientInitializeGame 0x401570` (`0x401648` → `0x5e253a`), which the §5 pinned to **once per
/// world entry, not once per process**. So a fresh world cannot inherit the last one's refusal.
///
/// Nothing else clears it, and never the passage of time: what actually silences a repeating
/// refusal in play is a landed swing, or the attack target going away (the tick's second gate).
fn on_world_enter(mut refusal: ResMut<SwingRefusal>) {
refusal.clear();
}
pub(crate) struct SwingRefusalPlugin;
impl Plugin for SwingRefusalPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SwingRefusal>()
.add_message::<SwingRefusalEdge>()
.add_systems(
Update,
// After the drain that writes the edges, and before the unit feeds, so a key
// queued this frame is resolved and shown by `ui_action::feed_actions` on the same
// frame rather than the next. Chained, so a refusal that arrives this frame is
// latched before the tick reads the latch.
(apply_swing_refusals, show_swing_refusal)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::ui_unit::UnitFeed),
)
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
on_world_enter,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
/// The latch makes the line due immediately; the re-arm is exactly 4 s later.
#[test]
fn the_first_line_is_immediate_and_the_repeat_is_four_seconds() {
let mut r = SwingRefusal::default();
r.latch(Latched::NotInRange, secs(100));
assert_eq!(
r.0,
Some((Latched::NotInRange, secs(100))),
"due now, not in 4 s"
);
assert_eq!(secs(100) + REPEAT, Duration::from_millis(104_000));
}
/// A **second** refusal defeats the 4 s cooldown: `0x5ecdb0` writes the deadline
/// unconditionally, so the next tick shows again at once — and a NOTINRANGE→BADFACING flip
/// shows immediately rather than 4 s later.
#[test]
fn a_second_refusal_packet_defeats_the_cooldown() {
let mut r = SwingRefusal::default();
r.latch(Latched::NotInRange, secs(100));
// …the tick shows the line and re-arms for 104 s…
r.0 = Some((Latched::NotInRange, secs(100) + REPEAT));
// …and a second packet one second later puts it due NOW, not at 104 s.
r.latch(Latched::BadFacing, secs(101));
assert_eq!(r.0, Some((Latched::BadFacing, secs(101))));
}
/// A landed swing clears it — the reason the line stops instead of repeating forever.
#[test]
fn a_landed_swing_clears_the_latch() {
let mut r = SwingRefusal::default();
r.latch(Latched::BadFacing, secs(5));
r.clear();
assert_eq!(r.0, None);
}
/// Both keys are catalog rows, and they are the two the reference's error ids name — the
/// `push 0xd8`/`push 0xd7` at `0x5ec9a5`/`0x5ec9b1`.
#[test]
fn the_two_keys_are_the_reference_error_ids() {
let pos = benilla_ui::messages::by_key(Latched::NotInRange.key()).expect("0xd8 is a row");
let facing = benilla_ui::messages::by_key(Latched::BadFacing.key()).expect("0xd7 is a row");
assert_eq!(pos.id, 0xd8);
assert_eq!(facing.id, 0xd7);
// Both go to the red line, not to chat — the catalog's own `kind`, not a choice here.
assert_eq!(pos.kind, benilla_ui::messages::MsgKind::Error);
assert_eq!(facing.kind, benilla_ui::messages::MsgKind::Error);
}
}
-15
View File
@@ -446,21 +446,6 @@ impl ChatLog {
.collect()
}
/// The text of every parked line headed for a chat window, in order — test-only, the
/// text-shaped twin of [`Self::pending_len`]. A count proves a line was queued; only the text
/// proves it is the *right* line, which is the whole question for a composed one (decision
/// 1764's `ERR_TRADE_BLOCKED_S`, whose `%s` has to name the right player).
#[cfg(test)]
pub(crate) fn pending_texts(&self) -> Vec<String> {
self.pending
.iter()
.filter_map(|p| match p {
Pending::Event(e) => Some(e.text.clone()),
_ => None,
})
.collect()
}
/// How many parked items are headed for a CHAT WINDOW.
///
/// Addon lines are excluded on purpose. They park in the same queue for the same ask-once name
@@ -2308,12 +2308,12 @@ mod tests {
),
(
"TRADE_REQUEST",
"UIParent.lua — the trade ASK dialog; benilla's trade session (0633's sibling) \
opens the window on the wire's own open and never asks",
),
(
"TRADE_REQUEST_CANCEL",
"UIParent.lua — the cancel of that ask, same reason",
"UIParent.lua — the trade ASK dialog, and the one entry on this list that is \
UNPRODUCEABLE rather than unbuilt: the 5875 client registers the event and \
signals it from NOWHERE (a whole-image census, wow-re \
ui/scratch/incoming-trade-request-law.md §3), so StaticPopupDialogs[\"TRADE\"] is \
dead code THERE too. benilla wired the dialog up once and took it back out — \
decision 1764. Producing this would be a divergence, not a fix",
),
(
"TRADE_REPLACE_ENCHANT",
+524 -39
View File
@@ -118,6 +118,57 @@ pub(crate) struct TradeSession {
our_accept: bool,
/// The partner pressed Trade (their accept glow) — set by `TRADE_STATUS_TRADE_ACCEPT`.
their_accept: bool,
/// **A `TRADE_REQUEST_CANCEL` is owed to the VM** — set by the `CANCELED` arm of
/// [`crate::net::apply::trade::trade_status`], drained by [`feed_trade`] on the next frame.
///
/// It is a *pending signal* rather than session state, so it is the one field
/// [`Self::close`] carries across the reset: the reference's own `CANCELED` arm signals the
/// event (`0x4bf832`) and *then* clears the window (`0x4bf842`), and a flag wiped by the
/// clear could never be observed. The net drain cannot fire it itself — a `SessionEvent` arm
/// has no `UiScript`, which is the same seam `answer_trade_request` exists for.
request_cancel: bool,
/// **Lines owed that name a player** — `(GlobalStrings key, whose name fills the `%s`)`,
/// parked until [`NameCache`] can answer and drained by [`feed_trade`].
///
/// The reference has no queue because it never waits: `ERR_INITIATE_TRADE_S` comes off the
/// player-name cache inside the `CMSG_INITIATE_TRADE` sender (`0x5d4031`, printing at
/// `0x5d4042` on a hit and from the query callback `0x5d4080` at `0x5d40ca` on a miss — wow-re
/// `ui/scratch/incoming-trade-request-law.md` §10.2), and the status arms' `%s` lines read the
/// name straight off the live `CGUnit` (`0x609210`). benilla may need a `CMSG_NAME_QUERY`
/// round trip for either, which is a wait a wire decoder cannot do — the same seam
/// [`answer_trade_request`]'s leg 8 exists for.
///
/// A `Vec` rather than one slot because two can be owed at once: our own initiate parks a line
/// and the server's refusal of that same initiate parks another, and the second must not eat
/// the first.
///
/// It survives [`Self::close_window`] for the reference's own reason: its callback hangs off
/// the **name query**, not off the trade, so a refusal arriving before the name does not
/// cancel the line. Nothing hangs on the wait — one query at worst, and in practice the target
/// is the unit you right-clicked, whose name is already cached.
named_lines: Vec<NamedLine>,
}
/// A line owed whose `%s` names a player — see [`TradeSession::named_lines`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NamedLine {
/// The `GlobalStrings` key; the catalog names its surface.
pub(crate) key: &'static str,
/// Whose name fills the `%s`.
pub(crate) who: u64,
/// **The status arms' second guard**, and the difference between the two kinds of line here.
///
/// Cases 0/5/14 take the name off the named player's **live `CGUnit`** (`0x609210`) after
/// asking `0x468460(guid, TYPEMASK_PLAYER)` whether the object is still there — so if it is
/// not, the reference prints **nothing**, and never waits. `ERR_INITIATE_TRADE_S` is the
/// opposite shape (wow-re §10.2): it goes through the player-name **cache** `0x55f080`, has no
/// object test at all, and defers to the name query's callback when the cache misses.
///
/// benilla has one name source for both, so the distinction has to be carried: `true` drops
/// the line the moment the player stops being a streamed object, `false` waits indefinitely
/// for the name. Collapsing them either way is wrong in one direction — a stale cached name
/// printed for someone who is gone, or an initiate line silently dropped.
pub(crate) needs_live_object: bool,
}
impl TradeSession {
@@ -128,6 +179,13 @@ impl TradeSession {
*self = TradeSession {
partner: Some(target),
initiated: true,
// The reference prints its line from inside the sender, so the owed line is parked in
// the same breath as the packet — see [`Self::named_lines`].
named_lines: vec![NamedLine {
key: "ERR_INITIATE_TRADE_S",
who: target,
needs_live_object: false,
}],
..Default::default()
};
}
@@ -246,18 +304,148 @@ impl TradeSession {
self.our_accept = true;
}
/// Whether **our** accept flag is up — the reference's `[0xb71730]`, which `0x4bf230`'s
/// idempotence check reads before it changes anything or sends `CMSG_UNACCEPT_TRADE`.
pub(crate) fn we_accepted(&self) -> bool {
self.our_accept
}
/// We dropped our accept (`CancelTradeAccept`) — local twin of the `CMSG_UNACCEPT_TRADE` send.
pub(crate) fn unaccept(&mut self) {
self.our_accept = false;
}
/// Close the trade — `CANCELED` / `COMPLETE` / `CLOSE_WINDOW` / a refusal, or the local close
/// verb. Clears everything.
pub(crate) fn close(&mut self) {
*self = TradeSession::default();
/// **Signal `TRADE_REQUEST_CANCEL`** — the one Lua event the reference's 23-case status
/// dispatcher fires, from the top of its `CANCELED` arm (`0x4bf832`, the sole `SignalEvent`
/// inside `[0x4bf720, 0x4bfa08)` — wow-re `re/events/event-firesites.tsv`). See
/// [`Self::request_cancel`] for why it survives [`Self::close`].
pub(crate) fn signal_request_cancel(&mut self) {
self.request_cancel = true;
}
/// Disconnect: drop the open window (mirrors the merchant/mail session clears).
/// Take the owed `TRADE_REQUEST_CANCEL`, if any — [`feed_trade`]'s side of the flag.
fn take_request_cancel(&mut self) -> bool {
std::mem::take(&mut self.request_cancel)
}
/// Park a line that names a player — see [`Self::named_lines`].
pub(crate) fn owe_named_line(&mut self, line: NamedLine) {
self.named_lines.push(line);
}
/// Take everything owed — [`feed_trade`]'s side of [`Self::named_lines`]. The caller parks
/// back whatever it could not name yet.
fn take_named_lines(&mut self) -> Vec<NamedLine> {
std::mem::take(&mut self.named_lines)
}
/// **The name a `%s` status line carries**, or `None` when the reference's own two guards
/// stop it printing at all.
///
/// Cases 0, 5 and 14 (`ERR_PLAYER_BUSY_S`, `ERR_IGNORING_YOU_S`) read the *network* handler's
/// guid cell `[0xc4bed0]`/`[0xc4bed4]` — never `0x4bf4e0`'s window-partner pair
/// `[0xb71728]`/`[0xb7172c]` — and each is guarded on `[0xc4bec8]`, the outbound-initiate
/// latch that exactly one instruction image-wide writes (`0x5d4021`, inside `InitiateTrade`).
/// **The cell holds different players on the two sides**, and that is the whole mechanism:
/// `InitiateTrade 0x5d3fb0` writes the **target's** guid (`0x5d401c`/`0x5d4010`) and then the
/// latch (`0x5d4021`) in the same breath, while the `BEGIN_TRADE` arm writes the
/// **initiator's** (`0x5d4815`/`0x5d481a`) and touches no latch. So an initiator has both and
/// prints, naming the player it clicked; a recipient has the cell but no latch and prints
/// nothing. (`0x5d3f70`, the latch getter, has exactly three callers image-wide — the three
/// name-bearing arms and nothing else.)
///
/// That asymmetry is load-bearing, not incidental. vmangos's `HandleIgnoreTradeOpcode` calls
/// `TradeCancel(sendback = true, …)`, which sends `IGNORE_YOU` to **both** sides — so the
/// client that just refused a trade gets the status back at itself with the initiator's guid
/// still in the cell. The latch, which a refuser never set, is what makes that arrive
/// silently. A client that dropped the guard would print "%s is ignoring you." to the person
/// who did the ignoring: inventing a quirk, not reproducing one.
///
/// The reference's **second** guard is not a cell test — it is
/// `0x468460(guid, TYPEMASK_PLAYER)`, "is that player still a resolvable object", because the
/// arm reads the name off the live `CGUnit`. It rides the owed line as
/// [`NamedLine::needs_live_object`], since benilla cannot answer it until it resolves the name.
pub(crate) fn line_names(&self) -> Option<u64> {
self.initiated.then_some(self.partner).flatten()
}
/// **Close the window** — the reference's `SetTradePartner(0, 0)` (`0x4bf4e0`), which zeroes
/// the partner guid `[0xb71728]`/`[0xb7172c]` and its three 7-slot mirrors and fires
/// `TRADE_CLOSED`. Reached from **exactly three** status arms — `CANCELED` (`0x4bf842`),
/// `COMPLETE` (`0x4bf867`) and `CLOSE_WINDOW` (`0x4bf89c`) — plus the local close verb
/// (`CloseTrade 0x4bfdc0` at `0x4bfdd9`) and the world-click select path.
///
/// **This is NOT what the other fourteen closing-looking statuses do** — see
/// [`Self::clear_pending`]. Conflating the two is the mistake this pair exists to prevent, and
/// benilla made it: `close` used to be called from seventeen arms.
///
/// All three statuses that reach it are also in the tail's clear set, and a full reset is a
/// superset of [`Self::clear_pending`] — so the arm calls only this one.
///
/// Clears everything except the two things in flight to the VM — [`Self::request_cancel`] and
/// [`Self::named_lines`] — which are signals, not session state, and which the reference
/// raises *before* it clears (`0x4bf832` precedes `0x4bf842`).
pub(crate) fn close_window(&mut self) {
*self = TradeSession {
request_cancel: self.request_cancel,
named_lines: std::mem::take(&mut self.named_lines),
..Default::default()
};
}
/// **The status handler's own common tail** (`0x5d490a`) — clear the three *network* cells
/// `[0xc4bec8]`, `[0xc4bed0]`, `[0xc4bed4]`: the outbound-initiate latch and the pending guid.
/// It runs on every status that leaves `esi != 0`, which is all of them except
/// `{1, 2, 4, 7, 9, 22}` — and it is **strictly after** the UI dispatcher call (`0x5d4931` vs
/// `0x5d4923`), so an arm always reads the cells before they go.
///
/// It does **not** touch the window. On vmangos every status that reaches this without also
/// closing arrives with no window open (they are all initiate refusals), so the distinction
/// has no observable there — but writing it as a close is how the code came to claim the
/// reference tears the window down on `TARGET_STUNNED`, which it does not.
///
/// `partner` clears only while the window is shut, because benilla fuses two of the
/// reference's cells into that one field: before `OPEN_WINDOW` it stands in for the pending
/// guid `[0xc4bed0]`/`[0xc4bed4]` (which this clears), and after it for the window partner
/// `[0xb71728]`/`[0xb7172c]` (which this must not).
pub(crate) fn clear_pending(&mut self) {
self.initiated = false;
self.request = None;
if !self.open {
self.partner = None;
}
}
/// `TRADE_STATUS_TRADE_REJECTED` (case 9, `0x4bf821`) — drop **the partner's** accept and
/// nothing else. It closes no window, prints no line and clears no cell (9 is one of the six
/// codes that leave `esi == 0`); benilla used to close the whole session on it. vmangos never
/// sends it, so this is unobservable there and is written the reference's way because there
/// is now no reason for it to be written any other way (1764 left it open for want of one).
pub(crate) fn partner_unaccepted(&mut self) {
self.their_accept = false;
}
/// `TRADE_STATUS_ONLY_CONJURED` (case 22, `0x4bf9ec` → `0x4bfbd0`) — the placement is
/// bounced, not the window: the offending slot is emptied out of **our** offer. `0xff` names
/// the money instead of a slot.
///
/// The reference indexes its 7-slot mirror with the wire byte and **does not bound-check it**;
/// ours does, because reproducing an out-of-bounds write is not reproducing a behaviour. The
/// repaint events (`TRADE_PLAYER_ITEM_CHANGED(slot + 1)`, `PLAYER_TRADE_MONEY`) fall out of
/// the feed's own diff, which is where every other offer change already raises them.
pub(crate) fn bounce_own_offer(&mut self, slot: u8) {
const MONEY: u8 = 0xff;
if slot == MONEY {
self.our.gold = 0;
} else if let Some(cell) = self.our.slots.get_mut(usize::from(slot)) {
*cell = None;
}
}
/// Disconnect: drop the open window (mirrors the merchant/mail session clears). Unlike
/// [`Self::close_window`] this drops the pending [`Self::request_cancel`] and
/// [`Self::named_lines`] too — there is no VM left to hear either, and a session teardown is
/// not a trade event.
pub(crate) fn clear_session(&mut self) {
*self = TradeSession::default();
}
@@ -265,6 +453,28 @@ impl TradeSession {
pub(crate) fn is_open(&self) -> bool {
self.open
}
// ── Test windows onto the private state, for the sibling module that owns the status arm ──
// (`crate::net::apply::trade`, whose tests drive `trade_status` and then have to ask what
// it did). Reading them through named accessors rather than opening the fields keeps the
// session's invariants — `partner`/`request` mutually exclusive, `open` gating `npc()` —
// enforced in exactly one file.
#[cfg(test)]
pub(crate) fn take_named_lines_for_test(&mut self) -> Vec<NamedLine> {
self.take_named_lines()
}
#[cfg(test)]
pub(crate) fn partner_has_accepted(&self) -> bool {
self.their_accept
}
/// Our own offer as `(gold, which slots are filled)`.
#[cfg(test)]
pub(crate) fn own_offer_for_test(&self) -> (u32, [bool; TRADE_SLOT_COUNT]) {
(self.our.gold, self.our.slots.map(|s| s.is_some()))
}
}
/// Trade points the shared `"npc"` portrait booth at the partner while the window is open (the
@@ -276,7 +486,7 @@ impl NpcSession for TradeSession {
}
fn close(&mut self) {
self.close();
self.close_window();
}
}
@@ -323,10 +533,6 @@ impl Plugin for UiTradePlugin {
#[derive(Resource, Default)]
pub(crate) struct BlockTrades(pub(crate) bool);
/// `ERR_TRADE_BLOCKED_S` (`GlobalStrings.lua` l.1889) — the line the *Block Trades* refusal prints,
/// naming the would-be partner. Two spaces after the full stop, as shipped.
const ERR_TRADE_BLOCKED_S: &str = "%s has requested to trade. You have refused.";
/// **Answer an incoming trade request** — the arm the reference keeps inside `CGTradeInfo`'s
/// dispatcher (`0x4bf736`, case 1 of the 23-case `0x4bf720`), lifted out to where its inputs live.
///
@@ -372,12 +578,28 @@ const ERR_TRADE_BLOCKED_S: &str = "%s has requested to trade. You have refused.
/// that produced the note above — `[0xb4b3e4]` is player *control*, not "in world"; `[0xb725f8]` is
/// the auction house, not a pending trade; and the ignore leg was missing entirely. This is why the
/// gloss was not built from.
/// **`0x468460(guid, TYPEMASK_PLAYER)`** — the guid's live object, or `None` when it is not a
/// streamed player. The reference asks this in two places in this arc and benilla now asks it in
/// the same two: the incoming ladder's leg 3 (`0x4bf779`, which drops a request from a guid that
/// resolves to nothing) and the status arms' second guard on a `%s` line
/// ([`NamedLine::needs_live_object`]). One predicate, because it is one question.
fn streamed_player<'a>(
index: &GuidIndex,
stores: &'a Query<&ObjectStore>,
guid: u64,
) -> Option<&'a ObjectStore> {
if !benilla_protocol::guid::is_player(guid) {
return None;
}
index.0.get(&guid).and_then(|e| stores.get(*e).ok())
}
#[allow(clippy::too_many_arguments)]
fn answer_trade_request(
mut trade: ResMut<TradeSession>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
social: Res<crate::ui_social::SocialState>,
cinematic: Res<crate::cinematic::Cinematic>,
player: Res<crate::player::Player>,
@@ -399,11 +621,7 @@ fn answer_trade_request(
// Leg 3 — the initiator does not resolve to a streamed **player**. The reference asks
// `0x468460(TYPEMASK_PLAYER)` and returns on NULL; ours is the guid index plus the store, and
// it fails in exactly the case the reference's does (nothing of that guid in the world).
let initiator_store = index
.0
.get(&initiator)
.and_then(|e| stores.get(*e).ok())
.filter(|_| benilla_protocol::guid::is_player(initiator));
let initiator_store = streamed_player(&index, &stores, initiator);
if trade.initiated || initiator_store.is_none() {
info!(
target: "trade",
@@ -477,15 +695,17 @@ fn answer_trade_request(
info!(target: "trade", "BEGIN_TRADE from {name} refused by BlockTrades; sending CMSG_BUSY_TRADE");
let _ = commands.0.send(ClientCommand::BusyTrade);
trade.refuse_request();
// `0x496720(0xbb, 0x609210(initiator))` — and catalog row `0xbb` carries `kind = 0`, chat
// type `0xa`, no sound cue. So this is a **system chat line**, not the red
// `UI_ERROR_MESSAGE` (`kind = 2`). All three trade rows are chat rows; `crate::ui_duel`'s
// note that benilla models `DisplayError` as the red toast is true of its own two ids and
// not of these.
chat_log.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
ERR_TRADE_BLOCKED_S.replacen("%s", &name, 1),
));
// `0x496720(0xbb, 0x609210(initiator))` — one `DisplayError` call, which is exactly what
// [`crate::ui_action::UiErrorKeys`] models: the key names the row, the row names the
// surface, and `ui_action::feed_actions` resolves the text out of the **player's own**
// `GlobalStrings.lua` and routes it. Row `0xbb` carries `kind = 0`, chat type `0xa`, no
// sound cue — a system chat line, not the red `UI_ERROR_MESSAGE` (`kind = 2`) — but no
// line here says so, and that is the point: this arm no longer knows or decides.
errors.0.push(crate::ui_action::UiError {
key: "ERR_TRADE_BLOCKED_S",
fill_s: Some(name),
fill_d: None,
});
return;
}
@@ -617,6 +837,11 @@ fn feed_trade(
icons: Option<Res<ItemDisplays>>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
// The status arms' second guard (`0x468460(guid, TYPEMASK_PLAYER)`) — see
// [`NamedLine::needs_live_object`]. The same two reads `answer_trade_request`'s leg 3 makes.
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
mut last: Local<crate::ui_script::VmMemo<Option<TradeState>>>,
mut last_open: Local<crate::ui_script::VmMemo<bool>>,
mut last_accept: Local<crate::ui_script::VmMemo<(bool, bool)>>,
@@ -664,6 +889,42 @@ fn feed_trade(
}
}
}
// The owed lines that name a player ([`TradeSession::named_lines`]) — resolved here rather
// than where they are raised, because this is where the name is: the raiser parks a guid,
// this asks the cache, and a miss simply waits for the query it just started. Where each
// lands is the message catalog's `+0x04`, read by `ui_action::feed_actions`; no line here
// decides it (all three of these happen to be chat rows, and nothing depends on that).
for line in trade.take_named_lines() {
// The status arms read the name off the named player's live `CGUnit`, having first asked
// whether that object is still there — and print nothing when it is not. Ours asks the
// same question of the guid index and drops the line on a miss, rather than serving a
// cached name for somebody who has gone.
if line.needs_live_object && streamed_player(&index, &stores, line.who).is_none() {
continue;
}
match names.resolve(line.who, &commands).map(str::to_string) {
Some(name) => errors.0.push(crate::ui_action::UiError {
key: line.key,
fill_s: Some(name),
fill_d: None,
}),
// Not cached yet: `resolve` has asked, so park the debt again and retry next frame.
None => trade.owe_named_line(line),
}
}
// `TRADE_REQUEST_CANCEL` — the `CANCELED` arm's own signal, and the ONLY Lua event the
// reference's 23-case status dispatcher fires (`0x4bf832`; wow-re `event-firesites.tsv` finds
// no other `SignalEvent` in `[0x4bf720, 0x4bfa08)`). It goes out **ahead of** the
// `TRADE_CLOSED` the same arm's window clear fires, which is the arm's own address order:
// signal `0x4bf832`, then `0x4bf842 call 0x4bf4e0`, whose `0x4bf522` is `TRADE_CLOSED`.
//
// Nothing stock listens: 1.12's `UIParent.lua` hides the `TRADE` StaticPopup on it, and that
// dialog can never be up (its `TRADE_REQUEST` is signalled from nowhere in the image —
// decision 1764). It is owed to **addons**, which is reason enough — an event the reference
// fires and we do not is a hole an addon falls into silently.
if trade.take_request_cancel() {
script.fire_event("TRADE_REQUEST_CANCEL", vec![]);
}
if opened {
// `SetTradePartner 0x4bf4e0`'s open leg (decision 1965): coins held on the cursor fold
// into the offer before anything else — the one leg that fires the two money events
@@ -815,7 +1076,7 @@ fn drain_trade(
if trade.is_open() || trade.partner.is_some() {
let _ = commands.0.send(ClientCommand::CancelTrade);
}
trade.close();
trade.close_window();
}
// The TRADE dialog's pair (decision 1963): `BeginTrade` is the empty `0x117`, `CancelTrade`
// the bare `0x11C` — no teardown of ours, the server's status reply drives the window.
@@ -912,7 +1173,7 @@ mod tests {
assert_eq!(s.npc(), Some(0x1234));
// A refusal / cancel closes and clears everything.
s.close();
s.close_window();
assert!(!s.is_open());
assert_eq!(s.partner, None);
assert_eq!(s.npc(), None);
@@ -949,7 +1210,7 @@ mod tests {
fn closing_clears_a_pending_request() {
let mut s = TradeSession::default();
s.request(0xABCD);
s.close();
s.close_window();
assert_eq!(s.pending_request(), None);
}
@@ -1113,6 +1374,221 @@ mod tests {
);
}
/// Build an app that can run [`feed_trade`] as a real system with a live VM — the seam the
/// two lines below need, since both are resolved (not raised) by the feed.
fn feed_app() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut names = NameCache::default();
names.insert_player(0x7, "Grubbis".to_string(), None);
app.init_resource::<TradeSession>()
.init_resource::<Items>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<GuidIndex>()
.insert_resource(names)
.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, feed_trade);
(app, rx)
}
/// The number of `TRADE_REQUEST_CANCEL`s a spy frame has seen.
fn cancels_seen(app: &mut App) -> i64 {
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<i64>("return BENILLA_TEST_TRADE_CANCELS or 0")
.unwrap()
}
/// **`TRADE_REQUEST_CANCEL` fires, and only on `CANCELED`.** It is the one Lua event the
/// reference's 23-case status dispatcher signals (`0x4bf832`, the sole `SignalEvent` inside
/// `[0x4bf720, 0x4bfa08)` — wow-re `re/events/event-firesites.tsv`), and benilla fired it from
/// nowhere: 1764 noticed the gap and left it, and the stock `UIParent.lua` has listened for it
/// ever since without anything to hear.
///
/// Nothing stock *acts* on it (it hides the `TRADE` StaticPopup, which can never be up — that
/// dialog is dead code in the reference too), so the observable is the event itself, which is
/// exactly what an addon sees.
#[test]
fn canceled_fires_trade_request_cancel_and_the_siblings_do_not() {
let (mut app, _rx) = feed_app();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(
r#"
local f = CreateFrame("Frame")
f:RegisterEvent("TRADE_REQUEST_CANCEL")
f:SetScript("OnEvent", function()
BENILLA_TEST_TRADE_CANCELS = (BENILLA_TEST_TRADE_CANCELS or 0) + 1
end)
"#,
)
.unwrap();
app.update();
assert_eq!(cancels_seen(&mut app), 0, "nothing has cancelled anything");
// A plain close — what `COMPLETE` and `CLOSE_WINDOW` do — signals nothing. (Which
// statuses reach which of these two is `net::apply::trade`'s own test; this is the half
// that turns a signal into an event.)
app.world_mut()
.resource_mut::<TradeSession>()
.close_window();
app.update();
assert_eq!(
cancels_seen(&mut app),
0,
"closing the window is not itself a cancel signal"
);
{
let mut trade = app.world_mut().resource_mut::<TradeSession>();
trade.signal_request_cancel();
trade.close_window();
}
app.update();
assert_eq!(cancels_seen(&mut app), 1, "CANCELED signals it, once");
app.update();
assert_eq!(
cancels_seen(&mut app),
1,
"and only once — the flag is taken"
);
}
/// **The outgoing line.** "You have requested to trade with %s." is printed by the reference
/// from inside its own `CMSG_INITIATE_TRADE` sender (`0x5d4042` on a name-cache hit,
/// `0x5d40ca` from the name-query callback on a miss — wow-re §10.2), not from anything the
/// server says back. benilla sent the packet in silence, so a trade that the server then
/// refused produced no text at all, in either direction.
///
/// Row `0xb9` is `kind = 0`, a **system chat line** — asserted off the catalog rather than
/// written here, because the surface is the record's to name.
#[test]
fn initiating_a_trade_says_so() {
let (mut app, _rx) = feed_app();
app.world_mut().resource_mut::<TradeSession>().initiate(0x7);
app.update();
assert_eq!(
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError {
key: "ERR_INITIATE_TRADE_S",
fill_s: Some("Grubbis".to_string()),
fill_d: None,
}],
);
assert_eq!(
benilla_ui::messages::kind_of("ERR_INITIATE_TRADE_S"),
benilla_ui::messages::MsgKind::Chat,
);
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.len(),
1,
"the debt is spent once, not re-raised every frame"
);
}
/// **A `%s` status line is dropped when the player it names is no longer a streamed object**
/// — the status arms' second guard (`0x468460(guid, TYPEMASK_PLAYER)`), which they have
/// because they read the name off the live `CGUnit` and print nothing when there is not one.
///
/// benilla's `NameCache` would happily serve a name for somebody who has despawned, so
/// without the guard the line would still go out — naming a player who is gone. The
/// *initiate* line is the deliberate opposite (it goes through the reference's name cache,
/// which has no such test) and the second half of this test holds that difference in place:
/// same session, same absent object, one line dropped and one still owed.
#[test]
fn a_naming_status_line_needs_a_live_object_and_the_initiate_line_does_not() {
let (mut app, _rx) = feed_app();
{
let mut trade = app.world_mut().resource_mut::<TradeSession>();
trade.initiate(0x7); // parks ERR_INITIATE_TRADE_S, needs_live_object: false
trade.owe_named_line(NamedLine {
key: "ERR_PLAYER_BUSY_S",
who: 0x7,
needs_live_object: true,
});
}
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.iter()
.map(|e| e.key)
.collect::<Vec<_>>(),
vec!["ERR_INITIATE_TRADE_S"],
"0x7 is named in the cache but is not a streamed object: the status line is dropped, the initiate line is not"
);
// Stream the player, ask again: now it prints.
let e = app.world_mut().spawn(ObjectStore::default()).id();
app.world_mut().resource_mut::<GuidIndex>().0.insert(0x7, e);
app.world_mut()
.resource_mut::<crate::ui_action::UiErrorKeys>()
.0
.clear();
app.world_mut()
.resource_mut::<TradeSession>()
.owe_named_line(NamedLine {
key: "ERR_PLAYER_BUSY_S",
who: 0x7,
needs_live_object: true,
});
app.update();
assert_eq!(
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError {
key: "ERR_PLAYER_BUSY_S",
fill_s: Some("Grubbis".to_string()),
fill_d: None,
}],
);
}
/// A target whose name is not cached yet **waits** — the reference's deferred arm, which
/// prints from the name-query callback. The line must not go out with a hole in it, and it
/// must not be dropped: a refusal arriving first does not cancel it, because the reference
/// hangs its callback off the name query and not off the trade.
#[test]
fn the_initiate_line_waits_for_the_name_and_survives_the_refusal() {
let (mut app, _rx) = feed_app();
app.world_mut()
.resource_mut::<TradeSession>()
.initiate(0x99); // no name cached
app.update();
assert!(
app.world()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.is_empty(),
"no name, no line — and never a line with an unfilled %s"
);
// The server refuses before the name lands; the debt survives the close.
app.world_mut()
.resource_mut::<TradeSession>()
.close_window();
app.world_mut().resource_mut::<NameCache>().insert_player(
0x99,
"Skarrid".to_string(),
None,
);
app.update();
assert_eq!(
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError {
key: "ERR_INITIATE_TRADE_S",
fill_s: Some("Skarrid".to_string()),
fill_d: None,
}],
);
}
/// A request that survives every leg is **accepted unasked** — the bottom of the reference's
/// own ladder, and the behaviour benilla briefly replaced with a consent dialog before taking
/// it back out (decision 1764). The 5875 client never asks: it registers `TRADE_REQUEST` and
@@ -1136,8 +1612,8 @@ mod tests {
);
assert!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.is_empty(),
"and an accepted request says nothing — only leg 8 speaks"
);
@@ -1155,10 +1631,19 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)));
assert_eq!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts(),
vec!["Grubbis has requested to trade. You have refused.".to_string()],
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError {
key: "ERR_TRADE_BLOCKED_S",
fill_s: Some("Grubbis".to_string()),
fill_d: None,
}],
"the refusal raises the reference's own `0x496720(0xbb, name)`"
);
// …and the row it names is a CHAT row, which is the half a re-implementation gets wrong:
// `DisplayError` is modelled elsewhere in benilla as the red toast, and is not one here.
assert_eq!(
benilla_ui::messages::kind_of("ERR_TRADE_BLOCKED_S"),
benilla_ui::messages::MsgKind::Chat,
);
}
@@ -1177,8 +1662,8 @@ mod tests {
);
assert!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.is_empty(),
"cinematic={cinematic} controlled={controlled}: a silent leg says nothing"
);
@@ -1217,8 +1702,8 @@ mod tests {
);
assert!(app
.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.resource::<crate::ui_action::UiErrorKeys>()
.0
.is_empty());
}
@@ -1321,7 +1806,7 @@ mod tests {
names.insert_player(0x7, "Grubbis".to_string(), None);
names.insert_player(0x8, "Skarrid".to_string(), None);
app.init_resource::<TradeSession>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_social::SocialState>()
.init_resource::<crate::ui_auction::AuctionOpen>()
.init_resource::<GuidIndex>()
@@ -67,6 +67,22 @@ impl Probe for Attack {
bail!("--attack: swung at {target:#x} but no SMSG_ATTACKERSTATEUPDATE decoded");
}
println!("✅ attack: {swings_seen} SMSG_ATTACKERSTATEUPDATE swing(s) decoded (target {target:#x}).");
// The refusals are REPORTED, never required: this probe lands on top of its kobold, so the
// server has no reason to send one. They are surfaced because before decision 2037 they
// were dropped silently, and a run that does provoke one should say so rather than swallow
// it again. Eliciting one on purpose needs a target out of melee reach whose hostility we
// can be sure of, which a live server does not hand us (2037's remainder 4).
let refusals = &cx.world.swing_refusals;
if refusals.is_empty() {
println!(
" (no SMSG_ATTACKSWING refusal seen — expected, we swing from on top of it)"
);
} else {
println!(
" {} swing refusal(s) decoded: {refusals:?}",
refusals.len()
);
}
Ok(())
}
}
@@ -8,8 +8,8 @@ use std::time::Instant;
use anyhow::Result;
use benilla_protocol::{
Character, EntityKind, MoveMode, ObjectFields, ServerPacket, SessionEvent, SpeedKind,
WorldSession,
AttackSwingError, Character, EntityKind, MoveMode, ObjectFields, ServerPacket, SessionEvent,
SpeedKind, WorldSession,
};
/// A Kobold Vermin spawn in Northshire (mangos `creature` guid 79992) — the `--attack` teleport
@@ -85,6 +85,9 @@ pub(crate) struct World {
pub(crate) dest_spell: Option<u32>,
pub(crate) dest_verdict: Option<(u32, bool, Option<u8>)>,
pub(crate) swings_seen: u32,
/// The refusals the server answered a swing with, by arm — `--attack`'s phase 2 verdict. The
/// wire has no reason byte, so the arm IS the reason.
pub(crate) swing_refusals: Vec<AttackSwingError>,
pub(crate) self_moves: u32,
pub(crate) tally: BTreeMap<String, u32>,
pub(crate) total: u32,
@@ -126,6 +129,7 @@ impl World {
dest_spell: None,
dest_verdict: None,
swings_seen: 0,
swing_refusals: Vec::new(),
self_moves: 0,
tally: BTreeMap::new(),
total: 0,
@@ -464,6 +468,10 @@ impl World {
s.attacker, s.victim, s.hit_info, s.damage, s.victim_state
);
}
SessionEvent::AttackSwingError(e) => {
self.swing_refusals.push(*e);
println!("SMSG_ATTACKSWING refusal: {e:?}");
}
SessionEvent::CastResult {
spell_id,
success,
+14 -3
View File
@@ -10,9 +10,9 @@
//! Coordinates stay **raw WoW** (the `benilla` boundary applies `bevy = (-y, z, -x)`).
use crate::messages::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, ChannelNoticeTail, Character, CreateSpline, DamageShield,
DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
ActionButton, AttackSwingError, AttackerState, AuctionBidderNotification, AuctionCommandTail,
AuctionListEntry, AuctionOwnerNotification, ChannelNoticeTail, Character, CreateSpline,
DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GmTicket, GossipOption, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
@@ -780,6 +780,17 @@ pub enum SessionEvent {
/// One completed melee swing (`SMSG_ATTACKERSTATEUPDATE`) — the attacker's swing-animation
/// trigger (decision 0073: one packet = one swing, no client timer).
AttackerState(AttackerState),
/// The server refused our melee swing — `SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/
/// `_DEADTARGET`/`_CANT_ATTACK`, in the three arms the reference wires
/// ([`crate::messages::AttackSwingError`]). Self-only: the server sends these to the swinging
/// player alone.
AttackSwingError(AttackSwingError),
/// The server forced our attack to stop (`SMSG_CANCEL_COMBAT`) — the swing family's fourth
/// arm, whose handler is arm 4's body verbatim: StopAttack, no message.
CancelCombat,
/// The target resisted our Feign Death (`SMSG_FEIGN_DEATH_RESISTED`) — one red line,
/// `ERR_FEIGN_DEATH_RESISTED` ("Resisted"), with no state behind it.
FeignDeathResisted,
/// A creature flared at someone (`SMSG_AI_REACTION`): reaction 2 = HOSTILE (sent on every
/// creature melee-attack start), 0 = ALERT (stealth pre-aggro detection); any other value is
/// a no-op. Pure audio in the client — the aggro/alert vocals (decision 0280).
@@ -218,6 +218,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::AttackStop { attacker, victim }]
}
ServerPacket::AttackerState(s) => vec![SessionEvent::AttackerState(s)],
ServerPacket::AttackSwingError(e) => vec![SessionEvent::AttackSwingError(e)],
ServerPacket::CancelCombat => vec![SessionEvent::CancelCombat],
ServerPacket::FeignDeathResisted => vec![SessionEvent::FeignDeathResisted],
ServerPacket::AiReaction { unit, reaction } => {
vec![SessionEvent::AiReaction { unit, reaction }]
}
+3 -3
View File
@@ -18,9 +18,9 @@ pub use events::{
SessionEvent,
};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo, JumpInfo,
MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, ServerPacket, SpeedKind,
SplineMode, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK,
AttackSwingError, CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo,
JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, ServerPacket,
SpeedKind, SplineMode, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK,
CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
};
pub use world::{
@@ -98,6 +98,28 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
})
}
/// The server's refusal of a `CMSG_ATTACKSWING` — the melee auto-attack answer that is not
/// `SMSG_ATTACKSTART`. **Three variants for four opcodes, because that is what the client can
/// distinguish**: the reference dispatches `0x145`/`0x146`/`0x148`/`0x149` through `0x6255b0`'s
/// jump table `0x625aec`, and `0x148` DEADTARGET and `0x149` CANT_ATTACK land on **arm 4
/// (`0x625ab8`) verbatim** — one shared body, no reason byte, nothing to tell them apart with.
/// Collapsing them here is the fidelity fact in the type: a consumer cannot key on a difference
/// the real client never had.
///
/// Every body is empty; the arms read nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttackSwingError {
/// `SMSG_ATTACKSWING_NOTINRANGE` (`0x145`) — arm 2 `0x625a8a`: latch code **1**.
NotInRange,
/// `SMSG_ATTACKSWING_BADFACING` (`0x146`) — arm 3 `0x625aa1`: latch code **2**.
BadFacing,
/// `SMSG_ATTACKSWING_DEADTARGET` (`0x148`) **or** `SMSG_ATTACKSWING_CANT_ATTACK` (`0x149`) —
/// both arm 4 `0x625ab8`, which raises **no message at all**: it resolves the active player
/// and calls StopAttack (`0x5ecac0`) on it, full stop. The name is deliberately the union of
/// the two: naming one of them would claim a distinction the client does not have.
DeadOrUnattackable,
}
/// Read `SMSG_ATTACKSTOP` (vmangos `AttackStop::AppendBodyTo`): two **packed** guids + a `u32`
/// "victim is dead" word (dropped — death arrives through the descriptor seam).
pub(super) fn read_attack_stop(r: &mut impl Read) -> io::Result<(u64, u64)> {
+1 -1
View File
@@ -76,7 +76,7 @@ pub use action_bar::{
};
pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
pub use area_trigger::area_trigger;
pub use attack::{attack_swing, AttackerState};
pub use attack::{attack_swing, AttackSwingError, AttackerState};
pub use auction::{
auction_action, auction_duration, auction_error, auction_filter, auction_hello,
auction_list_bidder_items, auction_list_items, auction_list_owner_items, auction_place_bid,
@@ -301,6 +301,39 @@ pub const MSG_MOVE_TIME_SKIPPED: u16 = 0x0319; // 793
pub const SMSG_ATTACKSTART: u16 = 0x0143; // 323
pub const SMSG_ATTACKSTOP: u16 = 0x0144; // 324
pub const SMSG_ATTACKERSTATEUPDATE: u16 = 0x014A; // 330
// The four melee swing REFUSALS — the server's answers to a `CMSG_ATTACKSWING` it will not honour.
// All four bodies are EMPTY (vmangos `Server/Packets/Combat.cpp`, every `AppendBodyTo` a no-op),
// and the reference's arms read nothing from the datastore either.
//
// **There is no `0x147` here, and that is the finding, not an omission.** The reference registers
// exactly these four opcodes onto its combat handler `0x6255b0` (`0x62555f`/`0x625570`/`0x625581`/
// `0x625592`); `0x147` SMSG_ATTACKSWING_NOTSTANDING is never registered, so the client ignores it —
// and vmangos never sends it either (`Player::SendAttackSwingNotStanding` has zero callers). An arm
// for it would be code no server can reach and no client ever ran.
pub const SMSG_ATTACKSWING_NOTINRANGE: u16 = 0x0145; // 325
pub const SMSG_ATTACKSWING_BADFACING: u16 = 0x0146; // 326
pub const SMSG_ATTACKSWING_DEADTARGET: u16 = 0x0148; // 328
pub const SMSG_ATTACKSWING_CANT_ATTACK: u16 = 0x0149; // 329
/// The FORCED attack cancel — the swing family's fourth arm, and the one that is not an
/// `SMSG_ATTACKSWING_*` at all. Empty body (vmangos `WorldPackets::Combat::CancelCombat`), sent by
/// `Player::SendAttackSwingCancelAttack()` from four places: `Unit::CombatStop`,
/// `Unit::StopAttackFaction`, `Unit::InterruptAttacksOnMe`, and a resisted feign death.
///
/// Registered by the SPELL TU, not the combat one (`0x5e3308`, handler `0x5e7dd0`) — which is why
/// `0x625520`'s eight-opcode census does not list it. Its handler is **byte-identical to arm 4**
/// (`0x625ab8`), down to the `__LINE__` it pushes: resolve the active player and StopAttack, no
/// message on any surface.
pub const SMSG_CANCEL_COMBAT: u16 = 0x014E; // 334
/// A Feign Death the target resisted — empty body (vmangos
/// `WorldPackets::Combat::FeignDeathResisted`), sent by `Player::SendFeignDeathResisted()` from the
/// one site that also sends [`SMSG_CANCEL_COMBAT`] (`Objects/Unit.cpp:9445-9451`). The reference's
/// handler `0x6e9800` is two instructions — `push 0x1a5; call 0x496720` — so it is a plain
/// `DisplayError(421)` = `ERR_FEIGN_DEATH_RESISTED` = "Resisted", with no latch and no cooldown
/// behind it.
pub const SMSG_FEIGN_DEATH_RESISTED: u16 = 0x02B4; // 692
/// Creature aggro/alert flare (VERIFIED vmangos `Opcodes_1_12_1.h`: 316; body in
/// [`super::attack::read_ai_reaction`]).
pub const SMSG_AI_REACTION: u16 = 0x013C; // 316
+30 -9
View File
@@ -5,15 +5,15 @@
use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, CastOutcome, ChannelNotify, Character, ChatMessage, CorpseLocation,
DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GmTicket, GossipOption, GossipPoi, GroupLootInfo,
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MoveMode, Object, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
ActionButton, AttackSwingError, AttackerState, AuctionBidderNotification, AuctionCommandTail,
AuctionListEntry, AuctionOwnerNotification, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog,
ExplorationXp, FriendEntry, FriendStatusUpdate, GameObjectQueryInfo, GmTicket, GossipOption,
GossipPoi, GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult,
JumpInfo, LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll,
MailListEntry, MirrorTimerStart, MoveMode, Object, PartyKillLog, PartyMemberStatsInfo,
PeriodicAuraLog, PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept,
QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestPushResult,
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
@@ -677,6 +677,16 @@ pub enum ServerPacket {
/// `SMSG_ATTACKERSTATEUPDATE` — one completed melee swing (decision 0073: the attacker's swing
/// animation trigger).
AttackerState(AttackerState),
/// The server refused our `CMSG_ATTACKSWING` — `SMSG_ATTACKSWING_NOTINRANGE` (`0x145`),
/// `_BADFACING` (`0x146`), `_DEADTARGET` (`0x148`) or `_CANT_ATTACK` (`0x149`), collapsed to
/// the three arms the reference actually wires (see [`AttackSwingError`]). Empty bodies.
AttackSwingError(AttackSwingError),
/// `SMSG_CANCEL_COMBAT` (`0x14e`) — the server forced our attack to stop. Empty body; the
/// reference's handler `0x5e7dd0` is arm 4's body verbatim (StopAttack, no message).
CancelCombat,
/// `SMSG_FEIGN_DEATH_RESISTED` (`0x2b4`) — the target resisted our Feign Death. Empty body;
/// the reference's handler `0x6e9800` is a bare `DisplayError(421)`.
FeignDeathResisted,
/// `SMSG_AI_REACTION` — a creature flared aggro (2 HOSTILE) or a stealth pre-aggro alert
/// (0 ALERT) at someone (layout in [`super::attack::read_ai_reaction`]; decision 0277).
AiReaction {
@@ -1691,6 +1701,17 @@ impl ServerPacket {
ServerPacket::AttackStart { .. } => "SMSG_ATTACKSTART".into(),
ServerPacket::AttackStop { .. } => "SMSG_ATTACKSTOP".into(),
ServerPacket::AttackerState(_) => "SMSG_ATTACKERSTATEUPDATE".into(),
// The wire opcode is not recoverable from the collapsed arm 4 — the client cannot tell
// DEADTARGET from CANT_ATTACK either, so the name says which arm ran.
ServerPacket::AttackSwingError(e) => match e {
AttackSwingError::NotInRange => "SMSG_ATTACKSWING_NOTINRANGE".into(),
AttackSwingError::BadFacing => "SMSG_ATTACKSWING_BADFACING".into(),
AttackSwingError::DeadOrUnattackable => {
"SMSG_ATTACKSWING_DEADTARGET/CANT_ATTACK".into()
}
},
ServerPacket::CancelCombat => "SMSG_CANCEL_COMBAT".into(),
ServerPacket::FeignDeathResisted => "SMSG_FEIGN_DEATH_RESISTED".into(),
ServerPacket::AiReaction { .. } => "SMSG_AI_REACTION".into(),
ServerPacket::SpellStart(_) => "SMSG_SPELL_START".into(),
ServerPacket::SpellGo(_) => "SMSG_SPELL_GO".into(),
+17 -2
View File
@@ -15,8 +15,8 @@ use super::{
combat_log, death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot,
mail, meeting_stone, mirror_timer, monster_move, movement, opcode, page_text, pet, petition,
progression, pvp, quest, social, spellbook, spells, stable, summon, tabard, taxi, trade,
trainer, tutorial, update_object, vendor, world_state, Character, CreatureQueryInfo, JumpInfo,
MoveMode, ServerPacket, SpeedKind, SplineMode,
trainer, tutorial, update_object, vendor, world_state, AttackSwingError, Character,
CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind, SplineMode,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -638,6 +638,21 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_ATTACKERSTATEUPDATE => {
ServerPacket::AttackerState(attack::read_attacker_state(&mut r)?)
}
// The swing refusals — empty bodies, nothing read. `SMSG_ATTACKSWING_NOTSTANDING` (`0x147`)
// is deliberately absent: the reference never registers it and vmangos never sends it, so
// it falls to the unknown-opcode arm exactly as it does in the real client's dispatcher.
opcode::SMSG_ATTACKSWING_NOTINRANGE => {
ServerPacket::AttackSwingError(AttackSwingError::NotInRange)
}
opcode::SMSG_ATTACKSWING_BADFACING => {
ServerPacket::AttackSwingError(AttackSwingError::BadFacing)
}
opcode::SMSG_ATTACKSWING_DEADTARGET | opcode::SMSG_ATTACKSWING_CANT_ATTACK => {
ServerPacket::AttackSwingError(AttackSwingError::DeadOrUnattackable)
}
// The family's fourth arm, from the spell TU's registration — same empty body, same act.
opcode::SMSG_CANCEL_COMBAT => ServerPacket::CancelCombat,
opcode::SMSG_FEIGN_DEATH_RESISTED => ServerPacket::FeignDeathResisted,
opcode::SMSG_AI_REACTION => {
let (unit, reaction) = attack::read_ai_reaction(&mut r)?;
ServerPacket::AiReaction { unit, reaction }
+82
View File
@@ -288,6 +288,88 @@ fn spell_and_action_wire() {
}
}
/// The melee swing family's SIX opcodes and FOUR arms — plus the one that must stay unparsed.
///
/// Every body is empty (vmangos `Server/Packets/Combat.cpp`: `AttackSwingNotInRange`,
/// `AttackSwingBadFacing`, `AttackSwingDeadTarget`, `AttackSwingCantAttack` all have a no-op
/// `AppendBodyTo`), so there are no bytes to pin — what needs pinning is the **arm map**, which is
/// the reference's and not the wire's:
///
/// - `0x148` DEADTARGET and `0x149` CANT_ATTACK both reach arm 4 (`0x625ab8`) verbatim, so the
/// client cannot tell them apart and neither can we;
/// - `0x147` NOTSTANDING is **never registered** (`0x6255b0`'s table `0x625aec` has it on the
/// default arm `0x625ade`) and vmangos never sends it
/// (`Player::SendAttackSwingNotStanding` has zero callers), so it must stay an unknown opcode
/// here. A future session adding it "for completeness" fails this test, which is the point.
#[test]
fn attack_swing_refusal_wire() {
use benilla_protocol::messages::AttackSwingError;
for (op, want) in [
(
messages::opcode::SMSG_ATTACKSWING_NOTINRANGE,
AttackSwingError::NotInRange,
),
(
messages::opcode::SMSG_ATTACKSWING_BADFACING,
AttackSwingError::BadFacing,
),
(
messages::opcode::SMSG_ATTACKSWING_DEADTARGET,
AttackSwingError::DeadOrUnattackable,
),
(
messages::opcode::SMSG_ATTACKSWING_CANT_ATTACK,
AttackSwingError::DeadOrUnattackable,
),
] {
let packet = messages::parse_server(op, &[]).unwrap_or_else(|e| panic!("{op:#x}: {e}"));
match &packet {
ServerPacket::AttackSwingError(got) => assert_eq!(*got, want, "opcode {op:#x}"),
other => panic!("{op:#x} parsed as {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::AttackSwingError(got) => assert_eq!(got, want, "opcode {op:#x} event"),
other => panic!("{op:#x} event, got {other:?}"),
}
}
// The family's fourth arm — a different opcode family, the same empty body and the same act.
assert!(
matches!(
messages::parse_server(messages::opcode::SMSG_CANCEL_COMBAT, &[]).unwrap(),
ServerPacket::CancelCombat
),
"SMSG_CANCEL_COMBAT is registered (`0x5e3308` -> `0x5e7dd0`), just not by the combat TU"
);
assert!(matches!(
decode(ServerPacket::CancelCombat).pop().unwrap(),
SessionEvent::CancelCombat
));
// The family's other empty-bodied sibling, sent from the same vmangos site as CANCEL_COMBAT —
// but a bare `DisplayError(421)` in the reference (`0x6e9800`), with no latch behind it.
assert!(matches!(
messages::parse_server(messages::opcode::SMSG_FEIGN_DEATH_RESISTED, &[]).unwrap(),
ServerPacket::FeignDeathResisted
));
assert!(matches!(
decode(ServerPacket::FeignDeathResisted).pop().unwrap(),
SessionEvent::FeignDeathResisted
));
// 0x147 SMSG_ATTACKSWING_NOTSTANDING — unregistered in the reference, unsent by vmangos, and
// therefore an UNKNOWN opcode here: it falls to the `Other` arm, which is exactly what
// `0x6255b0`'s default arm `0x625ade` does with it.
assert!(
matches!(
messages::parse_server(0x0147, &[]).unwrap(),
ServerPacket::Other { opcode: 0x0147 }
),
"0x147 NOTSTANDING must stay unparsed — the reference never registers it"
);
}
/// The spell-visual pipeline wire (decision 0099 phase 1): `SMSG_SPELL_START`/`GO`,
/// `SMSG_SPELL_FAILED_OTHER`, `SMSG_CANCEL_AUTO_REPEAT`, `SMSG_PLAY_SPELL_VISUAL`. Golden bytes are
/// hand-built from the vmangos writers (`Spell.cpp:4468-4659`, `SpellCastTargetsInfo.cpp:180-234`,
+21 -5
View File
@@ -240,12 +240,28 @@ fn trim_g(s: &str) -> &str {
s.trim_end_matches('0').trim_end_matches('.')
}
/// A widget **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
/// A **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
///
/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12 widget
/// bindings do not push booleans. `lua_pushboolean 0x6f39f0` has seven call sites in the whole
/// image and not one of them is inside a widget registrar body — every predicate goes through
/// `lua_pushnumber`/`lua_pushnil`. Decision 1830.
/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12's widget
/// predicates (1830) and its 29 `Unit*` predicates (2043) do not push booleans. `lua_pushboolean
/// 0x6f39f0` has seven call sites in the whole image; **not one is inside a widget registrar body,
/// and no binding in the 83-entry unit table at `0x850438` calls it at all** — those predicates go
/// through `lua_pushnumber 0x6f3810` (the constant double `1.0`) / `lua_pushnil 0x6f37f0`, exactly
/// one value at every live `ret`.
///
/// **Not a whole-image absolute, and 2048 corrects 2043 for saying so.** `lua_pushboolean` is real
/// and one of its seven callers *is* a registered FrameScript binding: `IsPetAttackActive
/// 0x4be0e0` answers a genuine Lua `true`/`false` and **never nil** (ours matches — see
/// [`super::pet`]). The other six are `rawequal`/`pcall`/`xpcall` in the base table `0x811e28` and
/// two unregistered helpers. So "1.12 has no `lua_pushboolean`" is false; "these two predicate
/// families never reach it" is what the bytes support, and is what this helper encodes.
///
/// **The false leg is settled per body, never inherited from a neighbour.** `IsEnabled 0x7800b0`
/// is number-`1`/number-`0`-and-never-nil (`setne` + `fild`), which is what makes
/// `FriendsFrame.lua:404`'s `== 0` live code — so it does NOT come through here. Inside the unit
/// table itself the *numeric getters* answer the number `0.0` where the predicates answer `nil`
/// (`UnitLevel 0x518144`, `UnitMana 0x5177c8`, `UnitXP 0x5173f5`, ~15 more; `UnitSex 0x517f9f`
/// pushes `2.0`).
///
/// **Truthiness hides the difference and direct comparison inverts it**, which is why this survived
/// so long. `if frame:IsVisible()` reads the same either way; `if frame:IsVisible() == nil` does
+23 -6
View File
@@ -4,9 +4,16 @@
//!
//! **Every predicate here returns through one function** — [`super::unit_predicate`] when it reads
//! a snapshot field, [`predicate`] when it computes its own bool. Neither ever hands mlua a Rust
//! `bool`: 1.12 pushes the number `1` or `nil` and has no boolean on its binding surface at all
//! (decisions 1830, 2043). A new predicate that open-codes `Value::Integer(1)`/`Value::Nil`, or
//! returns a `bool`, is the drift those two records exist to stop.
//! `bool`: all 29 of the reference's unit predicates push the constant double `1.0`
//! (`lua_pushnumber 0x6f3810`) or `nil` (`lua_pushnil 0x6f37f0`), exactly one value at every live
//! `ret`, and **no binding in the 83-entry table at `0x850438` calls `lua_pushboolean 0x6f39f0` at
//! all** (decisions 1830, 2043, 2048). A new predicate that open-codes
//! `Value::Integer(1)`/`Value::Nil`, or returns a `bool`, is the drift those records exist to stop.
//!
//! **The scope of that claim is the unit table, not "the binding surface"** — 2043 said the wider
//! thing and 2048 corrected it. `lua_pushboolean` exists at `0x6f39f0` with seven call sites, and
//! one of them *is* a registered FrameScript binding: `IsPetAttackActive 0x4be0e0` answers a real
//! Lua `true`/`false`, never nil (ours already does — `super::super::pet`).
use mlua::{Lua, Value};
@@ -346,11 +353,21 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitReaction(unit, other) → 1..8 (hated..exalted) or nil. The live API is directional (unit's
// UnitReaction(unit, other) → the reaction scale, or nil. The live API is directional (unit's
// reaction toward `other`); our feed only resolves it for the "target" token toward the player,
// which is the sole caller (`TargetFrame_CheckFaction`), so the `other` arg is accepted and
// unused. `0` (unknown / not yet streamed) reports as nil, the API's "can't tell" — the target
// frame paints its name plate blue then, exactly like the reference.
// unused.
//
// **NOT a 1/nil predicate, and its nil does not mean "reaction 0"** (decision 2048, correcting
// 2043's aside). `0x5167e0` pushes `0x6061e0(u1, u2)` **plus one** (`0x51683e inc eax`,
// `0x516842 fild`) — a self-compare answers **5** — so the value is 1-based and **0 is
// unreachable**. Its nil leg (`0x51685f`) means only that a token failed to resolve to a live
// UNIT.
//
// Ours maps our own `reaction == 0` to nil because that is our sentinel for "not yet fed", and
// the observable is the same nil an unresolved token gives. The gap is the feed's, not the
// shape's: a resolved unit whose reaction has not streamed answers nil here where the reference
// answers a number. The target frame paints its name plate blue on that nil.
g.set(
"UnitReaction",
lua.create_function(|lua, (token, _other): (Value, Value)| {
+104 -48
View File
@@ -56,7 +56,7 @@ use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::interior::{classify_entity_interior, InteriorLit};
use crate::mesh_tag::{shade_of, with_shade, InteriorProbePayload};
use crate::mesh_tag::{exterior_payload, shade_of, with_shade, InteriorProbePayload};
use crate::terrain_stream::{doodad_ground_shade, ShadeResolve, TerrainStreamer};
// Decision 0354 generalized this file from "the MCSH ground-shade byte" to the entity light
@@ -214,6 +214,29 @@ impl GroundShade {
}
}
/// **This part's light is its own `CMapDoodadDef`'s, not any ancestor entity's light node** — a
/// WMO doodad prop spawned onto a streamed GameObject (a transport's deck cargo and its cabin
/// furniture, `entities::wmo_props`). This file's walk must pass it by whichever payload it is on.
///
/// **The reference proves the node cannot reach it** (wow-re
/// `models/scratch/transport-wmo-doodad-light.md`, 2026-09-06, VERIFIED at the bytes): a
/// transport's WMO is linked into the SAME global `TSExplicitList<CMapObjDef>` (`0xca7d98`) the
/// WMO scene walk iterates — there is no separate GameObject WMO band — so its MODD entries are
/// ordinary doodad defs. And `[def+0xa4]`, the sun scale this file's byte models, has exactly
/// FOUR writers image-wide: the ctor `0x6a7d73` (0.0), the WMO doodad-set commit `0x695bb3`
/// (1.0), the ADT MDDF commit `0x6b01bb` (1.0), and the MCSH refresh `0x698cb4` (0.5). None of
/// them is the light node: the 2.5-producer `0x69e280` is entered only from `0x671a73`/`0x69e913`
/// and the ramp `0x69e770` only through the thunk `0x671a90`, whose callers all load `[obj+0xe0]`
/// — a path a doodad def never takes. A transport has no M2 for a node to light at all
/// (`0x5f80e0` returns 0 for the transport types).
///
/// So the faithful value is **1.0**, or 0.5 from a SINGLE MCSH sample at the doodad's own
/// footprint — `0x698c50` is a one-shot queue drain that unlinks each entry, so the verdict
/// freezes at the pose the prop became resident at, and never ramps. Pushing the host's ramped,
/// re-sampled byte onto these parts was a divergence (decision 2047, the other half of 2031).
#[derive(Component)]
pub struct DoodadDefLit;
/// Roots owed a shade re-assert this frame even at an unchanged byte: some OTHER lane rewrote a
/// descendant part's `MeshTag` since [`detect_shade_reclaims`] last ran — the interior
/// classifier's exterior reclaim (1358), a late-attached part (`Added` counts as changed).
@@ -250,6 +273,39 @@ pub(crate) fn detect_shade_reclaims(
}
}
/// TEMP-VERIFY: how many parts carry the doodad-def marker, and how many of those the shade walk
/// would otherwise have reached.
pub fn b373_marker_count(
marked: Query<(), With<DoodadDefLit>>,
roots: Query<Entity, With<GroundShade>>,
children: Query<&Children>,
is_marked: Query<(), With<DoodadDefLit>>,
tagged: Query<(), With<MeshTag>>,
time: Res<Time>,
mut next: Local<f32>,
) {
if time.elapsed_secs() < *next {
return;
}
*next = time.elapsed_secs() + 5.0;
let mut under_roots = 0usize;
let mut marked_under_roots = 0usize;
for r in &roots {
for e in children.iter_descendants(r) {
if tagged.get(e).is_ok() {
under_roots += 1;
if is_marked.get(e).is_ok() {
marked_under_roots += 1;
}
}
}
}
info!(
"B373-VERIFY DoodadDefLit total {} | tagged parts under a shade root {under_roots}, of which marked {marked_under_roots}",
marked.iter().count(),
);
}
pub(crate) struct EntityShadePlugin;
impl Plugin for EntityShadePlugin {
@@ -259,7 +315,11 @@ impl Plugin for EntityShadePlugin {
// detector sits between the two so the reclaim is observed the frame it happens.
app.init_resource::<ShadeDirtyRoots>().add_systems(
Update,
(detect_shade_reclaims, update_ground_shade)
(
detect_shade_reclaims,
update_ground_shade,
b373_marker_count,
)
.chain()
.after(classify_entity_interior),
);
@@ -294,6 +354,7 @@ pub(crate) fn update_ground_shade(
&mut MeshTag,
Option<&InteriorLit>,
Has<InteriorProbePayload>,
Has<DoodadDefLit>,
),
Without<crate::billboard::BillboardCard>,
>,
@@ -306,6 +367,7 @@ pub(crate) fn update_ground_shade(
&mut MeshTag,
Option<&InteriorLit>,
Has<InteriorProbePayload>,
Has<DoodadDefLit>,
)>,
// Reused across frames: each shaded ROOT → its shade byte this frame (a few hundred entries).
// The card pass walks `ChildOf` up to the nearest such root — this map used to record every
@@ -432,14 +494,23 @@ pub(crate) fn update_ground_shade(
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
// the byte, so a settled entity writes nothing and never re-triggers render extraction.
for part in children.iter_descendants(root) {
let Ok((mut tag, lit, own_probe)) = parts.get_mut(part) else {
let Ok((mut tag, lit, own_probe, own_def)) = parts.get_mut(part) else {
continue;
};
if probe_payload(lit, own_probe) {
// Two reasons this walk passes a part by, and they are different questions.
// OWNERSHIP: a WMO doodad prop is lit by its own def, never by the node above it
// (`DoodadDefLit`) — it is only in this tree so it rides a moving transport.
if own_def {
continue;
}
if shade_of(tag.0) != byte {
tag.0 = with_shade(tag.0, byte);
// PAYLOAD: asked the only way the shade accessors can be reached
// (`mesh_tag::exterior_payload`); `None` = this part's bits 6..=18 are a probe slot.
let Some(ext) = exterior_payload(lit.is_some_and(InteriorLit::is_bake), own_probe)
else {
continue;
};
if shade_of(tag.0, ext) != byte {
tag.0 = with_shade(tag.0, byte, ext);
}
}
}
@@ -451,40 +522,25 @@ pub(crate) fn update_ground_shade(
// anchor or a deep joint) resolves to the NEAREST shaded root above it: with nested roots (a
// mounted unit — rider and mount each carry a node) that is the mount's, matching the
// one-node-per-object structure above; the old whole-tree map made this pick last-writer-wins.
for (card, mut tag, lit, own_probe) in &mut cards {
if probe_payload(lit, own_probe) {
for (card, mut tag, lit, own_probe, own_def) in &mut cards {
// Both questions again, and a card needs them at least as much: it is a world ROOT, so
// this pass finds its owner by walking UP — which is how a deck lantern's glow card and a
// cabin prop's alike reached the host GameObject that does not light them (B373, 2047).
if own_def {
continue;
}
let Some(ext) = exterior_payload(lit.is_some_and(InteriorLit::is_bake), own_probe) else {
continue;
};
let Some(byte) = card_root_shade(&root_shade, &child_of, card.follows()) else {
continue; // a fixed terrain doodad's card — its shade rides the material selector
};
if shade_of(tag.0) != byte {
tag.0 = with_shade(tag.0, byte);
if shade_of(tag.0, ext) != byte {
tag.0 = with_shade(tag.0, byte, ext);
}
}
}
/// **Does this instance's `MeshTag` payload carry an SH-probe slot?** If it does, this file must
/// not touch it: the shade byte occupies bits 6..=13 and the probe slot 6..=18, so a shade write
/// renames the slot to `(slot & 0x1f00) | byte` — a foreign probe, or an unallocated (zeroed ⇒
/// **black**) row.
///
/// Two populations answer yes, by two different components, and the guard used to know only the
/// first:
/// - the interior classifier's **Bake law** — an entity part standing in a WMO room, whose slot
/// the classifier re-seats as the body moves ([`InteriorLit::is_bake`]);
/// - a lit interior **MODD prop**, whose slot was folded once at spawn and never moves
/// ([`InteriorProbePayload`]).
///
/// The second only ever meets this walk on a WMO-display GameObject, whose doodad props are
/// parented under the net entity so they ride a moving transport (decision 0474) — a *transform*
/// relationship that the descendant walk read as a *light* one. That is bug B373: every cabin
/// prop on every 1.12 transport read probe slot `(its own & 0x1f00) | the boat's shade byte`, and
/// the ones that landed on an unallocated row drew as solid black silhouettes beside lit walls.
fn probe_payload(lit: Option<&InteriorLit>, own_probe: bool) -> bool {
own_probe || lit.is_some_and(InteriorLit::is_bake)
}
/// The shade byte a card inherits: the NEAREST shaded root at or above `follows` (the owner itself
/// first, then the `ChildOf` chain). `None` — no owner, or no shaded root over it — skips the card,
/// exactly as the old whole-tree map's miss did.
@@ -606,27 +662,27 @@ mod tests {
/// A transport's cabin furniture is where the second population lands inside an entity's
/// descendant walk, and every one of those props drew under a foreign probe.
///
/// (The Bake half is the classifier's own state and is pinned by `interior`'s tests; what is
/// new here is that the marker answers on its own, whatever the classifier says.)
/// The question now lives in `mesh_tag` beside the bits it is about, and the shade accessors
/// take its answer as a witness — so this test is about the two populations, not about a
/// caller remembering to ask, which the type system now handles.
#[test]
fn a_spawned_probe_payload_is_hands_off_with_no_interior_lit_at_all() {
let matte = InteriorLit::new(crate::interior::InteriorKind::Matte, Handle::default());
fn either_probe_population_denies_the_shade_writer_its_witness() {
assert!(
probe_payload(None, true),
"a spawn-time MODD prop owns its payload, with no InteriorLit to say so"
);
assert!(
probe_payload(Some(&matte), true),
"and it owns it whatever law the classifier would have applied"
);
assert!(
!probe_payload(Some(&matte), false),
"the matte indoor lane carries no slot — the byte is its field"
);
assert!(
!probe_payload(None, false),
exterior_payload(false, false).is_some(),
"an ordinary exterior part takes the shade byte"
);
assert!(
exterior_payload(true, false).is_none(),
"the classifier's Bake law owns the payload"
);
assert!(
exterior_payload(false, true).is_none(),
"a spawn-time MODD prop owns it too, with no InteriorLit to say so"
);
assert!(
exterior_payload(true, true).is_none(),
"and either alone is enough — the question is an OR, not a tie-break"
);
}
/// Nested shade roots — a mounted unit: rider root and mount root each carry a node, the mount
+75 -19
View File
@@ -110,6 +110,12 @@
//! furniture is parented under the GameObject for TRANSFORM reasons, so the entity light
//! node's descendant walk reached props it does not light, and every one of them went black.
//!
//! **It is no longer possible to forget** (2041): [`with_shade`] and [`shade_of`] take an
//! [`ExteriorPayload`], which only [`exterior_payload`] mints and only when both populations
//! answer no. The overlap itself cannot be designed away — the four mode-independent fields
//! cost 19 bits, leaving 13 for a payload region the two modes want 21 of — so the guarantee
//! has to live in the type system instead of in a reader's memory.
//!
//! A further *payload* writer (stealth, ghost form, …) should claim reserved bits through a typed
//! accessor here — never a new ad-hoc whole-payload convention (decision 0066's rule, upheld by
//! 0173's layout).
@@ -290,12 +296,47 @@ pub fn with_alpha(tag: u32, alpha: f32) -> u32 {
(tag & !ALPHA_MASK) | alpha_bits(alpha)
}
/// **Proof that an instance is on the EXTERIOR payload** — that bits 6..=13 are its ground-shade
/// byte and not the low eight bits of somebody's probe slot. The shade accessors take one, so the
/// question cannot be skipped: there is no way to reach [`with_shade`] or [`shade_of`] without
/// having asked [`exterior_payload`] and been told yes.
///
/// **The overlap is forced, so the discipline has to be typed.** The four fields that mean the
/// same thing in both modes cost 19 bits (alpha 6, rig 11, highlight 1, interior-fog 1), leaving
/// 13 for the payload region — and the shade byte (8) plus the probe slot (13) want 21. There is
/// no shrinking that fits: even a 4-bit shade beside an 11-bit probe needs 15. So the two fields
/// will always share bits 6..=13, and the only question is whether a writer can reach the wrong
/// one by forgetting to ask. Since B373 (2031, and 2041 for the witness) it cannot: it has to hold one of these.
#[derive(Clone, Copy)]
pub struct ExteriorPayload(());
/// **Ask the payload question**, the one place it is stated. `None` means this instance's bits
/// 6..=18 are an SH-probe slot and the shade writer must leave the whole region alone.
///
/// Two populations hold a probe slot, and knowing only one of them is what B373 was:
/// - `on_bake_law` — the interior classifier's Bake law, an entity part standing in a WMO room
/// whose slot the classifier re-seats as the body moves (`interior::InteriorLit::is_bake`). A
/// runtime state, so it is passed in rather than read off a component here.
/// - `own_probe` — a lit interior MODD prop, whose slot was folded once at spawn and never moves
/// ([`InteriorProbePayload`], spawned by the placed-model assembler).
///
/// The second only meets an entity light node on a WMO-display GameObject, whose doodad props are
/// parented under the net entity so they ride a moving transport — a *transform* relationship that
/// the shade writer's descendant walk read as a *light* one, renaming every cabin prop's slot to
/// `(slot & 0x1f00) | byte`.
pub fn exterior_payload(on_bake_law: bool, own_probe: bool) -> Option<ExteriorPayload> {
(!on_bake_law && !own_probe).then_some(ExteriorPayload(()))
}
/// Write the shade byte of an exterior-payload tag (`0` = lit … `255` = fully MCSH-shadowed),
/// preserving the alpha and rig fields and the flag bits. If the alpha field reads `0`, the tag
/// was the whole-payload-`0` *untagged ⇒ opaque* sentinel (the field is never legitimately `0` —
/// [`alpha_bits`] floors at `1`), so materialize it as opaque — otherwise a non-zero shade byte
/// would defeat the sentinel and the instance would decode alpha 0 (invisible).
pub(crate) fn with_shade(tag: u32, shade: u8) -> u32 {
///
/// Takes an [`ExteriorPayload`] because bits 6..=13 are shared with the probe slot: see that type
/// for why they must be, and 2031 for what happened when a caller wrote them without asking.
pub(crate) fn with_shade(tag: u32, shade: u8, _: ExteriorPayload) -> u32 {
(tag & !(ALPHA_MASK | SHADE_MASK)) | carried_alpha(tag) | (u32::from(shade) << SHADE_SHIFT)
}
@@ -309,8 +350,11 @@ pub fn alpha_of(tag: u32) -> f32 {
(tag & ALPHA_MASK) as f32 / ALPHA_MAX
}
/// Read back the shade byte of an exterior-payload tag (the shade writer's change gate).
pub(crate) fn shade_of(tag: u32) -> u8 {
/// Read back the shade byte of an exterior-payload tag (the shade writer's change gate). Takes the
/// same [`ExteriorPayload`] proof as [`with_shade`]: on a probe payload these bits are the slot's
/// low eight, so the value would be meaningless — and a change gate that compares a meaningless
/// value is how a wrong write gets *skipped* as well as how it gets made.
pub(crate) fn shade_of(tag: u32, _: ExteriorPayload) -> u8 {
((tag & SHADE_MASK) >> SHADE_SHIFT) as u8
}
@@ -346,10 +390,13 @@ pub fn describe(tag: u32) -> String {
(false, true) => " fog",
(false, false) => "",
};
// Both readings come off the RAW masks, deliberately: this is the one caller that has no
// material context and wants none — printing the ambiguity IS its job, so it is the one place
// that must not take an [`ExteriorPayload`] witness (it could not honestly produce one).
format!(
"{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
alpha_of(tag),
shade_of(tag),
(tag & SHADE_MASK) >> SHADE_SHIFT,
(tag & PROBE_MASK) >> PROBE_SHIFT,
rig_of(tag),
)
@@ -359,13 +406,19 @@ pub fn describe(tag: u32) -> String {
mod tests {
use super::*;
/// The exterior-payload witness for a test that is about the BITS, not about which population
/// an instance belongs to — the population question has its own test above.
fn ext() -> ExteriorPayload {
exterior_payload(false, false).expect("neither probe population")
}
#[test]
fn describe_prints_both_readings_of_the_shared_bits() {
// The 0-sentinel is called out by name rather than decoded as "α 0.000" (invisible), which
// is the one thing it never means.
assert!(describe(0).contains("untagged"));
// Exterior law: the shade byte. Interior law: the same bits inside the slot. Both, always.
let t = with_shade(alpha_bits(1.0), 255);
let t = with_shade(alpha_bits(1.0), 255, ext());
assert!(describe(t).contains("shade 255"), "{}", describe(t));
assert!(describe(t).contains("slot 255"), "{}", describe(t));
// A probe payload reads back its slot, and carries the fog flag it bakes in.
@@ -386,7 +439,7 @@ mod tests {
for a in [0.0, f32::MIN_POSITIVE, 0.25, 0.5, 1.0] {
assert_eq!(alpha_bits(a) & HIGHLIGHT_BIT, 0);
}
assert_eq!(with_shade(alpha_bits(1.0), 255) & HIGHLIGHT_BIT, 0);
assert_eq!(with_shade(alpha_bits(1.0), 255, ext()) & HIGHLIGHT_BIT, 0);
assert_eq!(probe_bits(8191) & HIGHLIGHT_BIT, 0);
assert_eq!(rig_bits(2047) & HIGHLIGHT_BIT, 0);
// Both field writers preserve an already-set flag.
@@ -395,7 +448,7 @@ mod tests {
HIGHLIGHT_BIT
);
assert_eq!(
with_shade(HIGHLIGHT_BIT | 0x3f, 7) & HIGHLIGHT_BIT,
with_shade(HIGHLIGHT_BIT | 0x3f, 7, ext()) & HIGHLIGHT_BIT,
HIGHLIGHT_BIT
);
}
@@ -413,14 +466,14 @@ mod tests {
#[test]
fn alpha_and_shade_fields_compose() {
// Round-trip: shade survives an alpha write, alpha survives a shade write.
let t = with_shade(alpha_bits(1.0), 200);
assert_eq!(shade_of(t), 200);
let t = with_shade(alpha_bits(1.0), 200, ext());
assert_eq!(shade_of(t, ext()), 200);
let t = with_alpha(t, 0.25);
assert_eq!(shade_of(t), 200);
assert_eq!(shade_of(t, ext()), 200);
assert_eq!(t & ALPHA_MASK, alpha_bits(0.25));
let t = with_shade(t, 10);
let t = with_shade(t, 10, ext());
assert_eq!(t & ALPHA_MASK, alpha_bits(0.25));
assert_eq!(shade_of(t), 10);
assert_eq!(shade_of(t, ext()), 10);
}
#[test]
@@ -445,7 +498,7 @@ mod tests {
let spawn = rig_bits(1000) | alpha_bits(1.0);
assert_eq!(rig_of(spawn), 1000);
assert_eq!(rig_of(with_alpha(spawn, 0.3)), 1000); // fades (writers 1, 3, 4)
assert_eq!(rig_of(with_shade(spawn, 200)), 1000); // the ground-shade ramp (writer 5)
assert_eq!(rig_of(with_shade(spawn, 200, ext())), 1000); // the ground-shade ramp (writer 5)
let indoor = with_interior_probe(spawn, 4321); // the classifier's Bake law (writer 2)
assert_eq!(rig_of(indoor), 1000);
assert_eq!((indoor & PROBE_MASK) >> PROBE_SHIFT, 4321);
@@ -471,9 +524,12 @@ mod tests {
// its room fog).
let t = INTERIOR_FOG_BIT | alpha_bits(1.0);
assert_eq!(with_alpha(t, 0.25) & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
assert_eq!(with_shade(t, 191) & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
assert_eq!(
with_shade(t, 191, ext()) & INTERIOR_FOG_BIT,
INTERIOR_FOG_BIT
);
// And it never leaks into the payload fields it rides above.
assert_eq!(shade_of(t), 0);
assert_eq!(shade_of(t, ext()), 0);
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
// A probe payload keeps its slot decode with the flag set.
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
@@ -521,7 +577,7 @@ mod tests {
#[test]
fn a_shade_write_renames_a_probe_slot_instead_of_breaking_it() {
let slot = 440u16; // 0b1_1011_1000 — bits 14..=18 hold 1, bits 6..=13 hold 184
let tag = with_shade(probe_bits(slot), 191);
let tag = with_shade(probe_bits(slot), 191, ext());
assert_eq!(
(tag & PROBE_MASK) >> PROBE_SHIFT,
447,
@@ -538,11 +594,11 @@ mod tests {
fn with_shade_materializes_the_untagged_sentinel_as_opaque() {
// Shading an untagged (payload 0) instance must not defeat the "0 ⇒ opaque" rule by making
// the payload non-zero with a zero alpha field.
let t = with_shade(0, 128);
let t = with_shade(0, 128, ext());
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
assert_eq!(shade_of(t), 128);
assert_eq!(shade_of(t, ext()), 128);
// Same through the highlight bit (payload still reads 0 under the mask).
let t = with_shade(HIGHLIGHT_BIT, 128);
let t = with_shade(HIGHLIGHT_BIT, 128, ext());
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
}
}
@@ -135,6 +135,18 @@ pub fn spawn_model_entities(
// The shared delta table both registries slot into (decision 1381) — registration allocates
// here and bakes the slot into the material.
anim_table: &mut crate::mat_anim_table::MatAnimTable,
// **Is this placement a WMO doodad prop hanging off a streamed entity?** (The WMO-gameobject
// lane — a transport's deck cargo and cabin furniture.) It is the LANE, where `card_owner`
// below is only the anchor: that anchor is minted per placement and only when the model has
// billboard batches at all, so it answers "does this prop have cards to follow", never "which
// lane is this". Reading it as the lane is what left the marker below un-set on 133 of the
// ship's 134 props.
//
// What it decides here: [`crate::entity_shade::DoodadDefLit`] on every batch, which is the
// engine asserting its OWN invariant instead of trusting a caller to remember it (2047 — and
// 2041's lesson one level up). A prop's light is its `CMapDoodadDef`'s; it is under the net
// entity so it rides the deck, and the entity light node's descendant walk must pass it by.
entity_hosted: bool,
// `Some(anchor)` for a prop spawned ON a streamed entity (the WMO-gameobject path): a boneless
// model's billboard cards FOLLOW this anchor (`BillboardCard::following` — the entity-path law,
// decision 0153) instead of baking a world pivot, so they track the moving owner and
@@ -744,6 +756,16 @@ pub fn spawn_model_entities(
.entity(entity)
.insert(crate::mesh_tag::InteriorProbePayload);
}
// …and, on the entity-hosted lane, that this batch's light is its own doodad def's rather
// than its host's. Asserted here rather than by the caller so the rule cannot be half
// applied: this is the site that knows about EVERY batch, cards included, and a card is
// exactly what the caller cannot reach (it is a world root kept out of the returned list,
// and the shade writer's card pass finds it by walking UP to the host).
if entity_hosted {
commands
.entity(entity)
.insert(crate::entity_shade::DoodadDefLit);
}
// Animated material alpha (decision 0130 phase 2): the rare batch whose colour-alpha/weight
// tracks animate (fire flicker) or constantly dim gets its per-instance sampler; the
// visibility authority composes the value into the render-alpha tag + the A ≤ 0 cull.
@@ -208,7 +208,8 @@ pub(super) fn spawn_loaded_placements(
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
false, // world-static: not the entity-hosted lane
None, // world-static placement: cards bake their world pivot
Some((&mut *merge, MergeSite::Doodad { owner: p.owner })),
// The retained-pass collector (1429/1431) — ADT doodads are its lane.
staticgx
@@ -388,7 +389,8 @@ pub(super) fn spawn_loaded_placements(
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
false, // world-static: not the entity-hosted lane
None, // world-static placement: cards bake their world pivot
Some((
&mut *merge,
MergeSite::Wmo {
@@ -732,7 +734,8 @@ pub(super) fn spawn_loaded_placements(
&mut uv_reg,
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
false, // world-static: not the entity-hosted lane
None, // world-static placement: cards bake their world pivot
// The prop merge site (1418 lane 3): keyed by the rooms that name the prop,
// slot baked per vertex for the interior lane.
Some((