a refused login says why, the deep ocean darkens, and the blade trails
- a refused character login left the player on a loading screen with no world behind it and no way off — reported as sitting at character select with no message, and the symptom was worse than the report: `SMSG_CHARACTER_LOGIN_FAILED` (0x41) had a name and no parse arm, so the client never heard it, and it was not at character select when the refusal landed, because the IO thread sends `CMSG_PLAYER_LOGIN` and announces the connection in the same breath (the entry's head start, 0777), so `ClientState::InWorld` was already set and the loading cover was up waiting for a snap the server had just declined to send; the wire half parses the opcode into `ServerPacket::CharacterLoginFailed` / `SessionEvent::CharacterLoginFailed` and carries the byte RAW, since vmangos sends a bare `1` and mangos-classic its own `CharLoginFailReasons`, and a wire layer that interpreted it would be picking a server; the refusal is an entry being REVOKED, not a failure to enter — `Cycle::LoginRefused` runs the same `SessionEnd::LoggedOut` teardown a `/logout` does, the cover cuts to black, `back_on_login_refused` takes the select screen back with the relist's roster underneath, and clearing `pending_pick` is load-bearing: it is 0065's reconnect memory and the relist is auto-answered with it, so keeping it would re-enter the refused character forever from behind a black screen; the byte is a 1-BASED REASON INDEX, not a status code — wow-re read `OnCharLoginResult` (`0x5aad70`): `dec eax; cmp eax,5; ja` into a six-entry jump table mapping 1..=6 onto `CHAR_LOGIN_*` 0x3e/0x3f/0x40/0x42/0x43/0x44 and SKIPPING 0x41 `CHAR_LOGIN_FAILED`, reachable only as the default, so the tempting `0x3d + byte` is right for four rows and wrong for the rest; `char_login_refusal_text` is that table, resolved against the player's own `GlueStrings.lua` and asserted against it (vmangos's `1` therefore reads "World server is down" — the server's dialect, and what the reference would say); the select screen runs the shared `login::drive_dialog` rather than a second copy of the reference's one `GlueDialog`, which found two holes — `to_select_on_roster` closed ANY dialog on an arriving roster while the refusal's own relist produces one a second later, so it would have wiped the message (the original bug one layer up; now scoped to status/queued), and `preflight`'s descriptor latch outlived the session that armed it, announcing "the avatar never streamed in" from character select, now disarmed on any disconnect; a live probe against vmangos pins the three facts this rests on — the byte arrives as 0x01, the roster still serves after it, a retry enters the world on the same socket — and `WOW_REFUSE_LOGIN=1` refuses the first pick of a run by sending a non-player guid, the one guard that needs no server state, so a path no real account can stage by hand is walkable on demand (2071)
- the ocean's depth ramp was never a placeholder, and its deep row is darker than we drew it: four files called the ADT ocean's `V = byte/255` a stand-in "pending its own RE" — the director's report had the sea fading to deep colour about 6x too slowly — on the claim that ocean takes a non-LUT UV path via `0x68d890`, which is the MAGMA vert-fill, as `benilla_adt::LiquidVertex::texcoords` had said by that same address all along; the ocean fill is `0x68d690` and it reads a depth LUT exactly as the river fill `0x68d790` does, `0x68c4c0` building both side by side (`0xc81768 = min(d/42, 1)` river, `0xc7fcd8 = min(d/255, 1)` ocean), the two fills differing in exactly 3 of 246 bytes — one `fld` displacement — so there is no second ocean-only depth term, and wow-re's cross-check found `/255` bit-exact for all 256 entries; the divisors differ because the authored bytes do — a census over every MCLQ block on Azeroth + Kalimdor, each wet vertex's depth byte against its own geometric depth, reads a river at 8.96 byte/yd (reproducing the independently-measured ≈8.5, the control) and the sea at 1.72, 5.2x gentler, so `/255` saturates at ~148 yd and `/42` at ~4.7 yd, the same ramp in yards and not a 6x slower one, while 83.4% of ocean vertices carry byte 255 outright, so the open sea is authored fully deep and the ramp is only a shore band; a test pins both divisors against the raw MCLQ bytes (swapping the ocean onto `/42` fails it), `examples/liquid_depth_census` is the measurement with named shore-band pins, `WOW_OCEAN_DEPTH_DIV` is the A/B, and the alpha-ramp deferral closes with it — `0xc7fbc0`'s `1.6·(i/63)^8` is the sky glare falloff, bound at `0x685257` inside a branch proven dead, so the linear `127 + 2·row` alpha shipped was right; and the finding nobody asked for: `0x68a830` fills the 8×64 swatch as an exact integer accumulator in BYTE space, `row(i) = c0 + floor(i·(c1−c0)/64)`, so the ramp never reaches the deep endpoint (row 63 is ~98.4% of the way), and an ocean-only tail `[0x68a9c0, 0x68aa36)` — gated on last row AND selector 0, river being selector 1 — runs RGB→HSV, `V ×= 0.9` (`0x3f666666` at `0x8102ec`), HSV→RGB and forces that row's alpha to 255, which reduces to `floor(0.9·byte)` per channel (99.03% bit-exact over all 2^24 triples, max 1/255); byte 255 ⇒ `V = 1.0` ⇒ row 63 exactly, so the darkened row IS the open sea, and benilla had rendered deep ocean ~10% too bright over most of the water in the world — an error that survived every regression diff because no capture scenario holds any ocean; `liquid.wgsl`'s `swatch_row` / `swatch_at` port the accumulator, the LINEAR/LINEAR CLAMP sampling (so the darkening ramps in across the final 1/64 of V rather than stepping) and the ocean-only tail, on the ADT lane only — the WMO arms keep the plain lerp, their opacity being a different 256-entry ramp — pinned by a test against wow-re's published byte vectors (row 62 = 12,36,41; row 63 = 11,35,40, green one ABOVE the deep endpoint; after the tail 9,31,36) that also asserts the WGSL still carries the formula; rivers move at most 7/255 from the row quantization (2069, 2074)
- the weapon swing trail — `SpellVisualKit` CharProc type 8, which benilla had dropped on the floor with the rest of the dispatcher's unmodelled cases: 34 of 1772 kits carry it, 27 are reached from a live `Spell.dbc` chain, and 16 of those name NO emitter slot at all, so the ribbon is their entire visual — kit 324 alone reaches 218 spells (Heroic Strike, Overpower, Mortal Strike, Bloodthirst, Rend) and rendered nothing beyond the swing animation, with 506 (Revenge, Riposte, Mongoose Bite), 557 (Sunder Armor), 646 (Hamstring, Wing Clip), 399 (Sinister Strike), 822 (Garrote) and 823 (Feint, Disengage) in the same shape; the proc draws nothing — `0x60d80a` latches a packed colour and a duration on the unit, and the unit's next `PlayAnimation` (`0x5fe48e`, the fields' only reader) hands both to its two weapon-hand trail objects and clears the duration, so an arm fires exactly once and a second proc on a live trail RESETS its ring rather than stacking; each object rings 128 samples of its weapon model's `$WTB`/`$WTT` markers (455 of the 571 weapon models author both) and draws them as a strip that fades from opaque at the OLDEST end of the arc to ≈0 at the blade, the alpha being a clock and not just an opacity; `weapon_trail.rs` transcribes the arithmetic rather than approximating it — the truncating `_ftol` decode (not the small-int idiom every other integer param takes, which would yield colour 0 and no trail at all), `fadeStep`'s low-byte floor (`cmp al,1`, so a ≈768 ms hitch takes a floor of 1 instead of its own huge value), the alpha-derived termination, and the ring's writer/reader modulus mismatch (`& 127` stored, `% 127` read — shipped 5875 behaviour, reachable only by Whirlwind's 10 000 ms spin); the draw state was dispatched rather than guessed and the guess was wrong in three of seven places: the callback's six EGxRs writes say `0x07 = 2` (ordinary SRC_ALPHA / INV_SRC_ALPHA — additive is mode 3, the difference between a ribbon that whites out crossing itself and a coloured smear) and `0x10 = 0` (depth test OFF: the arc draws over the world and over the swinging character, never eaten by his own shoulder), so `EffectDrawSpec::no_depth_test` is a new pipeline-key bit on the shared effect lane with every other producer explicitly false, where the state had only existed as the global `WOW_PARTICLE_NODEPTH` debug lever; the third — lighting ON over a vertex format with no normal — is a verified state that is not yet a verified appearance and ships unlit as a named deviation with the question out; the consume edge is `AnimDriver::started_anim`, not the one-shot alone, because `0x5fe2f0` is the image's SINGLE animation entry point, locomotion included — a unit that simply starts running consumes the arm, which is how Charge (kit 44, 38 spells, anim id −1) trails at all; kit 324's 600 ms duration is on screen for 1250 ms at 60 Hz, since `fadeStep` is `trunc(dt·alpha/300)` and shrinks with the alpha it decays, twice wow-re's own estimate; the warm pass gets the depth-test-off pipeline as the one combination that ships (alpha, unlit, no rasterizer settle) rather than as another factor of the cross product — 36 more warm draws per camera for pipelines nothing asks for — so the first Heroic Strike does not compile one live; and `benilla-extract charprocs` grows the trail census that found the 34/27/16 split (2076)
- a zero injury column plays nothing: benilla walked down the wound-grunt family when the selected column was 0 — crushing to critical to ordinary — on a comment with no citation ("crushing rows are often 0 in data"), a true observation about the data and a false inference about the client; `0x623490` tests the selected id once (`0x6234e6 test ebx,ebx ; je 0x62350e`, the shared epilogue) and no fallback is even reachable — the row pointer dies at `0x6234e4`, so neither the bus pick `0x623b10` nor the play `0x458890` ever sees the row to re-read a column from, and the CGPlayer twin `0x62f880` is identical; it mattered because the class-2 chance roll had just landed beside it (2073): a crushing blow skips the roll (class 9 carries threshold 100) AND fell through to the ordinary kit, so it was louder than an ordinary hit on the same creature by two routes; `sound/combat.rs` now plays the selected column or nothing — crushing → column 5, critical → 4, ordinary → 3 — and a crushing blow on a creature with no crushing kit, which is most of the 406 rows (`Skeleton`, `Silithid`, `MountainGiant` among them), is silent, the reference's behaviour rather than an omission (2075)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -38,12 +38,15 @@ pub(super) fn select_input(
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
mut intent: ResMut<crate::login::LoginIntent>,
|
||||
glue_dialog: Res<crate::login::LoginDialog>,
|
||||
time: Res<Time>,
|
||||
mut last_click: Local<Option<(usize, f32)>>,
|
||||
) {
|
||||
// A modal owns the input while it is up — the delete confirm, the AddOns list, or the realm
|
||||
// list, which stands over this screen rather than replacing it.
|
||||
if dialog.open || panel.open || realms.shown {
|
||||
// A modal owns the input while it is up — the delete confirm, the AddOns list, the realm
|
||||
// list (which stands over this screen rather than replacing it), or the shared glue dialog
|
||||
// (a refused character login is said in that one, and its Okay must not double as this
|
||||
// screen's Enter World / Escape).
|
||||
if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
|
||||
@@ -32,9 +32,11 @@ mod screen;
|
||||
use benilla_protocol::{CharAction, Character};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
use crate::net::{
|
||||
CharActionResultMessage, CharListMessage, CharPick, CharRequest, EnteredWorldMessage,
|
||||
LoggedOutMessage,
|
||||
CharActionResultMessage, CharListMessage, CharPick, CharRequest, CharacterLoginFailedMessage,
|
||||
EnteredWorldMessage, LoggedOutMessage,
|
||||
};
|
||||
|
||||
/// The app's lifecycle: which screen owns the session (decision 0193).
|
||||
@@ -117,6 +119,7 @@ impl Plugin for CharSelectPlugin {
|
||||
apply_roster_policy,
|
||||
enter_on_connected,
|
||||
back_on_logout,
|
||||
back_on_login_refused,
|
||||
back_on_disconnect,
|
||||
// LAST, and outside both `run_if`s: it mirrors the pick this frame ended
|
||||
// with, and world entry is reached from the create screen too (1622).
|
||||
@@ -129,6 +132,12 @@ impl Plugin for CharSelectPlugin {
|
||||
input::rotate_model,
|
||||
debug_select_dialog,
|
||||
dialog::drive_delete_dialog,
|
||||
// The shared glue dialog (`crate::login::drive_dialog`) — the reference's
|
||||
// one `GlueDialog`, which on this screen carries the refused character
|
||||
// login. Its home is the login module only because that is where it was
|
||||
// first needed; the widget is the glue layer's, and the two screens run
|
||||
// the same instance of it rather than each keeping a copy.
|
||||
crate::login::drive_dialog,
|
||||
// Before the list refresh, and before `select_input` reads a click that
|
||||
// landed on the panel rather than the screen (decision 1196).
|
||||
debug_select_addons,
|
||||
@@ -573,6 +582,83 @@ fn back_on_disconnect(
|
||||
next.set(ClientState::Login);
|
||||
}
|
||||
|
||||
/// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) → back to the
|
||||
/// glue layer, pick cleared, and the refusal said out loud.
|
||||
///
|
||||
/// The entry it undoes was optimistic: the IO thread announces the connection in the same breath
|
||||
/// as the pick (so the destination's tiles start streaming a round-trip early, decision 0777), and
|
||||
/// the refusal arrives after. So this runs the logout's transition on a world that was only ever
|
||||
/// half-built — the cover comes down with it ([`crate::loading_screen`]), and the IO thread's
|
||||
/// relist puts the roster back underneath.
|
||||
///
|
||||
/// **Clearing the pick is the load-bearing half.** `pending_pick` is 0065's reconnect memory, and
|
||||
/// the relist behind this refusal produces exactly the roster it auto-answers: left set, the
|
||||
/// client would re-enter the character the server just refused, be refused again, and loop — from
|
||||
/// behind a black screen, since each pass raises the cover afresh.
|
||||
fn back_on_login_refused(
|
||||
mut msgs: MessageReader<CharacterLoginFailedMessage>,
|
||||
mut roster: ResMut<Roster>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut dialog: ResMut<crate::login::LoginDialog>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
) {
|
||||
let Some(msg) = msgs.read().last().copied() else {
|
||||
return;
|
||||
};
|
||||
roster.pending_pick = None;
|
||||
next.set(ClientState::CharSelect);
|
||||
let empty = GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
dialog.open_error(char_login_refusal_text(strings, msg.result));
|
||||
}
|
||||
|
||||
/// The refusal byte, in the client's own words — the reference's jump table `0x5aae08`,
|
||||
/// transcribed.
|
||||
///
|
||||
/// **The byte is a 1-based reason index, not a status code**, and that is the whole reason this
|
||||
/// table exists rather than an offset. `ClientServices::OnCharLoginResult 0x5aad70` reads it as
|
||||
/// `movzx eax,byte[ebp+8]; dec eax; cmp eax,5; ja <default>` and jumps through a six-entry table,
|
||||
/// which maps `1..=6` onto the `CHAR_LOGIN_*` status codes `0x3e, 0x3f, 0x40, 0x42, 0x43, 0x44` —
|
||||
/// **skipping `0x41`**, `CHAR_LOGIN_FAILED`, which is reachable only as the default. So the
|
||||
/// tempting `0x3d + byte` is right for four rows and wrong for the rest, and `0` and anything past
|
||||
/// `6` are "Login failed" rather than an out-of-bounds read: the `ja` guard precedes the table.
|
||||
/// (VERIFIED off `WoW.exe`, cross-checked — wow-5875-re
|
||||
/// `system/net/scratch/char-login-failed-law.md`. The strings are the shipped
|
||||
/// `GlueStrings.lua:158-166`, quoted here only as the graceful-absence fallback.)
|
||||
///
|
||||
/// Both emulators speak this dialect. vmangos sends a bare `1` for all three of its refusal
|
||||
/// guards (`PlayerLoading() || GetPlayer() || !guid.IsPlayer()`), so **every** refusal from our
|
||||
/// own local server reads "World server is down" — the reference would say exactly that too, and
|
||||
/// saying anything better here would be benilla inventing a diagnosis the client cannot make.
|
||||
/// mangos-classic's live enum is `CharLoginFailReasons` `0x01..=0x08`; its `ResponseCodes`
|
||||
/// `CHAR_LOGIN_*` block, whose values would NOT survive this table, is commented out.
|
||||
pub(crate) fn char_login_refusal_text(strings: &GlueStrings, result: u8) -> &str {
|
||||
let (key, fallback): (&str, &str) = match result {
|
||||
1 => ("CHAR_LOGIN_NO_WORLD", "World server is down"),
|
||||
2 => (
|
||||
"CHAR_LOGIN_DUPLICATE_CHARACTER",
|
||||
"A character with that name already exists",
|
||||
),
|
||||
3 => (
|
||||
"CHAR_LOGIN_NO_INSTANCES",
|
||||
"No instance servers are available",
|
||||
),
|
||||
4 => (
|
||||
"CHAR_LOGIN_DISABLED",
|
||||
"Login for that race, class, or character is currently disabled.",
|
||||
),
|
||||
5 => ("CHAR_LOGIN_NO_CHARACTER", "Character not found"),
|
||||
6 => (
|
||||
"CHAR_LOGIN_LOCKED_FOR_TRANSFER",
|
||||
"Your character is currently locked as part of the paid character transfer process.",
|
||||
),
|
||||
// `0`, and `7` up (mangos-classic's `LOCKED_BY_BILLING` and `FAILED` among them): the
|
||||
// switch's default arm, which is the only way `CHAR_LOGIN_FAILED` is ever reached.
|
||||
_ => ("CHAR_LOGIN_FAILED", "Login failed"),
|
||||
};
|
||||
strings.text(key, fallback)
|
||||
}
|
||||
|
||||
/// A confirmed `/logout` → back to the glue layer, pick cleared (the follow-up roster must be
|
||||
/// shown, not auto-answered).
|
||||
///
|
||||
@@ -1130,6 +1216,109 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A refused character login takes the entry back** — and does it in the same frame the
|
||||
/// entry was announced in.
|
||||
///
|
||||
/// The IO thread sends `CMSG_PLAYER_LOGIN` and emits `Connected` in the same breath (the
|
||||
/// entry's head start, decision 0777), so when the server refuses immediately — which is the
|
||||
/// vmangos guard's whole shape: `PlayerLoading() || GetPlayer() || !guid.IsPlayer()` answers
|
||||
/// before it touches the database — `Connected` and `SMSG_CHARACTER_LOGIN_FAILED` reach the
|
||||
/// app in ONE drain. Both edges then fire in one `Update`, and the refusal has to be the last
|
||||
/// word, exactly as a lost session does one test up.
|
||||
///
|
||||
/// Without this the client flips `InWorld` against a world the server has just declined to
|
||||
/// give it: no snap, no objects, and a loading cover armed for a snap that will never come —
|
||||
/// which is the shape the whole fix exists to end.
|
||||
#[test]
|
||||
fn a_refused_login_beats_the_entry_it_revokes() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
|
||||
.insert_state(ClientState::CharSelect)
|
||||
.init_resource::<Roster>()
|
||||
.init_resource::<crate::ui_script::PlayerUiHover>()
|
||||
.init_resource::<crate::ui_script::UiKeyboardCapture>()
|
||||
.add_message::<EnteredWorldMessage>()
|
||||
.add_message::<CharacterLoginFailedMessage>()
|
||||
.init_resource::<crate::login::LoginDialog>()
|
||||
.add_systems(Update, (enter_on_connected, back_on_login_refused).chain());
|
||||
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
|
||||
|
||||
app.world_mut().write_message(EnteredWorldMessage {
|
||||
billing_time_rested: 0,
|
||||
tutorial_flags: None,
|
||||
});
|
||||
app.world_mut()
|
||||
.write_message(CharacterLoginFailedMessage { result: 0x01 });
|
||||
app.update();
|
||||
app.update(); // `StateTransition` applies the pending state at the next frame
|
||||
|
||||
assert_eq!(
|
||||
*app.world().resource::<State<ClientState>>().get(),
|
||||
ClientState::CharSelect,
|
||||
"the refusal must win — the reference leaves the player on the select screen it \
|
||||
never actually took them off",
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<Roster>().pending_pick,
|
||||
None,
|
||||
"and the pick goes with it: the relist behind the refusal is auto-answered with \
|
||||
`pending_pick`, so keeping it would re-enter the character just refused, forever",
|
||||
);
|
||||
// And the player is told. No GlueStrings in this App, so this is the fallback literal —
|
||||
// the shipped sentence for vmangos's `1` is asserted against the real chain below.
|
||||
assert_eq!(
|
||||
app.world().resource::<crate::login::LoginDialog>().text,
|
||||
"World server is down",
|
||||
"a refusal the player cannot see is the bug this whole path exists to end",
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every refusal byte resolves to the sentence 1.12 actually ships**, read off the player's
|
||||
/// own chain — the char-create screen's regression (2045/2052) applied to the table this
|
||||
/// screen owns. Skips without client data.
|
||||
///
|
||||
/// The two rows worth writing down. `1` is the only byte vmangos ever sends, and it reads
|
||||
/// "World server is down" — not the "duplicate character" its own guard actually means, which
|
||||
/// is the server's dialect and not ours to improve on. And `4` is where the tempting
|
||||
/// `0x3d + byte` arithmetic breaks: the reference's table skips `CHAR_LOGIN_FAILED` (0x41),
|
||||
/// so `4` is `CHAR_LOGIN_DISABLED` and not the "Login failed" an offset would give.
|
||||
#[test]
|
||||
fn every_refusal_byte_resolves_in_the_real_glue_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
|
||||
assert_eq!(char_login_refusal_text(&strings, 1), "World server is down");
|
||||
assert_eq!(
|
||||
char_login_refusal_text(&strings, 4),
|
||||
"Login for that race, class, or character is currently disabled.",
|
||||
);
|
||||
assert_eq!(char_login_refusal_text(&strings, 5), "Character not found");
|
||||
|
||||
// The default arm, from both ends of the guard the reference's `dec eax; cmp eax,5; ja`
|
||||
// draws — and it must be the same sentence, since there is only one default.
|
||||
let default = char_login_refusal_text(&strings, 0);
|
||||
assert_eq!(default, "Login failed");
|
||||
for byte in [7u8, 8, 0x3f, 0x43, 0xff] {
|
||||
assert_eq!(char_login_refusal_text(&strings, byte), default);
|
||||
}
|
||||
|
||||
// Every named byte must resolve to a REAL key — a fallback that happens to match would
|
||||
// hide a missing key forever.
|
||||
let map = crate::glue_strings::table_from_chain(&mut chain).into_map();
|
||||
for key in [
|
||||
"CHAR_LOGIN_NO_WORLD",
|
||||
"CHAR_LOGIN_DUPLICATE_CHARACTER",
|
||||
"CHAR_LOGIN_NO_INSTANCES",
|
||||
"CHAR_LOGIN_DISABLED",
|
||||
"CHAR_LOGIN_NO_CHARACTER",
|
||||
"CHAR_LOGIN_LOCKED_FOR_TRANSFER",
|
||||
"CHAR_LOGIN_FAILED",
|
||||
] {
|
||||
assert!(map.contains_key(key), "{key} is not in the shipped table");
|
||||
}
|
||||
}
|
||||
|
||||
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
|
||||
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
|
||||
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
|
||||
|
||||
@@ -657,6 +657,7 @@ pub(super) fn exit_select(
|
||||
roots: Query<Entity, With<CharSelectUi>>,
|
||||
mut preview: ResMut<GluePreview>,
|
||||
mut dialog: ResMut<super::dialog::DeleteDialog>,
|
||||
mut glue_dialog: ResMut<crate::login::LoginDialog>,
|
||||
) {
|
||||
for e in &roots {
|
||||
commands.entity(e).despawn();
|
||||
@@ -666,4 +667,13 @@ pub(super) fn exit_select(
|
||||
preview.look = None;
|
||||
preview.scene = None;
|
||||
dialog.close();
|
||||
// The shared glue dialog is this screen's too while it is up (a refused character login), and
|
||||
// it must not follow us into the world or onto the create screen — the login screen's own
|
||||
// `exit_login` closes it on the same edge for the same reason. The tree goes with it **here**
|
||||
// rather than being left to the driver: the driver runs on the glue screens only, so on the
|
||||
// edges out of the glue layer there would be nobody left to despawn the root.
|
||||
if let Some(root) = glue_dialog.root.take() {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
glue_dialog.close();
|
||||
}
|
||||
|
||||
@@ -856,6 +856,20 @@ pub(crate) struct AnimDriver {
|
||||
sheath_byte: Option<u8>,
|
||||
/// The pending mid-animation weapon swap, while a draw/stow one-shot is in flight.
|
||||
sheath_swap: Option<SheathSwap>,
|
||||
/// **Did this unit start an animation THIS frame** — of any kind: a one-shot, a masked
|
||||
/// overlay, a cast/channel hold, or the mode machine's own base/gait play. Rewritten every
|
||||
/// pass by [`driver::drive_animations`], so a reader one system later sees exactly the
|
||||
/// frame's edge.
|
||||
///
|
||||
/// Its one consumer is the weapon-trail latch ([`crate::weapon_trail`], decision 2076). The
|
||||
/// reference consumes `unit+0xd1c`/`+0xd20` inside `CGUnit::PlayAnimation 0x5fe2f0` itself
|
||||
/// (`0x5fe48e`, the fields' only reader), and `0x5fe2f0` is the **single** animation entry
|
||||
/// point in the image — 40 call sites, locomotion among them (`0x602c60` → `0x5fd9e0` →
|
||||
/// `0x5fd8b0` → `0x5fd100`), which is why this is not a one-shot flag: a unit that simply
|
||||
/// starts running consumes the arm, and that is what starts Charge's trail (wow-re
|
||||
/// `charproc8-trail-draw-state.md` §11.5/§11.6). benilla's driver is one batched system, not
|
||||
/// a per-play function, so the arm cannot be read at the call the way the reference reads it.
|
||||
started_anim: bool,
|
||||
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
|
||||
/// sent to the SpineLow overlay (moving / seated / airborne-in-combat), playing *beside* [`Mode`]
|
||||
/// while the base track keeps driving the legs (run / sit / jump-arc). `None` = no overlay; a
|
||||
@@ -1022,6 +1036,7 @@ impl Default for AnimDriver {
|
||||
sheath_cur: None,
|
||||
sheath_byte: None,
|
||||
sheath_swap: None,
|
||||
started_anim: false,
|
||||
overlay: None,
|
||||
overlay_fade: None,
|
||||
wound: None,
|
||||
@@ -1078,6 +1093,18 @@ impl AnimDriver {
|
||||
self.sheath_cur
|
||||
}
|
||||
|
||||
/// Did this unit start an animation this frame — see [`Self::started_anim`].
|
||||
pub(crate) fn started_anim(&self) -> bool {
|
||||
self.started_anim
|
||||
}
|
||||
|
||||
/// Stand in for the driver's own write, so a consumer's test can raise the edge without
|
||||
/// standing up the whole animation pass to produce it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_started_anim(&mut self, played: bool) {
|
||||
self.started_anim = played;
|
||||
}
|
||||
|
||||
/// Whether a draw/stow ceremony is in flight — the manual toggle's mid-ceremony debounce
|
||||
/// (guards 11–12 of the client's `ToggleSheath` chain, decision 0080d).
|
||||
pub(crate) fn sheath_ceremony_active(&self) -> bool {
|
||||
@@ -1172,6 +1199,12 @@ impl Plugin for CreatureAnimPlugin {
|
||||
// one-shot lands the same frame the packet (or space press) arrived.
|
||||
flourish_to_anim,
|
||||
drive_animations,
|
||||
// The weapon-trail latch (decision 2076) — immediately after the driver,
|
||||
// because the edge it consumes is `AnimDriver::played_anim`, which the driver
|
||||
// rewrites every pass. The reference reads the latch *inside*
|
||||
// `PlayAnimation 0x5fe2f0` itself; one system later is as close as a
|
||||
// batched driver gets, and it is still the same frame.
|
||||
crate::weapon_trail::fire_weapon_trails,
|
||||
drive_hand_grip,
|
||||
fire_anim_events,
|
||||
// After the event scan: consume this frame's impact tags; the SwingImpact
|
||||
|
||||
@@ -1396,6 +1396,10 @@ pub(super) fn drive_animations(
|
||||
let masked_played = masked_played || hold_played.is_some();
|
||||
|
||||
let base_played = base_played || (drv.mode, drv.gait) != pre_state;
|
||||
// The weapon-trail latch's edge (decision 2076) — `0x5fe2f0` is the image's single
|
||||
// animation entry point, so ANY start consumes the arm, the mode machine's gait plays
|
||||
// included. Written every pass (never OR'd) so it is exactly this frame's.
|
||||
drv.started_anim = masked_played || base_played;
|
||||
wound_evict(entity, &mut drv, &mut player, masked_played, base_played);
|
||||
|
||||
if let Some(&edge) = pending_wound.get(&entity) {
|
||||
|
||||
@@ -4054,3 +4054,63 @@ fn the_no_wound_flag_takes_the_parry_but_not_the_dodge() {
|
||||
"the flag does not reach DODGE — it enters `0x60ec00` only on victimState 3"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The weapon-trail latch's edge** (decision 2076): `AnimDriver::started_anim` must be raised by
|
||||
/// a plain gait change, not only by a one-shot.
|
||||
///
|
||||
/// `0x5fe2f0` is the image's single animation entry point — locomotion reaches it through
|
||||
/// `0x602c60` → `0x5fd9e0` → `0x5fd8b0` → `0x5fd100`, and no locomotion id is in the combat set
|
||||
/// that takes its no-latch fast path — so a unit that simply starts running consumes the pending
|
||||
/// arm. That is what starts Charge's trail: kit 44's anim id is `-1`, `0x60f366 jl` skips the play
|
||||
/// block entirely, and the arm waits for the charge's own run (wow-re
|
||||
/// `charproc8-trail-draw-state.md` §11.5/§11.6). A one-shot-only edge would leave 38 Charge spells
|
||||
/// with no trail at all.
|
||||
///
|
||||
/// The other half matters as much: there is **no per-frame animation recompute** in the reference
|
||||
/// (`0x5fd8b0` has one caller and `0x5fd9e0`'s 38 sites are all event-driven), so a unit standing
|
||||
/// still must NOT keep raising the edge and eating arms.
|
||||
#[test]
|
||||
fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
|
||||
let mut app = app();
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
caster_model(),
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
MovementState::default(),
|
||||
))
|
||||
.id();
|
||||
let edge = |app: &App| {
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<AnimDriver>()
|
||||
.unwrap()
|
||||
.started_anim()
|
||||
};
|
||||
app.update(); // settle: Stand — itself a play
|
||||
app.update();
|
||||
assert!(!edge(&app), "a settled, motionless unit plays nothing");
|
||||
// Start running: the gait changes, so the base track takes a play.
|
||||
app.world_mut().entity_mut(unit).insert(MovementState {
|
||||
flags: move_flags::FORWARD,
|
||||
speed: 7.0,
|
||||
..Default::default()
|
||||
});
|
||||
app.update();
|
||||
assert!(edge(&app), "starting to run IS a PlayAnimation");
|
||||
app.update();
|
||||
assert!(
|
||||
!edge(&app),
|
||||
"…and holding that run is not — the reference has no per-frame recompute"
|
||||
);
|
||||
// A one-shot raises it too, which is the ordinary case.
|
||||
app.world_mut().write_message(EmoteAnim {
|
||||
entity: unit,
|
||||
anim_id: 57,
|
||||
seq: 1,
|
||||
});
|
||||
app.update();
|
||||
assert!(edge(&app), "and so does a one-shot");
|
||||
}
|
||||
|
||||
@@ -521,9 +521,21 @@ pub(crate) struct KitPush {
|
||||
pub(crate) seq: u64,
|
||||
}
|
||||
|
||||
/// The lanes [`route_cast_visuals`] resolves here but does not own — bundled into ONE system
|
||||
/// param because that system sits at Bevy's 16-`SystemParam` ceiling: the missile spawns
|
||||
/// (phase 4), the dest one-shot orders (0797), the beam plays (0955), the camera shakes (1849)
|
||||
/// and the weapon-trail arms (2076). Each is built by the module that owns what it becomes.
|
||||
type KitSpawnWriters<'w> = (
|
||||
MessageWriter<'w, MissileSpawn>,
|
||||
MessageWriter<'w, crate::entities::dest_fx::GroundBurst>,
|
||||
MessageWriter<'w, ChainProcPlay>,
|
||||
MessageWriter<'w, SpellKitShake>,
|
||||
MessageWriter<'w, crate::weapon_trail::TrailArm>,
|
||||
);
|
||||
|
||||
/// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the
|
||||
/// router's arms as one argument (each writer keeps its own system-param lifetime).
|
||||
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6> {
|
||||
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7> {
|
||||
oneshots: &'a mut MessageWriter<'w1, EmoteAnim>,
|
||||
wounds: &'a mut MessageWriter<'w2, WoundAnim>,
|
||||
sounds: &'a mut MessageWriter<'w3, SpellKitSound>,
|
||||
@@ -532,6 +544,9 @@ struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6> {
|
||||
chain: &'a mut MessageWriter<'w5, ChainProcPlay>,
|
||||
/// The kit's camera shake, if field 14 names a group (1849).
|
||||
shakes: &'a mut MessageWriter<'w6, SpellKitShake>,
|
||||
/// The kit's weapon-trail ARM, if its `CharProc` slots name one — the dispatcher's type-8
|
||||
/// case (2076). A latch, not a play: nothing is drawn until the unit's next animation.
|
||||
trails: &'a mut MessageWriter<'w7, crate::weapon_trail::TrailArm>,
|
||||
}
|
||||
|
||||
/// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit`
|
||||
@@ -623,6 +638,16 @@ fn play_kit(
|
||||
proc,
|
||||
});
|
||||
}
|
||||
// …and its type-8 case (`0x60d80a`, decision 2076), run on the same terms and for the same
|
||||
// reason: the arm is two words written on the UNIT, not one of the kit's attach-point models,
|
||||
// so `play.effects` does not gate it. The dispatcher runs at `0x60f35c` *before* the kit's own
|
||||
// animation is played at `0x60f3c5`, which is how a kit carrying both a proc-8 and an anim id
|
||||
// fires its trail on its own swing — our `EmoteAnim` above and this arm both land before the
|
||||
// driver reads either, so that self-fire falls out of the ordering rather than being arranged.
|
||||
if let Some(trail) = kit.trail_proc() {
|
||||
out.trails
|
||||
.write(crate::weapon_trail::TrailArm { entity, trail });
|
||||
}
|
||||
if !play.effects {
|
||||
return;
|
||||
}
|
||||
@@ -735,15 +760,7 @@ pub(super) fn route_cast_visuals(
|
||||
mut sounds: MessageWriter<SpellKitSound>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut sheaths: MessageWriter<super::SheathRequest>,
|
||||
// One tuple param (the 16-SystemParam ceiling): the missile spawns, the dest one-shot orders
|
||||
// (0797), the beam plays (0955) and the camera shakes (1849) — the lanes resolved here where
|
||||
// the catalogs live, built by `crate::entities` and `crate::camera_shake`.
|
||||
mut spawns: (
|
||||
MessageWriter<MissileSpawn>,
|
||||
MessageWriter<crate::entities::dest_fx::GroundBurst>,
|
||||
MessageWriter<ChainProcPlay>,
|
||||
MessageWriter<SpellKitShake>,
|
||||
),
|
||||
mut spawns: KitSpawnWriters,
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut weapon_src: WeaponVisualSrc,
|
||||
@@ -756,8 +773,13 @@ pub(super) fn route_cast_visuals(
|
||||
};
|
||||
// Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and
|
||||
// burst writers stay free for the GO loop below.
|
||||
let (missiles, bursts, chains, shakes) =
|
||||
(&mut spawns.0, &mut spawns.1, &mut spawns.2, &mut spawns.3);
|
||||
let (missiles, bursts, chains, shakes, trails) = (
|
||||
&mut spawns.0,
|
||||
&mut spawns.1,
|
||||
&mut spawns.2,
|
||||
&mut spawns.3,
|
||||
&mut spawns.4,
|
||||
);
|
||||
let mut out = KitOut {
|
||||
oneshots: &mut oneshots,
|
||||
wounds: &mut wounds,
|
||||
@@ -765,6 +787,7 @@ pub(super) fn route_cast_visuals(
|
||||
fx: &mut fx,
|
||||
chain: chains,
|
||||
shakes,
|
||||
trails,
|
||||
};
|
||||
|
||||
// The `holds` query is one command-flush stale: an instant cast's START and GO drain from
|
||||
@@ -1203,6 +1226,13 @@ pub(super) fn route_cast_visuals(
|
||||
proc,
|
||||
});
|
||||
}
|
||||
// …and the type-8 arm from the same caller: kit 370 (Whirlwind Primer / Axe
|
||||
// Flurry, a 10 000 ms trail) is reached at the CHANNEL stage and nowhere else,
|
||||
// so without this leg the longest trail in the table never arms (2076).
|
||||
if let Some(trail) = kit.trail_proc() {
|
||||
out.trails
|
||||
.write(crate::weapon_trail::TrailArm { entity, trail });
|
||||
}
|
||||
// The channel kit's effect models — persistent while the field holds (the
|
||||
// client's stage-2 lifetime, and so the stage-2 Birth → Hold → Decay lifecycle:
|
||||
// the channel poll `0x612a30` is one of the caller table's stage-2 sites).
|
||||
@@ -1698,6 +1728,7 @@ pub(super) fn replay_morph_kit(
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut chain: MessageWriter<ChainProcPlay>,
|
||||
mut shakes: MessageWriter<SpellKitShake>,
|
||||
mut trails: MessageWriter<crate::weapon_trail::TrailArm>,
|
||||
) {
|
||||
for swap in swaps.read() {
|
||||
// Consume-and-clear even when the kit resolves to nothing — the reference clears
|
||||
@@ -1719,6 +1750,7 @@ pub(super) fn replay_morph_kit(
|
||||
fx: &mut fx,
|
||||
chain: &mut chain,
|
||||
shakes: &mut shakes,
|
||||
trails: &mut trails,
|
||||
};
|
||||
play_kit(
|
||||
swap.entity,
|
||||
|
||||
@@ -44,6 +44,7 @@ fn app() -> App {
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -205,6 +206,7 @@ fn precast_kit_sound_rings_once_at_start() {
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -490,7 +492,8 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
|
||||
// messages just have to exist for the writers.
|
||||
app.add_message::<crate::aura_visual::AuraProc>();
|
||||
app.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>();
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>();
|
||||
app.init_resource::<FxLog>();
|
||||
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths(
|
||||
HashMap::from([(
|
||||
@@ -630,6 +633,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() {
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -736,6 +740,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -848,6 +853,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -1336,6 +1342,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> {
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<crate::weapon_trail::TrailArm>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
|
||||
@@ -320,6 +320,16 @@ impl HeldAttached {
|
||||
pub(crate) fn spawned_slots(&self) -> &[Option<Entity>; ATTACH_SLOTS] {
|
||||
&self.spawned
|
||||
}
|
||||
|
||||
/// The spawned roots as they would be after an attach pass — the one field a consumer test
|
||||
/// needs, without standing up the resolver, the display cache and the attach chain to get it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_spawned(spawned: [Option<Entity>; ATTACH_SLOTS]) -> Self {
|
||||
Self {
|
||||
applied: HeldItems::default(),
|
||||
spawned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The attach-slot names, in [`HeldAttached::spawned_slots`] order — the instruments' labels for
|
||||
|
||||
@@ -357,6 +357,19 @@ fn spawn_slot(
|
||||
});
|
||||
}
|
||||
}
|
||||
// The **weapon swing trail's** object (wow-re `charproc8-weapon-trail.md` §9, decision 2076)
|
||||
// — the reference's `WTOBJECT`, built per weapon HAND by `0x608d60` on the model it finds at
|
||||
// that hand's attachment and freed with it. This is the first-class consumer decision 0531
|
||||
// named and deferred: the `$WTB`/`$WTT` pair is a *trail* marker, and the bowstring above is
|
||||
// the special case, not the rule. `0x6c67f0`'s `0x7130e0` presence gate is exactly the
|
||||
// `string_anchors` `Option` — a weapon authoring only one marker draws nothing at all.
|
||||
if slot_idx <= 1 {
|
||||
if let Some([top, bottom]) = dm.string_anchors {
|
||||
commands
|
||||
.entity(root)
|
||||
.insert(crate::weapon_trail::WeaponTrail::new(top.1, bottom.1));
|
||||
}
|
||||
}
|
||||
// The fishing line's near anchor (wow-re `fishing-line.md`, decision 1099): a MAINHAND prop
|
||||
// whose model authors `$CCH` is the pole (the reference gates on ItemCache {class 2, subclass
|
||||
// 20} + the marker's presence; exactly one weapon model in the chain authors the marker, so
|
||||
|
||||
@@ -188,6 +188,7 @@ mod ui_unit;
|
||||
mod ui_world_map;
|
||||
mod video;
|
||||
mod vplates;
|
||||
mod weapon_trail;
|
||||
mod world_backdrop;
|
||||
mod world_state;
|
||||
mod world_state_ui;
|
||||
@@ -537,6 +538,7 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// `benilla-config/Diagnostics/fps-journal.csv`; `WOW_FPS_JOURNAL=<csv>` is the harness lever.
|
||||
.add_plugins(perf::FpsJournalPlugin)
|
||||
.add_plugins(BowstringPlugin)
|
||||
.add_plugins(weapon_trail::WeaponTrailPlugin)
|
||||
.add_plugins(FishingLinePlugin)
|
||||
.add_plugins(QuestMarkersPlugin)
|
||||
// Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every
|
||||
|
||||
@@ -453,6 +453,7 @@ fn drive_loading_screen(
|
||||
MessageReader<crate::net::TeleportMessage>,
|
||||
MessageReader<crate::net::LoggedOutMessage>,
|
||||
MessageReader<crate::net::DisconnectedMessage>,
|
||||
MessageReader<crate::net::CharacterLoginFailedMessage>,
|
||||
Option<Res<crate::net::PendingTransfer>>,
|
||||
Option<Res<crate::char_select::Roster>>,
|
||||
),
|
||||
@@ -464,6 +465,7 @@ fn drive_loading_screen(
|
||||
mut teleports,
|
||||
mut logouts,
|
||||
mut lost,
|
||||
mut refusals,
|
||||
transfer,
|
||||
roster,
|
||||
) = edges;
|
||||
@@ -520,7 +522,12 @@ fn drive_loading_screen(
|
||||
// arms `awaiting_snap` for a snap the dead socket will never send, so without the disarm here
|
||||
// the cover is what the player would have been left staring at.
|
||||
let session_over = lost.read().any(|m| m.session_over);
|
||||
if logouts.read().next().is_some() || session_over {
|
||||
// A **refused character login** is the same cut, and it needs the disarm as badly as a dead
|
||||
// session does: the entry edge above raised the cover with `awaiting_snap` armed for a snap
|
||||
// the server has just told us will never come, and nothing else in this function can end that
|
||||
// wait. Left alone it is a loading screen with no world behind it and no way off.
|
||||
let refused = refusals.read().next().is_some();
|
||||
if logouts.read().next().is_some() || session_over || refused {
|
||||
screen.active = true;
|
||||
screen.blackout = true;
|
||||
screen.awaiting_snap = false;
|
||||
@@ -530,6 +537,8 @@ fn drive_loading_screen(
|
||||
"loading screen: blackout ({})",
|
||||
if session_over {
|
||||
"session lost"
|
||||
} else if refused {
|
||||
"character login refused"
|
||||
} else {
|
||||
"logout"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,22 @@ impl Plugin for LoginPlugin {
|
||||
tick_login_caret,
|
||||
screen::refresh_boxes,
|
||||
screen::refresh_checkbox,
|
||||
// **The dialog is the GLUE's, not this screen's.** The reference's
|
||||
// `GlueDialog` is a `GlueParent` child shown over whichever glue screen is
|
||||
// up, and benilla needed that the moment a *character* login could be
|
||||
// refused: `SMSG_CHARACTER_LOGIN_FAILED` has to be said on the select
|
||||
// screen, where the pick was made. So [`drive_dialog`] is `pub(crate)` and
|
||||
// the select screen's chain runs it too — one widget, two screens, rather
|
||||
// than a second copy of it over there. The kinds this screen owns
|
||||
// (`Status`, `Queued`, `Realmlist`) can only be *opened* from here, so
|
||||
// only `Error` is ever reachable on the other one.
|
||||
//
|
||||
// It stays inside each screen's own chain rather than being hoisted out
|
||||
// with a `before`/`after` pair: the shared painters below sit in this
|
||||
// chain, and ordering a system both after `login_input` and before
|
||||
// `art_swaps` from outside makes `art_swaps` transitively ordered against
|
||||
// its own `SystemTypeSet`, which Bevy rejects at schedule build — a panic
|
||||
// no unit test sees and only a real run finds.
|
||||
drive_dialog,
|
||||
// Both after `drive_dialog`: it is what spawns the dialog's edit box, and
|
||||
// what a realmlist Okay changes the address in.
|
||||
@@ -656,7 +672,15 @@ fn to_select_on_roster(
|
||||
intent.in_flight = false;
|
||||
intent.park = IoPark::Active;
|
||||
intent.retry_at = None;
|
||||
dialog.close();
|
||||
// **Only the dialogs the roster ANSWERS.** This used to close whatever was up, which was
|
||||
// right while the only dialog that could be up here was this screen's own "Connecting…". It
|
||||
// stopped being right when a refused *character* login started raising an `Error` on the
|
||||
// select screen: the refusal's relist produces a roster a second later, and closing on it
|
||||
// would take the message off the screen before the player had read it — the same silent
|
||||
// refusal, one layer up. An `Error` is dismissed by its Okay, never by an arriving packet.
|
||||
if matches!(dialog.kind, Some(DialogKind::Status | DialogKind::Queued)) {
|
||||
dialog.close();
|
||||
}
|
||||
if *state.get() == ClientState::Login {
|
||||
next.set(ClientState::CharSelect);
|
||||
}
|
||||
@@ -1105,7 +1129,7 @@ fn dialog_keys(kind: DialogKind, on_screen: bool, enter: bool, escape: bool) ->
|
||||
(button1, kind == DialogKind::Realmlist && escape)
|
||||
}
|
||||
|
||||
/// The login screen's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
|
||||
/// The glue layer's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
|
||||
/// despawns the tree (respawning on a kind change — the button caption differs) and updates the
|
||||
/// text in place.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -1131,12 +1155,25 @@ pub(crate) struct LoginDialog {
|
||||
}
|
||||
|
||||
impl LoginDialog {
|
||||
fn open_status(&mut self, text: &str) {
|
||||
self.kind = Some(DialogKind::Status);
|
||||
/// Raise the one-button error dialog over whichever glue screen is up.
|
||||
///
|
||||
/// `pub(crate)` for the character-select screen: a refused *character* login
|
||||
/// (`SMSG_CHARACTER_LOGIN_FAILED`) is said there, not here, and it is the same dialog — the
|
||||
/// reference has exactly one, under `GlueParent`.
|
||||
pub(crate) fn open_error(&mut self, text: &str) {
|
||||
self.kind = Some(DialogKind::Error);
|
||||
self.set_text(text);
|
||||
}
|
||||
fn open_error(&mut self, text: &str) {
|
||||
self.kind = Some(DialogKind::Error);
|
||||
|
||||
/// Is a dialog up? The glue screens' modal test — while one is, the screen behind it does not
|
||||
/// answer clicks or keys (the select screen's own delete confirm and AddOns panel are read the
|
||||
/// same way).
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.kind.is_some()
|
||||
}
|
||||
|
||||
fn open_status(&mut self, text: &str) {
|
||||
self.kind = Some(DialogKind::Status);
|
||||
self.set_text(text);
|
||||
}
|
||||
/// Enter the queue: a fresh ring (a second login must not inherit the first one's estimate)
|
||||
@@ -1165,7 +1202,7 @@ impl LoginDialog {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
pub(crate) fn close(&mut self) {
|
||||
self.kind = None;
|
||||
self.text.clear();
|
||||
self.dirty = false;
|
||||
@@ -1177,7 +1214,7 @@ impl LoginDialog {
|
||||
/// stage boundary) and forgets the intent; Error's Okay just closes. Esc = the button; Enter
|
||||
/// confirms an error.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_dialog(
|
||||
pub(crate) fn drive_dialog(
|
||||
mut commands: Commands,
|
||||
mut dialog: ResMut<LoginDialog>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
@@ -1470,6 +1507,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A roster does not take a refusal off the screen.**
|
||||
///
|
||||
/// [`to_select_on_roster`] closes the dialog because a roster is the answer to this screen's
|
||||
/// "Connecting…" — but since a refused *character* login raises an `Error` on the SELECT
|
||||
/// screen, and its own relist produces a roster a second later, an unscoped close would wipe
|
||||
/// the message before it could be read. That is the original bug (a refusal nobody sees) one
|
||||
/// layer up, and nothing else would catch it: the build is green either way and the window is
|
||||
/// a second long.
|
||||
#[test]
|
||||
fn an_arriving_roster_closes_the_connecting_dialog_but_not_an_error() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
|
||||
.insert_state(ClientState::CharSelect)
|
||||
.init_resource::<LoginIntent>()
|
||||
.init_resource::<LoginDialog>()
|
||||
.add_message::<CharListMessage>()
|
||||
.add_systems(Update, to_select_on_roster);
|
||||
|
||||
// The refusal's dialog survives its own relist.
|
||||
app.world_mut()
|
||||
.resource_mut::<LoginDialog>()
|
||||
.open_error("World server is down");
|
||||
app.world_mut().write_message(CharListMessage {
|
||||
characters: Vec::new(),
|
||||
realm: None,
|
||||
});
|
||||
app.update();
|
||||
let dialog = app.world().resource::<LoginDialog>();
|
||||
assert_eq!(dialog.kind, Some(DialogKind::Error));
|
||||
assert_eq!(dialog.text, "World server is down");
|
||||
|
||||
// …and the connecting dialog the roster IS the answer to still closes.
|
||||
app.world_mut()
|
||||
.resource_mut::<LoginDialog>()
|
||||
.open_status("Connecting");
|
||||
app.world_mut().write_message(CharListMessage {
|
||||
characters: Vec::new(),
|
||||
realm: None,
|
||||
});
|
||||
app.update();
|
||||
assert!(app.world().resource::<LoginDialog>().kind.is_none());
|
||||
}
|
||||
|
||||
/// An **unattended** run keeps 0065's paced reconnect on a lost session — the verdict rides
|
||||
/// the message, so the policy honours it without re-reading the environment.
|
||||
#[test]
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(super) struct CheckMark;
|
||||
pub(super) struct CheckHilight;
|
||||
/// The dialog's message text (updated in place on stage changes).
|
||||
#[derive(Component)]
|
||||
pub(super) struct DialogText;
|
||||
pub(crate) struct DialogText;
|
||||
/// The dialog's edit box row items — the ref's `GlueDialogEditBox`, painted from
|
||||
/// [`super::LoginDialog::edit`] by [`refresh_dialog_box`].
|
||||
#[derive(Component, Clone)]
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Plugin for NetPlugin {
|
||||
.add_message::<RealmListMessage>()
|
||||
.add_message::<CharListMessage>()
|
||||
.add_message::<CharActionResultMessage>()
|
||||
.add_message::<CharacterLoginFailedMessage>()
|
||||
.add_message::<EnteredWorldMessage>()
|
||||
.add_message::<CinematicTriggeredMessage>()
|
||||
.add_message::<ServerSaidMessage>()
|
||||
@@ -2402,6 +2403,17 @@ pub(crate) struct CharActionResultMessage {
|
||||
pub(crate) code: u8,
|
||||
}
|
||||
|
||||
/// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`), bridged from the
|
||||
/// Net drain. The entry announced a moment earlier is void: `crate::char_select` takes the screen
|
||||
/// back and raises the refusal dialog, and the loading cover comes down with it.
|
||||
///
|
||||
/// `result` is the server's raw reason index — [`crate::char_select::char_login_refusal_text`] is
|
||||
/// the one place that holds the reference's table for it.
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub(crate) struct CharacterLoginFailedMessage {
|
||||
pub(crate) result: u8,
|
||||
}
|
||||
|
||||
/// We entered the world (the IO thread's `Connected`, bridged from the Net drain): flips
|
||||
/// [`crate::char_select::ClientState`] to `InWorld`.
|
||||
#[derive(Message)]
|
||||
|
||||
@@ -130,7 +130,13 @@ pub(crate) fn apply_net_updates(
|
||||
MessageWriter<CharListMessage>,
|
||||
MessageWriter<crate::net::RealmListMessage>,
|
||||
),
|
||||
MessageWriter<CharActionResultMessage>,
|
||||
// **The pick park's two verdicts, paired** (the tuple is at Bevy's 16-element ceiling, and
|
||||
// these belong together): what a create/delete came back with, and the refusal of the pick
|
||||
// itself.
|
||||
(
|
||||
MessageWriter<CharActionResultMessage>,
|
||||
MessageWriter<super::CharacterLoginFailedMessage>,
|
||||
),
|
||||
MessageWriter<EnteredWorldMessage>,
|
||||
MessageWriter<LoggedOutMessage>,
|
||||
MessageWriter<super::SpeedChangeMessage>,
|
||||
@@ -531,7 +537,7 @@ pub(crate) fn apply_net_updates(
|
||||
mut teleports,
|
||||
mut worldports,
|
||||
(mut char_lists, mut realm_lists),
|
||||
mut char_actions,
|
||||
(mut char_actions, mut char_login_failures),
|
||||
mut entered_world,
|
||||
mut logged_out,
|
||||
mut speed_changes,
|
||||
@@ -595,6 +601,9 @@ pub(crate) fn apply_net_updates(
|
||||
SessionEvent::CharActionResult { action, code } => {
|
||||
session::char_action_result(action, code, &mut char_actions)
|
||||
}
|
||||
SessionEvent::CharacterLoginFailed { result } => {
|
||||
session::character_login_failed(result, &mut char_login_failures)
|
||||
}
|
||||
SessionEvent::CinematicTriggered { cinematic_id } => {
|
||||
session::cinematic_triggered(cinematic_id, &mut cinematics)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
|
||||
use super::super::{
|
||||
CharActionResultMessage, CharListMessage, CinematicTriggeredMessage, DisconnectedMessage,
|
||||
DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex, KnockBackMessage, LoggedOutMessage,
|
||||
LoginFailedMessage, LoginStageMessage, NetStatus, PendingTransfer, Reputations, SelfGuid,
|
||||
ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
|
||||
CharActionResultMessage, CharListMessage, CharacterLoginFailedMessage,
|
||||
CinematicTriggeredMessage, DisconnectedMessage, DroppedOpcodes, EnteredWorldMessage, GameTime,
|
||||
GuidIndex, KnockBackMessage, LoggedOutMessage, LoginFailedMessage, LoginStageMessage,
|
||||
NetStatus, PendingTransfer, Reputations, SelfGuid, ServerTime, ServerWallClock,
|
||||
TeleportMessage, WorldportMessage,
|
||||
};
|
||||
|
||||
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
|
||||
@@ -82,6 +83,16 @@ pub(super) fn character_list(
|
||||
char_lists.write(CharListMessage { characters, realm });
|
||||
}
|
||||
|
||||
/// The server refused the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — the entry
|
||||
/// announced a moment ago is void. `crate::char_select` takes the screen back and says why.
|
||||
pub(super) fn character_login_failed(
|
||||
result: u8,
|
||||
out: &mut MessageWriter<CharacterLoginFailedMessage>,
|
||||
) {
|
||||
warn!("net: character login refused (result {result:#04x})");
|
||||
out.write(CharacterLoginFailedMessage { result });
|
||||
}
|
||||
|
||||
/// A cinematic sequence was triggered (`SMSG_TRIGGER_CINEMATIC`) — hand it to
|
||||
/// [`crate::cinematic`], which plays it and owns the ack.
|
||||
///
|
||||
|
||||
@@ -6,14 +6,20 @@
|
||||
//! handshake (emitting [`SessionEvent::LoginStage`]s,
|
||||
//! and [`SessionEvent::LoginFailed`] + re-park on any pre-roster failure), it **parks at character
|
||||
//! select**: it emits the roster as a [`SessionEvent::CharacterList`] and blocks until the app
|
||||
//! answers with a guid over the pick channel. The pick sends `CMSG_PLAYER_LOGIN`, and the thread
|
||||
//! streams decoded [`SessionEvent`]s from there. All *policy* (credential auto-resubmit and its
|
||||
//! pacing, the env fast path, auto-relogin on reconnect, the director's click) is app-side
|
||||
//! answers with a guid over the pick channel. The pick sends `CMSG_PLAYER_LOGIN` and announces
|
||||
//! the connection **without waiting for the server's verdict** (the entry's head start, decision
|
||||
//! 0777 — the destination's tiles stream a round-trip early), and the thread streams decoded
|
||||
//! [`SessionEvent`]s from there. A `SMSG_CHARACTER_LOGIN_FAILED` in that stream is the verdict
|
||||
//! arriving late: it ends the cycle like a logout ([`Cycle::LoginRefused`]). All *policy*
|
||||
//! (credential auto-resubmit and its pacing, the env fast path, auto-relogin on reconnect, the
|
||||
//! director's click) is app-side
|
||||
//! ([`crate::login`], [`crate::char_select`]); this thread is a pure sequencer — it never sleeps.
|
||||
//!
|
||||
//! On a stream failure it emits a [`SessionEvent::Disconnected`] carrying
|
||||
//! [`SessionEnd::Lost`] and returns to the login park; a clean in-game logout
|
||||
//! ([`SessionEvent::LoggedOut`]) does the same with [`SessionEnd::LoggedOut`]. What happens next is
|
||||
//! ([`SessionEvent::LoggedOut`]) does the same with [`SessionEnd::LoggedOut`], and so does a
|
||||
//! **refused character login** ([`SessionEvent::CharacterLoginFailed`]) — the pick is announced
|
||||
//! optimistically, so a refusal is an entry taken back rather than a failure to enter. What happens next is
|
||||
//! the app's, and the two answers differ (decision 1262): the logout relists, while a loss ends the
|
||||
//! session at the account screen unless nobody is there to type. A single long-lived sibling write thread drains
|
||||
//! [`ClientCommand`](super::ClientCommand)s down to the server; each successful connection hands it
|
||||
@@ -269,6 +275,17 @@ enum Cycle {
|
||||
/// A clean in-game logout: emit the teardown `Disconnected` (decision 0065's path), then park.
|
||||
/// The app's pending credentials re-establish the roster silently (decision 0539 §3).
|
||||
LoggedOut,
|
||||
/// The server refused the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — we are not in
|
||||
/// the world, and the optimistic entry this thread already announced has to be taken back.
|
||||
///
|
||||
/// It ends the cycle **exactly like a logout**, and for the same reason: by the time the
|
||||
/// refusal lands the session has been split and the writer handed away, so there is no way
|
||||
/// back to the character-select park on this connection. The relist that follows is the
|
||||
/// logout path's, unchanged — the player sees their roster again, one reconnect they never
|
||||
/// asked about behind it. (The reference keeps its connection here; the refusal is rare
|
||||
/// enough, and the divergence invisible enough, that paying for it with the machinery a
|
||||
/// logout already has is the trade decision 0065 made for the identical case.)
|
||||
LoginRefused,
|
||||
}
|
||||
|
||||
/// Everything [`spawn_net`] hands the app: the inbound event stream, the outbound command sender,
|
||||
@@ -335,12 +352,18 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
match run(&cfg, &events_tx, &writer_tx, &parks, &abandon, &read_clock) {
|
||||
Ok(Cycle::Exit) => return,
|
||||
Ok(Cycle::Repark) => {}
|
||||
Ok(Cycle::LoggedOut) => {
|
||||
Ok(end @ (Cycle::LoggedOut | Cycle::LoginRefused)) => {
|
||||
// Clean logout: the Disconnected tears the streamed world down app-side
|
||||
// (decision 0065's path); the app's pending credentials re-park us live.
|
||||
// A refused login rides the same edge — the world it tears down is the
|
||||
// one the entry had only started to build.
|
||||
let reason = match end {
|
||||
Cycle::LoggedOut => "logged out",
|
||||
_ => "character login refused",
|
||||
};
|
||||
if events_tx
|
||||
.send(SessionEvent::Disconnected {
|
||||
reason: "logged out".into(),
|
||||
reason: reason.into(),
|
||||
end: SessionEnd::LoggedOut,
|
||||
})
|
||||
.is_err()
|
||||
@@ -774,7 +797,7 @@ fn run(
|
||||
.find(|c| c.guid == guid)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
session.player_login(guid)?;
|
||||
session.player_login(refuse_once(guid))?;
|
||||
session.set_active_mover(guid)?;
|
||||
|
||||
let billing_time_rested = session.billing_time_rested();
|
||||
@@ -838,14 +861,20 @@ fn run(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A confirmed logout ends the cycle *after* the app hears about it.
|
||||
let logged_out = matches!(ev, SessionEvent::LoggedOut);
|
||||
// A confirmed logout — and a refused character login — end the cycle
|
||||
// *after* the app hears about it, so the screen always has the reason
|
||||
// before the teardown that acts on it.
|
||||
let ends = match ev {
|
||||
SessionEvent::LoggedOut => Some(Cycle::LoggedOut),
|
||||
SessionEvent::CharacterLoginFailed { .. } => Some(Cycle::LoginRefused),
|
||||
_ => None,
|
||||
};
|
||||
// Receiver dropped → the app exited; end the thread cleanly.
|
||||
if events_tx.send(ev).is_err() {
|
||||
return Ok(Cycle::Exit);
|
||||
}
|
||||
if logged_out {
|
||||
return Ok(Cycle::LoggedOut);
|
||||
if let Some(cycle) = ends {
|
||||
return Ok(cycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -888,6 +917,35 @@ fn run(
|
||||
} // 'realm
|
||||
}
|
||||
|
||||
/// **The refusal rehearsal** (`WOW_REFUSE_LOGIN=1`): make the server refuse the *first* character
|
||||
/// login of the run, so the path a refusal takes is walkable on demand.
|
||||
///
|
||||
/// A refused `CMSG_PLAYER_LOGIN` is rare on a local server and cannot be staged by hand — every
|
||||
/// guid the screen can offer is a real character on the account — which is exactly why the client
|
||||
/// sat on an unclearable loading screen for so long without anyone noticing. This sends the pick
|
||||
/// with a guid vmangos cannot read as a player (`HIGHGUID_UNIT`), which its
|
||||
/// `!packet.guid.IsPlayer()` guard refuses immediately, touching nothing else. The guid on the
|
||||
/// wire is the only thing altered: everything the app is told still names the real character, so
|
||||
/// what runs afterwards is the genuine path and not a special case.
|
||||
///
|
||||
/// **Once**, not every pick — so the run continues into the recovery (dialog → relist → a second
|
||||
/// pick that works) rather than looping on a refusal the `WOW_CHAR` fast path would re-answer
|
||||
/// forever. Inert without the env.
|
||||
fn refuse_once(guid: u64) -> u64 {
|
||||
/// `HIGHGUID_UNIT | 1` — a guid no player can have, and one the server rejects before it
|
||||
/// looks anything up. Shared with `benilla-protocol`'s `login_refusal_probe`.
|
||||
const NOT_A_PLAYER: u64 = 0xF130_0000_0000_0001;
|
||||
static ARMED: std::sync::OnceLock<AtomicU64> = std::sync::OnceLock::new();
|
||||
let armed = ARMED
|
||||
.get_or_init(|| AtomicU64::new(u64::from(std::env::var_os("WOW_REFUSE_LOGIN").is_some())));
|
||||
if armed.swap(0, Ordering::SeqCst) == 1 {
|
||||
bevy::log::warn!("net: WOW_REFUSE_LOGIN — sending this pick with a non-player guid");
|
||||
NOT_A_PLAYER
|
||||
} else {
|
||||
guid
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the writer's sent-packet log into the trace as `out` lines — one per packet that reached
|
||||
/// the socket, by opcode name and body length. A no-op unless the `out` tag armed the log when the
|
||||
/// connection was handed over.
|
||||
|
||||
@@ -656,6 +656,7 @@ fn warm_effect_lane(
|
||||
raster_bias,
|
||||
raster_slope,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: cam,
|
||||
light: None,
|
||||
},
|
||||
@@ -663,5 +664,37 @@ fn warm_effect_lane(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The depth-test-off arm (2076), warmed as the ONE combination that ships rather than as
|
||||
// another factor of the cross product above — the weapon swing trail is its only producer
|
||||
// and it always draws alpha-blended, unlit, with no rasterizer settle. A hole here is a
|
||||
// live compile on the first Heroic Strike anyone lands, which is the whole failure this
|
||||
// module exists to prevent; a doubled cross product would be 36 more warm draws per camera
|
||||
// for pipelines nothing will ever ask for.
|
||||
let start = quads.begin();
|
||||
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
quads.verts.push(EffectVertex {
|
||||
pos: [u * 0.01, v * 0.01, 0.0],
|
||||
uv: [u, v],
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
});
|
||||
}
|
||||
quads.commit_quads(
|
||||
start,
|
||||
EffectDrawSpec {
|
||||
cam,
|
||||
texture: tex.id(),
|
||||
blend: EffectBlend::Alpha,
|
||||
fog: EffectFog::Off,
|
||||
lighting: benilla_world::particles::buffer::EffectLighting::None,
|
||||
anchor: Vec3::ZERO,
|
||||
bias: 0.0,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: true,
|
||||
main_entity: cam,
|
||||
light: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::area::AreaTableRes;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::net::{DisconnectedMessage, EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::probe_shield::{ProbeShield, ShieldReport};
|
||||
use benilla_world::world_map::CurrentMap;
|
||||
|
||||
@@ -241,7 +241,7 @@ fn camera_2d_msaa_agrees(
|
||||
}
|
||||
|
||||
/// The banner's once-per-entry latch: armed by [`EnteredWorldMessage`], disarmed when the report
|
||||
/// goes out (or when the wait expires).
|
||||
/// goes out, when the wait expires, or when the session that armed it ends.
|
||||
#[derive(Resource, Default)]
|
||||
struct Preflight {
|
||||
/// `Time::elapsed_secs` at the world entry we still owe a report for; `None` = nothing pending.
|
||||
@@ -253,6 +253,7 @@ struct Preflight {
|
||||
fn report_session(
|
||||
mut state: ResMut<Preflight>,
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut ended: MessageReader<DisconnectedMessage>,
|
||||
time: Res<Time>,
|
||||
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
@@ -265,6 +266,16 @@ fn report_session(
|
||||
if entered.read().next().is_some() {
|
||||
state.armed_at = Some(time.elapsed_secs());
|
||||
}
|
||||
// **A session that ended disarms the wait** — after the arm, so the entry-and-death-in-one-drain
|
||||
// race (decision 1262) resolves the way it does everywhere else: the dead session is the last
|
||||
// word. Without this the latch outlives the session that armed it and the banner fires from the
|
||||
// glue screen, telling a future reader that "the avatar never streamed in" when the truth is
|
||||
// that nobody ever entered a world for it to stream into. A refused character login makes that
|
||||
// reachable on purpose (`SMSG_CHARACTER_LOGIN_FAILED` revokes an entry already announced); a
|
||||
// socket that dies mid-entry always could.
|
||||
if ended.read().next().is_some() {
|
||||
state.armed_at = None;
|
||||
}
|
||||
let Some(armed_at) = state.armed_at else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -860,17 +860,20 @@ fn combat_sounds(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// **A zero column plays NOTHING** (decision 2075). benilla used to walk down the
|
||||
// family here — crushing → critical → ordinary — on the reasoning that "crushing
|
||||
// rows are often 0 in data"; they are, and the reference is simply silent for
|
||||
// them. `0x623490` tests the selected id exactly once (`0x6234e6 test ebx,ebx ;
|
||||
// 0x6234e8 je 0x62350e`, the shared epilogue), and no fallback is even reachable:
|
||||
// the row pointer dies at `0x6234e4`, so neither the bus pick `0x623b10` (which
|
||||
// takes the category) nor the play `0x458890` (bus, kit, &pos) ever sees the row
|
||||
// to re-read a column from. The CGPlayer twin `0x62f880` is identical
|
||||
// (`0x62f8be`/`0x62f8c3`). wow-re
|
||||
// `object-layer/scratch/wound-parry-gate-and-injury-vocal.md` §13.
|
||||
let vocal = net
|
||||
.display_id
|
||||
.and_then(|d| voices.0.for_display(d))
|
||||
.map(|v| {
|
||||
let idx = if crushing { 2 } else { usize::from(crit) };
|
||||
// Crushing rows are often 0 in data — fall back down the family.
|
||||
[v.injury[idx], v.injury[usize::from(crit)], v.injury[0]]
|
||||
.into_iter()
|
||||
.find(|k| *k != 0)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.map(|v| v.injury[if crushing { 2 } else { usize::from(crit) }])
|
||||
.unwrap_or(0);
|
||||
// Your own wounds get the CGPlayer twin's private bus 8 (cap 1); everyone
|
||||
// else's share the world's bus 7 (cap 2).
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! The weapon **swing trail** — `SpellVisualKit` CharProc type **8**, the ribbon a melee ability
|
||||
//! smears behind its blade (wow-re `charproc8-weapon-trail.md`, §5-verified end to end).
|
||||
//!
|
||||
//! For **16 of the 27 trail kits a live spell reaches, this is the entire visual**: kit 324
|
||||
//! (Heroic Strike / Overpower / Mortal Strike / Bloodthirst / Rend — 218 spells), 506
|
||||
//! (Revenge / Riposte / Mongoose Bite), 557 (Sunder Armor), 646 (Hamstring / Wing Clip), 399
|
||||
//! (Sinister Strike), 822 (Garrote), 823 (Feint / Disengage) name no emitter slot at all, so
|
||||
//! without this module those abilities render nothing beyond the swing animation.
|
||||
//!
|
||||
//! ## The mechanism
|
||||
//!
|
||||
//! The proc does not draw. It **latches two words on the unit** and the unit's *next* animation
|
||||
//! fires them:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x60d80a the type-8 dispatcher arm unit+0xd1c = ftol(ParamZero) | (ftol(ParamThree) << 24)
|
||||
//! unit+0xd20 = ftol(ParamTwo) -- ms
|
||||
//! 0x5fe48e CGUnit::PlayAnimation, the if (unit+0xd20 != 0) 0x60e550(&colour, duration)
|
||||
//! fields' ONLY reader unit+0xd20 = 0 -- one-shot
|
||||
//! 0x60e550 fan out to CGUnit+0xd34[0..1] one WTOBJECT per weapon hand
|
||||
//! 0x6c6750 arm a SWING on that object re-arming a live one RESETS its ring, never stacks
|
||||
//! 0x6c67f0 the weapon model's per-frame cb sample $WTB/$WTT -> 0x6c6560
|
||||
//! 0x6c6560 append / fade / draw / die the arithmetic transcribed in [`Swing::step`]
|
||||
//! ```
|
||||
//!
|
||||
//! [`WeaponTrail`] is the WTOBJECT: one per spawned weapon prop whose model authors both markers,
|
||||
//! carrying the reference's nullable `SWING` as [`Swing`]. The prop's own despawn (a weapon swap,
|
||||
//! a shapeshift) is the teardown, exactly as `0x6c6970` frees the object with the model reference
|
||||
//! it holds.
|
||||
//!
|
||||
//! ## The two named deviations
|
||||
//!
|
||||
//! - **The trail is LIT in the reference and unlit here.** The callback writes EGxRs `0x0e = 1`
|
||||
//! (`0x6c6847`; `GL_LIGHTING` / `D3DRS_LIGHTING = TRUE`) — but its vertex format carries **no
|
||||
//! normal** (format 7 is position + colour, stride 16), and what a fixed-function pipeline
|
||||
//! produces from a lit vertex with no normal is not settled by the state bit alone. wow-re's own
|
||||
//! round left the neighbouring question — whether the device even disables the texture unit for
|
||||
//! a declaration with no texcoord — `DEFERRED:` on `gx`'s board for the same unwalked functions
|
||||
//! (`0x594210`, `0x5a1b20`). Modelling a light term on a guess at the normal would be inventing
|
||||
//! the look; the authored colour burns as it is until the bytes say what the light does to it.
|
||||
//! Dispatched.
|
||||
//! - **No combat-over-combat fast path.** `0x5fe43c` skips the latch read entirely when the unit
|
||||
//! is *already* playing a combat animation and requests another one — it re-times the clip
|
||||
//! instead of restarting it (`0x5fe48b ret`, before `0x5fe48e`), so the arm is **held**, not
|
||||
//! lost, and fires on the next play that is not that shape. The combat set is `0x5fcc10`'s
|
||||
//! bytemap `{10, 16–24, 30, 36, 57, 58, 59, 85–88, 95, 117, 118}`. benilla's driver is batched
|
||||
//! and has no single "currently playing id" across its base and masked slots, so this is not
|
||||
//! modelled: we fire on the frame's edge, which makes a trail start *slightly earlier* than the
|
||||
//! reference in the one case where a combat one-shot lands mid-combat-animation. Over-firing,
|
||||
//! never under-firing, and never a trail the reference would not eventually draw.
|
||||
//!
|
||||
//! ## What is deliberately NOT here
|
||||
//!
|
||||
//! - **No transport ride-frame.** A committed sample is stored in absolute world coordinates and
|
||||
//! never re-projected. `0x6c67f0` takes `0x7131b0`'s world position straight into the ring and
|
||||
//! neither it nor `0x6c6560` reads `[CM2Model+0x17c]` — unlike the particle and ribbon lanes,
|
||||
//! whose birth sites (`0x7b5160`, `0x7b7bc0`) do (`crate` `benilla_world::ride_frame`, 1591).
|
||||
//! A swing on a moving deck smears in the reference too; that is the mechanism, not a gap.
|
||||
//! - **No sheath gate, and no handedness test.** `0x608d60` resolves the weapon's attachment
|
||||
//! through `0x47a070(SheatheType, isMainHand)` and hangs the object wherever the weapon
|
||||
//! currently sits, so a stowed weapon is still trail-capable; and neither of `[vtbl+0x98]`'s two
|
||||
//! bodies (`0x5ec240` for a player, `0x605e30` for a creature) contains an InventoryType or
|
||||
//! handedness test. A two-hander gets one trail only because the server leaves the off-hand slot
|
||||
//! empty — which is exactly what our own off-hand slot does. **Creatures get trails too**, off
|
||||
//! `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY`; this is not a player-only effect.
|
||||
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::TrailProc;
|
||||
use benilla_world::particles::buffer::{EffectVertex, WorldEffectDraw};
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
use crate::entities::HeldAttached;
|
||||
|
||||
/// The ring's slot count — `0x6c677c` zeroes `0x80` entries of 12 bytes, and `0x6c64a4`'s
|
||||
/// `and eax,0x7f` is the write mask. Two vertices are appended per frame, so this is **64 frames**
|
||||
/// of blade history.
|
||||
const RING_SLOTS: u32 = 0x80;
|
||||
|
||||
/// The **reader's** modulus, and it is not [`RING_SLOTS`]: `0x6c64e5`/`0x6c64ea`
|
||||
/// (`mov ecx,0x7f; idiv ecx`) takes `i % 127` where the writer stored at `i & 127`. The two
|
||||
/// genuinely disagree in build 5875 — both of wow-re's independent derivations found it — so once
|
||||
/// the write counter passes 127 the reader picks up a neighbouring sample instead of the one it
|
||||
/// wants. See [`Swing::read`] for what that looks like and which kits reach it.
|
||||
const READ_MODULUS: u32 = 0x7f;
|
||||
|
||||
/// `0x811278` in `.rdata` — `0x3b5a740e`, the nearest f32 to `1/300`, and the per-millisecond fade
|
||||
/// coefficient the step multiplies by.
|
||||
const FADE_PER_MS: f32 = 0.0033333334;
|
||||
|
||||
/// The type-8 dispatcher **arming** a unit — the two words `0x60d828`/`0x60d835` write to
|
||||
/// `unit+0xd1c` / `unit+0xd20`. Written for every kit play that carries the proc, at every stage,
|
||||
/// exactly where the beam case is written; consumed by [`fire_weapon_trails`].
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub(crate) struct TrailArm {
|
||||
/// The unit the kit played on — the wielder, never the target.
|
||||
pub(crate) entity: Entity,
|
||||
pub(crate) trail: TrailProc,
|
||||
}
|
||||
|
||||
/// The per-unit **pending arm** — `unit+0xd1c` / `unit+0xd20` as a map, the same shape
|
||||
/// [`super::creature_anim::spell_visual::MorphLatch`] gives `[unit+0xd54]`.
|
||||
///
|
||||
/// One slot per unit, because the reference has one pair of fields: a second proc before the
|
||||
/// first has fired **overwrites** it rather than queueing. Consumed and cleared by the unit's next
|
||||
/// animation (`0x5fe4b1 mov [ebx+0xd20],edi`), and swept when the unit goes.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct TrailLatch(EntityHashMap<TrailProc>);
|
||||
|
||||
/// A spawned **weapon prop** whose model authors both `$WTB` and `$WTT` — the reference's
|
||||
/// `WTOBJECT` (pool `0xce86d8`), one per weapon hand, alive for as long as the weapon is.
|
||||
///
|
||||
/// A model missing either marker gets no component at all, which is `0x6c67f0`'s own
|
||||
/// `0x7130e0` presence gate (`je -> return`: absent ⇒ nothing is drawn).
|
||||
#[derive(Component)]
|
||||
pub(crate) struct WeaponTrail {
|
||||
/// `$WTT` — the blade tip, prop-local Bevy space (the prop root's frame, as
|
||||
/// [`crate::bowstring`] uses the same pair).
|
||||
///
|
||||
/// The reference's per-hand index (`CGUnit+0xd34[0..1]`) is not carried: our two objects are
|
||||
/// two entities, reached through the wielder's own main/off slots, so the ordinal that
|
||||
/// distinguishes them there is the query here.
|
||||
top: Vec3,
|
||||
/// `$WTB` — the blade base.
|
||||
bottom: Vec3,
|
||||
/// The live `SWING`, or `None` when this hand is not trailing. Boxed so an idle weapon costs
|
||||
/// a pointer rather than the whole ring.
|
||||
swing: Option<Box<Swing>>,
|
||||
}
|
||||
|
||||
impl WeaponTrail {
|
||||
/// The component a spawned weapon prop takes, given its model's baked `[top, bottom]`
|
||||
/// anchors. Called from the held-item attach.
|
||||
pub(crate) fn new(top: Vec3, bottom: Vec3) -> Self {
|
||||
Self {
|
||||
top,
|
||||
bottom,
|
||||
swing: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm this hand — `0x6c6750`. An already-live swing **resets its ring in place** rather than
|
||||
/// allocating a second one (`0x6c675f: [eax+4] = 0 ; [eax] = 0`), so a second proc on the same
|
||||
/// weapon restarts the trail instead of stacking one on top of it.
|
||||
pub(crate) fn arm(&mut self, trail: TrailProc, now_ms: u32) {
|
||||
let [r, g, b] = trail.rgb();
|
||||
let swing = self.swing.get_or_insert_with(|| {
|
||||
Box::new(Swing {
|
||||
ring: [Vec3::ZERO; RING_SLOTS as usize],
|
||||
head: 0,
|
||||
tail: 0,
|
||||
rgb: [f32::from(r), f32::from(g), f32::from(b)],
|
||||
alpha: 0,
|
||||
duration_ms: 0,
|
||||
start_ms: 0,
|
||||
last_ms: 0,
|
||||
})
|
||||
});
|
||||
swing.head = 0;
|
||||
swing.tail = 0;
|
||||
swing.rgb = [
|
||||
f32::from(r) / 255.0,
|
||||
f32::from(g) / 255.0,
|
||||
f32::from(b) / 255.0,
|
||||
];
|
||||
swing.alpha = trail.alpha();
|
||||
swing.duration_ms = trail.duration_ms;
|
||||
swing.start_ms = now_ms;
|
||||
swing.last_ms = now_ms;
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's `SWING` record (pool `0xce86ec`) — the ring of blade samples plus the colour,
|
||||
/// duration and clocks that fade it out. Layout, field for field:
|
||||
/// `+0x00` head · `+0x04` tail · `+0x08` the 128-slot ring · `+0x608` the packed colour
|
||||
/// (byte 3 = the live alpha) · `+0x60c` duration · `+0x610` start · `+0x614` last.
|
||||
struct Swing {
|
||||
/// `+0x08`. Written at `head & 0x7f`, read at `i % 127` — see [`READ_MODULUS`].
|
||||
ring: [Vec3; RING_SLOTS as usize],
|
||||
/// `+0x00` — the monotonic write cursor, never masked itself.
|
||||
head: u32,
|
||||
/// `+0x04` — the floor: the oldest sample still retained.
|
||||
tail: u32,
|
||||
/// The trail's colour, `0..=1` per channel. Constant for the swing's whole life; only the
|
||||
/// alpha ramps.
|
||||
rgb: [f32; 3],
|
||||
/// The **live** alpha byte (`+0x60b`). Not just an opacity — it is the clock: the fade rate,
|
||||
/// the retained segment count and the termination test are all derived from it.
|
||||
alpha: u8,
|
||||
/// `+0x60c` — how long the ring keeps *appending*. The trail outlives it by the fade.
|
||||
duration_ms: u32,
|
||||
/// `+0x610`.
|
||||
start_ms: u32,
|
||||
/// `+0x614` — the previous evaluation's timestamp. The step's first factor is the delta
|
||||
/// against this, **not** the elapsed time (wow-re §7a: feeding elapsed makes `fadeStep`
|
||||
/// explode and kills the trail after one or two frames).
|
||||
last_ms: u32,
|
||||
}
|
||||
|
||||
/// One evaluated frame of a swing: the strip to draw, or the verdict that it is over.
|
||||
enum Step {
|
||||
/// `0x6c6560` returned 0 — the caller frees the SWING and unregisters the callback.
|
||||
Ended,
|
||||
/// The retained samples, oldest first, as `(bottom, top, alpha 0..=1)` per appended frame.
|
||||
/// The **oldest** pair carries the highest alpha and the pair at the weapon carries ≈ 0.
|
||||
Strip(Vec<(Vec3, Vec3, f32)>),
|
||||
}
|
||||
|
||||
impl Swing {
|
||||
/// `0x6c64a0` — push one sample. `slot = head & 0x7f`, then `head++`, then the floor follows
|
||||
/// the head so the ring never reports more than its capacity.
|
||||
fn push(&mut self, p: Vec3) {
|
||||
self.ring[(self.head & (RING_SLOTS - 1)) as usize] = p;
|
||||
self.head += 1;
|
||||
// `if (tail < head - 0x80) tail = head - 0x80`, on a counter that starts at 0: below the
|
||||
// capacity the right-hand side is negative and the floor stays where it is.
|
||||
self.tail = self.tail.max(self.head.saturating_sub(RING_SLOTS));
|
||||
}
|
||||
|
||||
/// `0x6c6540(n)` — raise the floor so at most `n` samples remain, and report how many do.
|
||||
fn trim(&mut self, n: u32) -> u32 {
|
||||
self.tail = self.tail.max(self.head.saturating_sub(n));
|
||||
self.head - self.tail
|
||||
}
|
||||
|
||||
/// Read counter `i` back out — through the reader's **`% 127`**, which is the shipped
|
||||
/// mismatch against the writer's `& 127`.
|
||||
///
|
||||
/// For `i < 127` the two agree and the ring is exact. Past that the reader is offset: slot
|
||||
/// `s` was last written by counter `s + 128k`, so a read at `i ∈ [128, 254]` lands on the
|
||||
/// sample written **one counter later** — and since consecutive counters alternate
|
||||
/// bottom/top, that swaps the ribbon's two edges. Only a trail that appends more than 127
|
||||
/// samples reaches it: at 60 Hz that is 64 frames, so every 300–700 ms kit stops short of it
|
||||
/// and **Whirlwind's 10 000 ms spin (kits 369/370/4213) is the one that lives there.**
|
||||
fn read(&self, i: u32) -> Vec3 {
|
||||
self.ring[(i % READ_MODULUS) as usize]
|
||||
}
|
||||
|
||||
/// `0x6c6560` — one frame: append while the duration holds, compute the fade step, decide
|
||||
/// whether the trail is over, trim to the segments the step affords, build the strip, then
|
||||
/// decay the live alpha past the half-duration mark.
|
||||
///
|
||||
/// Order matters and is the reference's: the strip is coloured from the **pre-decay** alpha,
|
||||
/// and the decay happens after the draw (`0x6c669a` then `0x6c669f`).
|
||||
fn step(&mut self, now_ms: u32, bottom: Vec3, top: Vec3) -> Step {
|
||||
// `6c657c: eax = now - start` / `jae 0x6c65b9` — an unsigned test, so appending stops the
|
||||
// moment the duration is reached and the ribbon freezes where the blade left it.
|
||||
let elapsed = now_ms.wrapping_sub(self.start_ms);
|
||||
if elapsed < self.duration_ms {
|
||||
self.push(bottom); // `$WTB` first (0x6c6587) …
|
||||
self.push(top); // … then `$WTT` (0x6c65a1)
|
||||
}
|
||||
let dt = now_ms.wrapping_sub(self.last_ms);
|
||||
self.last_ms = now_ms;
|
||||
let step = fade_step(dt, self.alpha);
|
||||
// `6c65fa: eax = alpha / fadeStep` then `cmp eax,1 ; jg` — one segment or fewer and the
|
||||
// trail is over.
|
||||
let segments = u32::from(self.alpha) / step;
|
||||
if segments <= 1 {
|
||||
return Step::Ended;
|
||||
}
|
||||
let live = self.trim(2 * segments);
|
||||
if live == 0 {
|
||||
// `6c6620 je -> return 0` — an empty ring ends the trail rather than drawing nothing.
|
||||
return Step::Ended;
|
||||
}
|
||||
// `0x6c64d0`, tail → head. The alpha is decremented once per PAIR (`test bl,1`) BEFORE
|
||||
// that pair's colour is stored, so the oldest retained pair is the opaque end and the
|
||||
// pair at the weapon lands at ≈ 0. Counter-intuitive, and wow-re reached it twice
|
||||
// independently from opposite ends of the function — do not invert it.
|
||||
let mut alpha = i32::from(self.alpha);
|
||||
let mut strip = Vec::with_capacity((live as usize).div_ceil(2));
|
||||
let mut i = self.tail;
|
||||
while i + 1 < self.head {
|
||||
alpha -= step as i32;
|
||||
strip.push((
|
||||
self.read(i),
|
||||
self.read(i + 1),
|
||||
(alpha.max(0) as f32) / 255.0,
|
||||
));
|
||||
i += 2;
|
||||
}
|
||||
// `6c669f: ecx = duration>>1 ; if (elapsed > duration/2) [esi+0x60b] -= fadeStep`. The
|
||||
// trail holds full strength for its first half and only then begins to go.
|
||||
if elapsed > self.duration_ms / 2 {
|
||||
self.alpha = self
|
||||
.alpha
|
||||
.saturating_sub(step.min(u32::from(u8::MAX)) as u8);
|
||||
}
|
||||
Step::Strip(strip)
|
||||
}
|
||||
}
|
||||
|
||||
/// `fadeStep = max(1, ftol(dt_ms · (1/300) · alpha))` — `0x6c65c6`–`0x6c65f8`.
|
||||
///
|
||||
/// The floor is applied through `cmp al,1 / jae`, comparing only the **low byte** of the
|
||||
/// truncated result: a value whose low byte is zero — 256, i.e. a ≈ 768 ms hitch at alpha 100 —
|
||||
/// takes the floor of 1 rather than its own huge value, and the trail survives the stall instead
|
||||
/// of ending on it. Transcribed as the byte comparison it is, not as `max(1, …)`.
|
||||
fn fade_step(dt_ms: u32, alpha: u8) -> u32 {
|
||||
// `fild dt` → `fmul dword [0x811278]` → `fimul dword [alpha]` → `_ftol`. The constant is the
|
||||
// nearest f32 to 1/300 and the x87 evaluates at PC_53 with it widened, so the product runs in
|
||||
// **f64 with an f32 constant** — `dt · alpha / 300.0` is different arithmetic and is not what
|
||||
// the binary does (wow-re `charproc8-trail-draw-state.md` §10).
|
||||
let raw = (f64::from(dt_ms) * f64::from(FADE_PER_MS) * f64::from(alpha)).trunc() as i64 as u32;
|
||||
if raw & 0xff >= 1 {
|
||||
raw
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb this frame's arms into the latch, then fire the latch on every unit that started an
|
||||
/// animation — `0x5fe48e`'s read, `0x60e550`'s fan-out to both hands, and `0x5fe4b1`'s clear.
|
||||
///
|
||||
/// The drain precedes the consume so a kit carrying **both** a proc-8 and an anim id self-fires in
|
||||
/// the frame it plays, which is the reference's own ordering inside `0x60edf0` (dispatcher at
|
||||
/// `0x60f35c`, animation at `0x60f3c5`). A kit whose anim id is `-1` — Charge's kit 44, 38 spells —
|
||||
/// arms and waits: `0x60f366 jl` skips the animation leg outright, so the trail starts on whatever
|
||||
/// the unit plays next, and because `0x5fe2f0` is the image's **single** animation entry point
|
||||
/// that is the charge's own run, not the swing at the end of it.
|
||||
///
|
||||
/// There is **no per-frame recompute** in the reference (`0x5fd8b0` has one caller;
|
||||
/// `0x5fd9e0`'s 38 sites are all event-driven), so an arm on a unit that then changes nothing sits
|
||||
/// armed indefinitely and the trail starts late — at whatever event finally plays something. The
|
||||
/// arm can be spent on a footstep. [`crate::creature_anim::AnimDriver::started_anim`] is the same
|
||||
/// edge-triggered shape for the same reason.
|
||||
pub(crate) fn fire_weapon_trails(
|
||||
time: Res<Time>,
|
||||
mut arms: MessageReader<TrailArm>,
|
||||
mut latch: ResMut<TrailLatch>,
|
||||
units: Query<(Entity, &crate::creature_anim::AnimDriver, &HeldAttached)>,
|
||||
mut trails: Query<&mut WeaponTrail>,
|
||||
) {
|
||||
for arm in arms.read() {
|
||||
// One slot: a second arm before the first fires replaces it, as two writes to one field do.
|
||||
latch.0.insert(arm.entity, arm.trail);
|
||||
}
|
||||
let now_ms = time.elapsed().as_millis() as u32;
|
||||
for (entity, drv, held) in &units {
|
||||
if !drv.started_anim() {
|
||||
continue;
|
||||
}
|
||||
// `0x5fe4a0 je` — the read is gated on a nonzero duration, and `TrailProc` only decodes
|
||||
// for one, so the latch's presence IS that test. Removed either way: the clear at
|
||||
// `0x5fe4b1` is unconditional once the branch is taken.
|
||||
let Some(trail) = latch.0.remove(&entity) else {
|
||||
continue;
|
||||
};
|
||||
// `0x60e550`: `lea esi,[ecx+0xd34]` over TWO slots, skipping a null handle. Ours are the
|
||||
// main- and off-hand prop roots; a hand holding nothing, or a weapon whose model authors
|
||||
// neither marker, simply has no component to arm.
|
||||
for root in held.spawned_slots().iter().take(2).flatten() {
|
||||
if let Ok(mut t) = trails.get_mut(*root) {
|
||||
t.arm(trail, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entries die with the unit (the reference drops the pending arm in the CGUnit teardown
|
||||
// `0x5fbb60`, eight instructions after it destroys both trail objects).
|
||||
latch.0.retain(|e, _| units.contains(*e));
|
||||
}
|
||||
|
||||
/// Step every live swing and write its strip into the shared effect stream.
|
||||
///
|
||||
/// Runs post-propagation so the prop's `GlobalTransform` is *this* frame's blade pose — the
|
||||
/// anchors ride the prop root's frame exactly as [`crate::bowstring`]'s do.
|
||||
fn draw_weapon_trails(
|
||||
time: Res<Time>,
|
||||
mut draw: WorldEffectDraw,
|
||||
white: Res<TrailWhite>,
|
||||
world_cam: Query<Entity, With<WorldCamera>>,
|
||||
mut trails: Query<(
|
||||
Entity,
|
||||
&mut WeaponTrail,
|
||||
&GlobalTransform,
|
||||
&InheritedVisibility,
|
||||
)>,
|
||||
) {
|
||||
let Ok(cam) = world_cam.single() else {
|
||||
return;
|
||||
};
|
||||
let now_ms = time.elapsed().as_millis() as u32;
|
||||
for (entity, mut trail, prop, vis) in &mut trails {
|
||||
let bottom = prop.transform_point(trail.bottom);
|
||||
let top = prop.transform_point(trail.top);
|
||||
let Some(swing) = trail.swing.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let (rgb, strip) = (swing.rgb, swing.step(now_ms, bottom, top));
|
||||
let Step::Strip(strip) = strip else {
|
||||
trail.swing = None;
|
||||
continue;
|
||||
};
|
||||
// The vis chain is ours, not the reference's: a hidden prop has no blade on screen to
|
||||
// trail from. The swing keeps ageing above so it does not resume mid-arc when shown.
|
||||
if !vis.get() || strip.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let anchor = {
|
||||
let (b, t, _) = strip[strip.len() - 1];
|
||||
(b + t) * 0.5
|
||||
};
|
||||
// The six render states the callback writes, byte for byte (wow-re
|
||||
// `charproc8-trail-draw-state.md` §1) — three of which contradict what a spell ribbon
|
||||
// looks like it should be, and every one of the three is visible:
|
||||
//
|
||||
// | EGxRs | value | |
|
||||
// |---|---|---|
|
||||
// | `0x07` | **2** | ordinary `SRC_ALPHA / INV_SRC_ALPHA` — **not** additive (that is mode 3) |
|
||||
// | `0x10` | **0** | depth test **OFF** — the arc is never eaten by the swinger's own shoulder |
|
||||
// | `0x12` | 0 | depth write off — so it occludes nothing in turn |
|
||||
// | `0x14` | 0 | two-sided (the lane never backface-culls) |
|
||||
// | `0x0f` | 0 | unfogged (the lane's default) |
|
||||
// | `0x0e` | 1 | lighting ON — see the module doc's named deviation |
|
||||
//
|
||||
// Setting `0x07` also cascades EGxRs `0x08` (`0x593741`–`0x593764`) to an alpha test of
|
||||
// `GEQUAL 1/255`, which discards the ramp's zero-alpha end. Under alpha blending a
|
||||
// zero-alpha fragment contributes nothing anyway, so it is reproduced by arithmetic.
|
||||
let mut batch = draw
|
||||
.batch(cam, white.0.id())
|
||||
.alpha()
|
||||
.over_everything()
|
||||
.anchored(anchor)
|
||||
.owner(entity);
|
||||
let verts = batch.verts_mut();
|
||||
for w in strip.windows(2) {
|
||||
let ((b0, t0, a0), (b1, t1, a1)) = (w[0], w[1]);
|
||||
// The lane indexes a quad `[0,1,2, 0,2,3]`, so perimeter order `[b0, b1, t1, t0]`
|
||||
// reproduces the strip triangles the reference's `primType 4` draws
|
||||
// (`benilla_world::ribbons` converts its own strip the same way).
|
||||
for (pos, a) in [(b0, a0), (b1, a1), (t1, a1), (t0, a0)] {
|
||||
verts.push(EffectVertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [0.0, 0.0],
|
||||
color: [rgb[0], rgb[1], rgb[2], a],
|
||||
});
|
||||
}
|
||||
}
|
||||
batch.quads();
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1×1 opaque-white texel the trail samples. The reference's trail vertex is **16 bytes** —
|
||||
/// a `C3Vector` and a packed colour, no texcoord (`0x6c6523 add esi,0x10`) — so the ribbon is
|
||||
/// pure vertex colour; the shared effect lane always samples a texture, and white is the
|
||||
/// identity for it.
|
||||
#[derive(Resource)]
|
||||
struct TrailWhite(Handle<Image>);
|
||||
|
||||
fn init_trail_white(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
|
||||
commands.insert_resource(TrailWhite(images.add(Image::default())));
|
||||
}
|
||||
|
||||
/// Registers the trail lane.
|
||||
pub(crate) struct WeaponTrailPlugin;
|
||||
|
||||
impl Plugin for WeaponTrailPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The arm edge and its latch register here; `fire_weapon_trails` itself is placed inside
|
||||
// `creature_anim`'s own chain, immediately after the driver that sets the edge it reads.
|
||||
app.add_message::<TrailArm>()
|
||||
.init_resource::<TrailLatch>()
|
||||
.add_systems(Startup, init_trail_white)
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
draw_weapon_trails
|
||||
.in_set(benilla_world::billboard::BillboardPlace)
|
||||
.after(benilla_world::rig_anim::finalize_rig_worlds)
|
||||
// B161's edge: a lane that writes before the frame's clear loses everything it
|
||||
// pushed, silently, with its arithmetic perfect.
|
||||
.after(benilla_world::particles::buffer::begin_effect_frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,500 @@
|
||||
//! The swing arithmetic, pinned against `0x6c6560`'s own numbers.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A shipped proc: kit 324's — `#f82929`, alpha 100, 600 ms.
|
||||
fn kit_324() -> TrailProc {
|
||||
TrailProc {
|
||||
packed: 0x64f8_2929,
|
||||
duration_ms: 600,
|
||||
}
|
||||
}
|
||||
|
||||
fn armed(trail: TrailProc) -> WeaponTrail {
|
||||
let mut t = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
|
||||
t.arm(trail, 0);
|
||||
t
|
||||
}
|
||||
|
||||
/// wow-re §8's worked example: "with the shipped `CharParamThree = 100` and a 16.7 ms frame,
|
||||
/// `fadeStep = 5`, so `nSegments = 20` — twenty appended frames ≈ 0.33 s of trail history".
|
||||
#[test]
|
||||
fn shipped_frame_gives_five_and_twenty() {
|
||||
for dt in [16, 17] {
|
||||
assert_eq!(fade_step(dt, 100), 5, "dt {dt} ms at alpha 100");
|
||||
}
|
||||
assert_eq!(100 / fade_step(16, 100), 20);
|
||||
}
|
||||
|
||||
/// The floor is `cmp al,1` — a **low-byte** test, so a result of exactly 256 takes the floor
|
||||
/// rather than its own value, and a long hitch does not end the trail.
|
||||
#[test]
|
||||
fn fade_step_floor_is_the_low_byte() {
|
||||
assert_eq!(fade_step(0, 100), 1, "a zero delta floors");
|
||||
assert_eq!(fade_step(1, 100), 1, "trunc(0.33) = 0 floors");
|
||||
assert_eq!(
|
||||
fade_step(768, 100),
|
||||
1,
|
||||
"trunc = 256, low byte 0 -> the floor"
|
||||
);
|
||||
assert_eq!(fade_step(771, 100), 257, "trunc = 257, low byte 1 -> kept");
|
||||
}
|
||||
|
||||
/// The writer masks `& 127` and the reader divides `% 127`. Below the capacity they agree
|
||||
/// exactly; past it the reader is off by one counter, which swaps the ribbon's two edges.
|
||||
#[test]
|
||||
fn ring_read_and_write_diverge_past_127() {
|
||||
let mut t = armed(kit_324());
|
||||
let s = t.swing.as_mut().expect("armed");
|
||||
for i in 0..127u32 {
|
||||
s.push(Vec3::splat(i as f32));
|
||||
}
|
||||
for i in 0..127u32 {
|
||||
assert_eq!(s.read(i), Vec3::splat(i as f32), "counter {i} is exact");
|
||||
}
|
||||
// Counter 127 writes slot 127; counter 128 writes slot 0, and the reader's `128 % 127 = 1`
|
||||
// picks up slot 1 — the sample written by counter 129, one AHEAD of the one it wants.
|
||||
for i in 127..135u32 {
|
||||
s.push(Vec3::splat(i as f32));
|
||||
}
|
||||
assert_eq!(
|
||||
s.read(128),
|
||||
Vec3::splat(129.0),
|
||||
"the reader lands one counter late — the shipped 5875 mismatch, not a slip"
|
||||
);
|
||||
}
|
||||
|
||||
/// The ring never reports more than its capacity, and the floor follows the head.
|
||||
#[test]
|
||||
fn push_bounds_the_ring_at_capacity() {
|
||||
let mut t = armed(kit_324());
|
||||
let s = t.swing.as_mut().expect("armed");
|
||||
for i in 0..300u32 {
|
||||
s.push(Vec3::splat(i as f32));
|
||||
assert!(s.head - s.tail <= RING_SLOTS, "at push {i}");
|
||||
}
|
||||
assert_eq!(s.head - s.tail, RING_SLOTS);
|
||||
}
|
||||
|
||||
/// Re-arming a live swing resets its ring in place (`0x6c675f`) rather than stacking a second
|
||||
/// trail — the same creation-time law the effect nodes take (2057).
|
||||
#[test]
|
||||
fn rearm_resets_rather_than_stacks() {
|
||||
let mut t = armed(kit_324());
|
||||
let s = t.swing.as_mut().expect("armed");
|
||||
for i in 0..40u32 {
|
||||
s.push(Vec3::splat(i as f32));
|
||||
}
|
||||
assert_eq!(s.head, 40);
|
||||
t.arm(kit_324(), 5_000);
|
||||
let s = t.swing.as_ref().expect("still armed");
|
||||
assert_eq!((s.head, s.tail), (0, 0), "the ring restarts");
|
||||
assert_eq!(s.start_ms, 5_000, "and so does the clock");
|
||||
}
|
||||
|
||||
/// One whole swing at 60 Hz, kit 324: how long it lives, how long it gets, and that it dies.
|
||||
#[test]
|
||||
fn kit_324_at_sixty_hz() {
|
||||
let mut t = armed(kit_324());
|
||||
let (mut frame, mut longest, mut appended) = (0u32, 0usize, 0u32);
|
||||
let end = loop {
|
||||
frame += 1;
|
||||
let now = frame * 1000 / 60;
|
||||
let blade = Vec3::new(frame as f32 * 0.1, 0.0, 0.0);
|
||||
let swing = t.swing.as_mut().expect("live");
|
||||
let before = swing.head;
|
||||
match swing.step(now, blade, blade + Vec3::Y) {
|
||||
Step::Ended => break now,
|
||||
Step::Strip(s) => {
|
||||
longest = longest.max(s.len());
|
||||
appended += (swing.head - before) / 2;
|
||||
}
|
||||
}
|
||||
assert!(frame < 10_000, "the trail must terminate");
|
||||
};
|
||||
assert_eq!(appended, 35, "appends for the 600 ms duration at 60 Hz");
|
||||
// §8's nominal is 20 pairs (alpha 100 / fadeStep 5), but `fadeStep` is a TRUNCATED product,
|
||||
// so as the alpha decays the integer quotient drifts *above* it — alpha 68 at a 16 ms frame
|
||||
// truncates to step 3 and asks for 22. The ring's 64 pairs is the real ceiling.
|
||||
assert_eq!(
|
||||
longest, 22,
|
||||
"the retained history peaks just over the nominal 20 pairs"
|
||||
);
|
||||
// wow-re §8 estimates the visible life at "≈ duration/2 + (alpha/fadeStep)·dt ≈ duration",
|
||||
// holding `fadeStep` constant. It is not constant — it is `trunc(dt·alpha/300)`, so it shrinks
|
||||
// with the alpha it is decaying, and the truncation shaves it further (alpha 54 asks for 2.88
|
||||
// and gets 2). The decay is therefore sub-exponential and a 600 ms kit is on screen for
|
||||
// **twice** its duration.
|
||||
assert_eq!(
|
||||
end, 1_250,
|
||||
"a 600 ms kit is visible for 1.25 s, not §8's ~0.6 s"
|
||||
);
|
||||
}
|
||||
|
||||
/// The alpha ramp's DIRECTION — the one thing a re-implementation is most likely to invert.
|
||||
/// The oldest retained pair is the opaque end; the pair at the weapon is the transparent one,
|
||||
/// and once the ring holds its full `2·segments` the ramp reaches exactly 0 there.
|
||||
#[test]
|
||||
fn the_far_end_of_the_arc_is_the_opaque_end() {
|
||||
let mut t = armed(kit_324());
|
||||
let swing = t.swing.as_mut().expect("live");
|
||||
let mut strip = Vec::new();
|
||||
// Twenty appended frames is where the ring first holds the full `alpha/fadeStep` segments.
|
||||
for frame in 1..=20u32 {
|
||||
let blade = Vec3::new(frame as f32, 0.0, 0.0);
|
||||
match swing.step(frame * 1000 / 60, blade, blade + Vec3::Y) {
|
||||
Step::Strip(s) => strip = s,
|
||||
Step::Ended => panic!("still within the duration"),
|
||||
}
|
||||
}
|
||||
let alphas: Vec<f32> = strip.iter().map(|&(_, _, a)| a).collect();
|
||||
assert!(alphas.len() > 2);
|
||||
assert!(
|
||||
alphas.windows(2).all(|w| w[0] > w[1]),
|
||||
"alpha must fall from the oldest sample to the weapon: {alphas:?}"
|
||||
);
|
||||
// One `fadeStep` per pair, subtracted before the pair is stored — so the ramp is arithmetic
|
||||
// and its last term is exactly zero.
|
||||
let drop = alphas[0] - alphas[1];
|
||||
assert!(
|
||||
alphas.windows(2).all(|w| (w[0] - w[1] - drop).abs() < 1e-6),
|
||||
"a constant fadeStep per pair: {alphas:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
alphas[alphas.len() - 1],
|
||||
0.0,
|
||||
"the end at the blade is transparent: {alphas:?}"
|
||||
);
|
||||
assert!(
|
||||
alphas[0] > 0.3,
|
||||
"and the far end is the opaque one: {alphas:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Before the ring fills, the oldest pair carries `alpha − fadeStep` outright — 95/255 at the
|
||||
/// shipped alpha of 100.
|
||||
#[test]
|
||||
fn the_oldest_pair_carries_alpha_minus_one_step() {
|
||||
let mut t = armed(kit_324());
|
||||
let swing = t.swing.as_mut().expect("live");
|
||||
let mut strip = Vec::new();
|
||||
for frame in 1..=10u32 {
|
||||
match swing.step(frame * 1000 / 60, Vec3::ZERO, Vec3::Y) {
|
||||
Step::Strip(s) => strip = s,
|
||||
Step::Ended => panic!("still within the duration"),
|
||||
}
|
||||
}
|
||||
assert_eq!(swing.alpha, 100, "still inside the first half");
|
||||
assert!((strip[0].2 - 95.0 / 255.0).abs() < 1e-6, "{}", strip[0].2);
|
||||
}
|
||||
|
||||
/// A trail holds full strength for its first half; the decay only starts past `duration/2`.
|
||||
#[test]
|
||||
fn alpha_holds_until_the_half_duration_mark() {
|
||||
let mut t = armed(kit_324());
|
||||
let swing = t.swing.as_mut().expect("live");
|
||||
for frame in 1..=22u32 {
|
||||
let now = frame * 1000 / 60;
|
||||
assert!(matches!(
|
||||
swing.step(now, Vec3::ZERO, Vec3::Y),
|
||||
Step::Strip(_)
|
||||
));
|
||||
if now <= 300 {
|
||||
assert_eq!(swing.alpha, 100, "still at full strength at {now} ms");
|
||||
}
|
||||
}
|
||||
assert!(swing.alpha < 100, "and decaying past the mark");
|
||||
}
|
||||
|
||||
/// Whirlwind's 10 000 ms spin is the one shipped kit that outruns the ring, so it is the one the
|
||||
/// reader/writer modulus mismatch is visible on.
|
||||
#[test]
|
||||
fn only_whirlwind_outruns_the_ring() {
|
||||
let whirlwind = TrailProc {
|
||||
packed: 0x64f7_1717,
|
||||
duration_ms: 10_000,
|
||||
};
|
||||
for (trail, outruns) in [(kit_324(), false), (whirlwind, true)] {
|
||||
let mut t = armed(trail);
|
||||
let mut frame = 0u32;
|
||||
let head = loop {
|
||||
frame += 1;
|
||||
let swing = t.swing.as_mut().expect("live");
|
||||
if matches!(
|
||||
swing.step(frame * 1000 / 60, Vec3::ZERO, Vec3::Y),
|
||||
Step::Ended
|
||||
) {
|
||||
break swing.head;
|
||||
}
|
||||
assert!(frame < 10_000);
|
||||
};
|
||||
assert_eq!(
|
||||
head > READ_MODULUS,
|
||||
outruns,
|
||||
"{} ms appended {head} samples",
|
||||
trail.duration_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The latch: armed by the kit, consumed by the unit's next animation, one slot per unit.
|
||||
|
||||
use crate::creature_anim::AnimDriver;
|
||||
use crate::entities::{HeldAttached, ATTACH_SLOT_NAMES};
|
||||
|
||||
/// A unit holding a trail-capable weapon in each hand, plus the app that fires them.
|
||||
fn latch_app() -> (App, Entity, [Entity; 2]) {
|
||||
let mut app = App::new();
|
||||
app.add_message::<TrailArm>()
|
||||
.init_resource::<TrailLatch>()
|
||||
.init_resource::<Time>()
|
||||
.add_systems(Update, fire_weapon_trails);
|
||||
let hands = [
|
||||
app.world_mut()
|
||||
.spawn(WeaponTrail::new(Vec3::Y, Vec3::ZERO))
|
||||
.id(),
|
||||
app.world_mut()
|
||||
.spawn(WeaponTrail::new(Vec3::Y, Vec3::ZERO))
|
||||
.id(),
|
||||
];
|
||||
let mut spawned = [None; ATTACH_SLOT_NAMES.len()];
|
||||
spawned[0] = Some(hands[0]);
|
||||
spawned[1] = Some(hands[1]);
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((AnimDriver::default(), HeldAttached::with_spawned(spawned)))
|
||||
.id();
|
||||
(app, unit, hands)
|
||||
}
|
||||
|
||||
fn trailing(app: &App, hand: Entity) -> bool {
|
||||
app.world()
|
||||
.get::<WeaponTrail>(hand)
|
||||
.expect("the hand exists")
|
||||
.swing
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn play_anim(app: &mut App, unit: Entity) {
|
||||
app.world_mut()
|
||||
.get_mut::<AnimDriver>(unit)
|
||||
.expect("the unit exists")
|
||||
.set_started_anim(true);
|
||||
}
|
||||
|
||||
/// The proc ARMS; it does not draw. Nothing happens until the unit plays an animation — which is
|
||||
/// what makes Charge (kit 44, anim id −1) start its trail on the charge itself.
|
||||
#[test]
|
||||
fn an_arm_alone_fires_nothing() {
|
||||
let (mut app, unit, hands) = latch_app();
|
||||
app.world_mut().write_message(TrailArm {
|
||||
entity: unit,
|
||||
trail: kit_324(),
|
||||
});
|
||||
app.update();
|
||||
assert!(
|
||||
hands.iter().all(|&h| !trailing(&app, h)),
|
||||
"no animation yet"
|
||||
);
|
||||
play_anim(&mut app, unit);
|
||||
app.update();
|
||||
assert!(
|
||||
hands.iter().all(|&h| trailing(&app, h)),
|
||||
"the next animation fires it — on BOTH hands (0x60e550's two slots)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x5fe4b1 mov [ebx+0xd20],edi` — the duration is cleared the moment it is read, so one arm is
|
||||
/// one trail. A second animation must not restart it.
|
||||
#[test]
|
||||
fn the_latch_is_consumed_exactly_once() {
|
||||
let (mut app, unit, hands) = latch_app();
|
||||
app.world_mut().write_message(TrailArm {
|
||||
entity: unit,
|
||||
trail: kit_324(),
|
||||
});
|
||||
play_anim(&mut app, unit);
|
||||
app.update();
|
||||
assert!(trailing(&app, hands[0]));
|
||||
// Clear the swing and play again: with the latch consumed, nothing re-arms.
|
||||
app.world_mut()
|
||||
.get_mut::<WeaponTrail>(hands[0])
|
||||
.expect("the hand exists")
|
||||
.swing = None;
|
||||
play_anim(&mut app, unit);
|
||||
app.update();
|
||||
assert!(!trailing(&app, hands[0]), "the arm was one-shot");
|
||||
}
|
||||
|
||||
/// One pair of fields on the unit means one pending arm: a second proc before the first fires
|
||||
/// OVERWRITES it rather than queueing behind it.
|
||||
#[test]
|
||||
fn a_second_arm_replaces_the_pending_one() {
|
||||
let (mut app, unit, hands) = latch_app();
|
||||
let whirlwind = TrailProc {
|
||||
packed: 0x64f7_1717,
|
||||
duration_ms: 10_000,
|
||||
};
|
||||
for trail in [kit_324(), whirlwind] {
|
||||
app.world_mut().write_message(TrailArm {
|
||||
entity: unit,
|
||||
trail,
|
||||
});
|
||||
}
|
||||
play_anim(&mut app, unit);
|
||||
app.update();
|
||||
let hand = app
|
||||
.world()
|
||||
.get::<WeaponTrail>(hands[0])
|
||||
.expect("the hand exists");
|
||||
let swing = hand.swing.as_ref().expect("armed");
|
||||
assert_eq!(swing.duration_ms, 10_000, "the LAST arm wins");
|
||||
}
|
||||
|
||||
/// A hand holding nothing — or a weapon whose model authors neither marker — has no object to
|
||||
/// arm, and the other hand is unaffected (`0x60e550`'s `test ecx,ecx ; je` per slot).
|
||||
#[test]
|
||||
fn an_empty_hand_is_skipped() {
|
||||
let (mut app, unit, hands) = latch_app();
|
||||
app.world_mut().entity_mut(hands[1]).remove::<WeaponTrail>();
|
||||
app.world_mut().write_message(TrailArm {
|
||||
entity: unit,
|
||||
trail: kit_324(),
|
||||
});
|
||||
play_anim(&mut app, unit);
|
||||
app.update();
|
||||
assert!(trailing(&app, hands[0]), "the armed hand still fires");
|
||||
}
|
||||
|
||||
/// The pending arm dies with the unit — the reference drops it in the CGUnit teardown
|
||||
/// (`0x5fbcdc`), eight instructions after destroying both trail objects.
|
||||
#[test]
|
||||
fn the_latch_is_swept_when_the_unit_goes() {
|
||||
let (mut app, unit, _) = latch_app();
|
||||
app.world_mut().write_message(TrailArm {
|
||||
entity: unit,
|
||||
trail: kit_324(),
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(app.world().resource::<TrailLatch>().0.len(), 1);
|
||||
app.world_mut().entity_mut(unit).despawn();
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<TrailLatch>().0.is_empty(),
|
||||
"the arm goes with the unit"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The draw: an armed trail must actually reach the shared effect stream.
|
||||
|
||||
use benilla_world::particles::buffer::{begin_effect_frame, EffectQuads};
|
||||
|
||||
/// End to end through the render half: an armed swing on a moving blade commits vertices, they
|
||||
/// carry the kit's colour and the ramp, and the draw stops when the trail does.
|
||||
#[test]
|
||||
fn an_armed_trail_commits_a_strip_to_the_effect_stream() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<Assets<Image>>()
|
||||
.init_resource::<EffectQuads>()
|
||||
.add_systems(Startup, init_trail_white)
|
||||
.add_systems(Update, (begin_effect_frame, draw_weapon_trails).chain());
|
||||
let cam = app.world_mut().spawn(WorldCamera).id();
|
||||
let _ = cam;
|
||||
let mut trail = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
|
||||
trail.arm(kit_324(), 0);
|
||||
let hand = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
trail,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
))
|
||||
.id();
|
||||
// Frame 1 seeds one pair; a strip needs two, so the first commit is empty by construction.
|
||||
app.update();
|
||||
// Sweep the blade so the samples are distinct, and let the ring fill.
|
||||
for f in 1..=8u32 {
|
||||
app.world_mut()
|
||||
.get_mut::<Transform>(hand)
|
||||
.expect("the hand exists")
|
||||
.translation = Vec3::new(f as f32 * 0.2, 0.0, 0.0);
|
||||
app.world_mut()
|
||||
.get_mut::<GlobalTransform>(hand)
|
||||
.expect("the hand exists")
|
||||
.clone_from(&GlobalTransform::from_translation(Vec3::new(
|
||||
f as f32 * 0.2,
|
||||
0.0,
|
||||
0.0,
|
||||
)));
|
||||
app.world_mut()
|
||||
.resource_mut::<Time>()
|
||||
.advance_by(std::time::Duration::from_millis(16));
|
||||
app.update();
|
||||
}
|
||||
let quads = app.world().resource::<EffectQuads>();
|
||||
assert_eq!(quads.draws.len(), 1, "one draw for the one live trail");
|
||||
let draw = &quads.draws[0];
|
||||
let verts = &quads.verts[draw.range.start as usize..draw.range.end as usize];
|
||||
assert!(verts.len() >= 8, "at least two quads: {}", verts.len());
|
||||
assert_eq!(verts.len() % 4, 0, "whole quads");
|
||||
// Kit 324's #f82929, and an alpha ramp that actually varies across the strip.
|
||||
for v in verts {
|
||||
assert!((v.color[0] - 248.0 / 255.0).abs() < 1e-6, "{:?}", v.color);
|
||||
assert!((v.color[1] - 41.0 / 255.0).abs() < 1e-6, "{:?}", v.color);
|
||||
}
|
||||
// Eight appended frames is short of the 20 the ring will hold, so the ramp has not yet
|
||||
// reached 0 at the blade — but it must already fall, by one `fadeStep` per quad, from the
|
||||
// opaque far end (alpha − fadeStep = 95/255) toward it.
|
||||
let alphas: Vec<f32> = verts.iter().map(|v| v.color[3]).collect();
|
||||
let (lo, hi) = (
|
||||
alphas.iter().cloned().fold(f32::MAX, f32::min),
|
||||
alphas.iter().cloned().fold(f32::MIN, f32::max),
|
||||
);
|
||||
assert!((hi - 95.0 / 255.0).abs() < 1e-6, "the opaque end: {hi}");
|
||||
assert!(hi - lo > 0.1, "a real ramp, not one flat alpha: {alphas:?}");
|
||||
// Each quad is `[b0, b1, t1, t0]` — the two ends of one segment, so its corners pair up.
|
||||
for q in verts.chunks_exact(4) {
|
||||
assert_eq!(
|
||||
q[0].color[3], q[3].color[3],
|
||||
"the older pair shares an alpha"
|
||||
);
|
||||
assert_eq!(q[1].color[3], q[2].color[3], "and so does the newer");
|
||||
assert!(q[0].color[3] > q[1].color[3], "falling toward the weapon");
|
||||
}
|
||||
// The blade swept along +X, so the strip must span more than a single blade's worth of it.
|
||||
let xs: Vec<f32> = verts.iter().map(|v| v.pos[0]).collect();
|
||||
let span =
|
||||
xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min);
|
||||
assert!(span > 0.5, "the ribbon follows the blade's arc: {span}");
|
||||
}
|
||||
|
||||
/// A hidden prop draws no trail — our own vis chain, the same gate [`crate::bowstring`] takes.
|
||||
#[test]
|
||||
fn a_hidden_weapon_draws_nothing() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<Assets<Image>>()
|
||||
.init_resource::<EffectQuads>()
|
||||
.add_systems(Startup, init_trail_white)
|
||||
.add_systems(Update, (begin_effect_frame, draw_weapon_trails).chain());
|
||||
app.world_mut().spawn(WorldCamera);
|
||||
let mut trail = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
|
||||
trail.arm(kit_324(), 0);
|
||||
app.world_mut().spawn((
|
||||
trail,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::HIDDEN,
|
||||
));
|
||||
for _ in 0..6 {
|
||||
app.world_mut()
|
||||
.resource_mut::<Time>()
|
||||
.advance_by(std::time::Duration::from_millis(16));
|
||||
app.update();
|
||||
}
|
||||
assert!(app.world().resource::<EffectQuads>().draws.is_empty());
|
||||
}
|
||||
@@ -666,4 +666,72 @@ mod tests {
|
||||
and a fragment write costs every draw on this lane its early-Z (decision 2016)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The ADT depth swatch, pinned two ways: the WGSL still carries the reference's own row
|
||||
/// arithmetic, and that arithmetic reproduces wow-re's published byte vectors.
|
||||
///
|
||||
/// The mirror below is a **spec anchor**, not a second implementation — nothing else calls it.
|
||||
/// It exists because WGSL has no unit-test harness here (Bevy's `#import`/`#ifdef` mean naga
|
||||
/// cannot validate these files standalone), so the source-text pin above it is what ties the two
|
||||
/// together: change the shader's formula and this fails, pointing at the numbers it has to keep
|
||||
/// producing.
|
||||
///
|
||||
/// The vectors are wow-re's, from `terrain/scratch/ocean-depth-ramp-law.md` — `Light.dbc` id 4,
|
||||
/// map 0, t = 1440, the ocean pair `LightIntBand` sub-14 → sub-15.
|
||||
#[test]
|
||||
fn liquid_swatch_reproduces_the_reference_row_ramp() {
|
||||
let src = include_str!("shaders/liquid.wgsl");
|
||||
// The row accumulator: `c0 + floor(i*(c1 - c0)/64)`, NOT a lerp to the deep endpoint.
|
||||
assert!(
|
||||
src.contains("let row = c0 + floor(i * (c1 - c0) / 64.0);"),
|
||||
"the swatch stopped building its rows the way FUN_0068a830 does — a plain lerp to the \
|
||||
deep endpoint runs a 64th of a ramp that does not exist (decision 2074)"
|
||||
);
|
||||
// The ocean-only tail: floor(0.9*byte) on the LAST row, alpha forced opaque.
|
||||
assert!(
|
||||
src.contains("if ocean && i >= 63.0 {")
|
||||
&& src.contains("vec4<f32>(floor(row.rgb * 0.9), 255.0)"),
|
||||
"the ocean's last-row darkening is gone — ~80% of the world's ocean vertices sample \
|
||||
that row, so this is the open sea's colour (decision 2074)"
|
||||
);
|
||||
// LINEAR sampling across the two rows V falls between, at texel `V*64 - 0.5`.
|
||||
assert!(
|
||||
src.contains("let t = clamp(v * 64.0 - 0.5, 0.0, 63.0);"),
|
||||
"the swatch stopped sampling as an 8x64 LINEAR/CLAMP texture — the ocean darkening \
|
||||
would step instead of ramping across the final 1/64 of V (decision 2074)"
|
||||
);
|
||||
|
||||
/// `row(i)` for one channel, mirroring `swatch_row`'s byte arithmetic exactly.
|
||||
fn row(c0: i32, c1: i32, i: i32) -> i32 {
|
||||
c0 + (i * (c1 - c0)).div_euclid(64)
|
||||
}
|
||||
/// The ocean tail's colour half.
|
||||
fn tail(b: i32) -> i32 {
|
||||
(f64::from(b) * 0.9).floor() as i32
|
||||
}
|
||||
|
||||
// Ocean shallow (sub-14) 0x00457b63 and deep (sub-15) 0x000b2228, as R/G/B.
|
||||
let (shallow, deep) = ([69, 123, 99], [11, 34, 40]);
|
||||
let at = |i| [0, 1, 2].map(|k| row(shallow[k], deep[k], i));
|
||||
|
||||
// Row 63 is one short of the endpoint on G — the ramp never arrives.
|
||||
assert_eq!(at(63), [11, 35, 40], "row 63 before the ocean tail");
|
||||
assert_ne!(
|
||||
at(63)[1],
|
||||
deep[1],
|
||||
"the ramp must NOT reach the deep endpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
at(62),
|
||||
[12, 36, 41],
|
||||
"row 62, the other half of the last blend"
|
||||
);
|
||||
assert_eq!(
|
||||
at(63).map(tail),
|
||||
[9, 31, 36],
|
||||
"row 63 after the ocean tail (HSV V *= 0.9, which is floor(0.9*byte) per channel)"
|
||||
);
|
||||
// The shallow end is the endpoint exactly — the tail and the shortfall are deep-end only.
|
||||
assert_eq!(at(0), shallow, "row 0 is the shallow endpoint verbatim");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,15 @@
|
||||
// is against the shader leg — which is the leg we implement. The two do not compose: an active ARB
|
||||
// program bypasses the texture environment entirely.
|
||||
//
|
||||
// A SINGLE swatch row (V) indexes both the colour and the alpha — they track together. V is `clamp(byte/42)`
|
||||
// for river/lake (VERIFIED `c81768`, saturates ~5 yd → the channel middle hits the deep teal row) and
|
||||
// byte/255 for ocean (placeholder; ocean uses a non-LUT UV path). (Earlier cuts: ripple-as-colour → black;
|
||||
// ×8 over-saturated; FLAT colour killed the gradient; sky×0.711 was the wrong builder; `byte/255` was the
|
||||
// wrong LUT → river middle never went teal. Corrected to rows 14–17 raw lerp + the /42 V, 2026-05-31.)
|
||||
// A SINGLE swatch row (V) indexes both the colour and the alpha — they track together. Each kind reads
|
||||
// its OWN verified LUT, built side by side in `FUN_0068c4c0`: `clamp(byte/42)` for river/lake
|
||||
// (`c81768`, `FUN_0068d790`, saturates ~5 yd → the channel middle hits the deep teal row) and
|
||||
// `clamp(byte/255)` for ocean (`c7fcd8`, `FUN_0068d690`, saturates ~148 yd). The divisors differ
|
||||
// because the authored bytes do — the sea ramps 1.72 byte/yd against a river's 8.96, and 83 % of ocean
|
||||
// vertices are pinned at 255 outright (decision 2069, `examples/liquid_depth_census`). (Earlier cuts:
|
||||
// ripple-as-colour → black; ×8 over-saturated; FLAT colour killed the gradient; sky×0.711 was the wrong
|
||||
// builder; `byte/255` on the RIVER was the wrong LUT → river middle never went teal. Corrected to
|
||||
// rows 14–17 raw lerp + the /42 V on rivers, 2026-05-31.)
|
||||
//
|
||||
// Two-sided comes from the material (cull off) and is right for EVERY kind: all four reference liquid
|
||||
// passes force GL_CULL_FACE off at pass entry against a cull-ON device baseline, and `glFrontFace` is
|
||||
@@ -283,6 +287,66 @@ fn vertex(in: Vertex) -> LiquidVsOut {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── The ADT depth swatch, as the reference actually BUILDS and SAMPLES it ────────────────────
|
||||
//
|
||||
// `FUN_0068a830` fills an 8×64 texture (U inert — each row is `rep stosd`-replicated across all 8
|
||||
// columns, matching the vertex fill's pinned `u = 0.5`). Its rows are an exact 32-bit integer
|
||||
// accumulator in **byte space**, not a float lerp:
|
||||
//
|
||||
// step = ((c1 - c0) << 8) >> 6 ; == 4*(c1 - c0) EXACTLY (six zero low bits, so the sar
|
||||
// acc = c0 << 8 ; acc += step ; cannot truncate -> no accumulator drift over 64 rows)
|
||||
// row(i) = (acc >> 8) & 0xff ; == c0 + floor(i*(c1 - c0) / 64), i = 0..63
|
||||
//
|
||||
// Two things that costs us, both missed until wow-re's ocean §5 round (decision 2074):
|
||||
//
|
||||
// * **the ramp never reaches the deep endpoint.** Row 63 is `c0 + floor(63*d/64)`, ≈98.4 % of the
|
||||
// way, not `c1`. Our old `mix(shallow, deep, V)` ran the last 1/64 of the ramp that does not
|
||||
// exist.
|
||||
// * **the ocean's last row alone is darkened.** The tail `[0x68a9c0, 0x68aa36)` is gated on *last
|
||||
// row* AND *selector == 0* (ocean; river is selector 1 and gets neither): RGB→HSV,
|
||||
// `0x68aa13 fmul [0x8102ec]` — **V *= 0.9** (`0x3f666666`, the f32 nearest 0.9) — HSV→RGB, then
|
||||
// `0x7bbec0`/`0x7bbec8` forcing that row's alpha to 255. `0x7bbd60`'s HSV→RGB writes every
|
||||
// channel as a product with V and the tail touches neither H nor S (and `S == 0` returns
|
||||
// `(V,V,V)` without reading H, so achromatic input takes no hue shift), so the colour half is
|
||||
// exactly `floor(0.9 * byte)` per channel — transcribed at f32 and run over all 2^24 byte
|
||||
// triples: 99.03 % bit-exact, **max deviation 1/255**.
|
||||
//
|
||||
// It is not a corner case: ~80 % of the ocean vertices in the shipped world carry depth byte 255,
|
||||
// so `V = 1.0` and this row IS the open sea (decision 2069's census).
|
||||
//
|
||||
// Sampling is **LINEAR/LINEAR, no mip, D3DTADDRESS_CLAMP** — flags word `0x201`, `0x5a2a18 and 7`
|
||||
// -> `0x85c7d8` row 1 `{MAG, MIN, MIP} = {2, 2, 0}`, `0x5a2a62 shr 3` -> `0x80a254[0] = 3`;
|
||||
// corroborated by a GL capture of this exact texture (8x64, levels 1, CLAMP_TO_EDGE, LINEAR/LINEAR).
|
||||
// So V maps to the texel coordinate `V*64 - 0.5` and blends across neighbouring rows: the darkening
|
||||
// **ramps in over the final 1/64 of V**, it does not step. That band is the only place any of this
|
||||
// is visible on a shore.
|
||||
//
|
||||
// The WMO arms do NOT come through here — their opacity is a different, 256-entry ramp
|
||||
// (`0xca7f10`), whose 1/256 granularity the plain lerp above reproduces.
|
||||
fn swatch_row(shallow: vec4<f32>, deep: vec4<f32>, i: f32, ocean: bool) -> vec4<f32> {
|
||||
// RGB endpoints arrive already BYTES (`0x68a8fb`/`0x68a902` read two packed dwords straight out
|
||||
// of DayNight state — there is no quantization step for RGB at all); the ALPHA endpoints are
|
||||
// `LightParams` floats the reference quantizes `floor(v*255)` first.
|
||||
let c0 = vec4<f32>(round(shallow.rgb * 255.0), floor(shallow.w * 255.0));
|
||||
let c1 = vec4<f32>(round(deep.rgb * 255.0), floor(deep.w * 255.0));
|
||||
let row = c0 + floor(i * (c1 - c0) / 64.0);
|
||||
if ocean && i >= 63.0 {
|
||||
return vec4<f32>(floor(row.rgb * 0.9), 255.0) / 255.0;
|
||||
}
|
||||
return row / 255.0;
|
||||
}
|
||||
|
||||
/// The swatch sampled at depth coord `v`, LINEAR across the two rows it falls between.
|
||||
fn swatch_at(shallow: vec4<f32>, deep: vec4<f32>, v: f32, ocean: bool) -> vec4<f32> {
|
||||
let t = clamp(v * 64.0 - 0.5, 0.0, 63.0);
|
||||
let i0 = floor(t);
|
||||
return mix(
|
||||
swatch_row(shallow, deep, i0, ocean),
|
||||
swatch_row(shallow, deep, min(i0 + 1.0, 63.0), ocean),
|
||||
t - i0,
|
||||
);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
// HARD FAR-CLIP WALL (same as terrain/models, see terrain.wgsl): discard water beyond the
|
||||
@@ -324,7 +388,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
|
||||
// Per-vertex swatch coord V (in `in.depth`, computed CPU-side in wow-formats/liquid.rs): river/lake
|
||||
// = `clamp(byte/42)` (VERIFIED WoW.exe `c81768` LUT / `FUN_0068d790`, saturating ~5 yd so the channel
|
||||
// middle reaches the deep/teal row), ocean = byte/255 (placeholder, different path). The depth swatch
|
||||
// middle reaches the deep/teal row), ocean = `clamp(byte/255)` (VERIFIED `c7fcd8` / `FUN_0068d690`,
|
||||
// its own LUT on its own authored byte scale — decision 2069). The depth swatch
|
||||
// is a plain 2-endpoint lerp (`FUN_0068a830`), so a SINGLE V indexes BOTH the colour and the alpha
|
||||
// row: colour `shallow→deep` and opacity `shallow_α→deep_α` track together. (Earlier `×4` colour
|
||||
// compression + the gentle `byte/255` V were band-aids for a wrong "V tops at 0.31" belief — removed.)
|
||||
@@ -337,8 +402,6 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
shallow = wow_light.water_ocean[0];
|
||||
deep = wow_light.water_ocean[1];
|
||||
}
|
||||
let water_tint = mix(shallow.rgb, deep.rgb, depth);
|
||||
|
||||
// ---- The two WMO water arms ------------------------------------------------------------
|
||||
//
|
||||
// Neither is the ADT combine below. `0x6b62e0`'s category 0 splits on the owning group's
|
||||
@@ -446,14 +509,19 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
// `docs/knowledge/scratch/liquid-depth/fleck-deep.md`.)
|
||||
let secondary = in.secondary_vtx;
|
||||
|
||||
// The stage-0 depth swatch, built and sampled as the reference does (see `swatch_row`): a
|
||||
// byte-space 64-row ramp that stops short of the deep endpoint, LINEAR across rows, with the
|
||||
// ocean's last row darkened `floor(0.9*byte)` and its alpha forced opaque.
|
||||
let swatch = swatch_at(shallow, deep, depth, w.kind.y > 0.5);
|
||||
|
||||
// primary·colorTex.rgb + detail.rgb + (secondary + 0.25)·detail.a (the ocean0_s.bls math)
|
||||
var rgb = primary * water_tint + detail.rgb + (secondary + vec3<f32>(0.25)) * detail.a;
|
||||
var rgb = primary * swatch.rgb + detail.rgb + (secondary + vec3<f32>(0.25)) * detail.a;
|
||||
|
||||
// Opacity: depth ramp between the shallow/deep LightParams water alphas, over the SAME V as the
|
||||
// colour. Deeper = more opaque, up to α=1.0 where V saturates (river/lake byte 42 ≈ 5 yd), so the
|
||||
// channel middle is opaque + teal while the shore stays semi-transparent (V→0, α≈0.5) and the bottom
|
||||
// shows through (faithful — the pale edge band). One steep V drives both colour and opacity together.
|
||||
let alpha = mix(shallow.w, deep.w, depth);
|
||||
let alpha = swatch.w;
|
||||
|
||||
// Distance fog (see `apply_fog`) — the water fog colour is also teal, so far water converges on the
|
||||
// haze.
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! What the shipped MCLQ **depth byte** actually is, per liquid kind — the instrument behind the
|
||||
//! ocean depth-ramp settle:
|
||||
//! `cargo run --release -p benilla-formats --example liquid_depth_census -- [map ...]`
|
||||
//! (default: `Azeroth Kalimdor`, i.e. every ADT ocean and river in the vanilla world.)
|
||||
//!
|
||||
//! The reference indexes its water depth-swatch by this one byte through a per-kind LUT — river
|
||||
//! `min(d/42, 1)`, ocean `min(d/255, 1)` — so the *look* of a sea is that LUT applied to whatever
|
||||
//! the artists authored into the byte. A divisor argued from the binary alone can still be wrong
|
||||
//! about the picture if the authored range never reaches it, so this measures the range, and the
|
||||
//! byte→yard slope that turns it into a distance a swimmer can feel:
|
||||
//!
|
||||
//! - the **depth-byte histogram** per kind, over vertices that touch a wet cell (dry-cell verts
|
||||
//! carry the FLT_MAX height sentinel and no meaningful byte);
|
||||
//! - the **byte→yard slope**, least-squares through the origin against the real geometric depth
|
||||
//! `surface − terrain` at each vertex, plus the correlation — which is also the check that byte 0
|
||||
//! of the water/ocean union IS a depth at all;
|
||||
//! - a handful of **shore-band pins** — world coords where the depth byte is mid-ramp, i.e. the only
|
||||
//! places the divisor is visible at all. Everything else is either the pale edge or the pinned-deep
|
||||
//! open sea, and an A/B aimed anywhere else photographs two identical pictures;
|
||||
//! - **what each candidate divisor does to the picture**: the share of wet water that lands on the
|
||||
//! deep swatch row (V ≥ 0.95), on the shallow row (V ≤ 0.05), and the mean V.
|
||||
//!
|
||||
//! Output is Blizzard-derived data — read it, pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use benilla_formats::{terrain_height_at, LiquidKind, CHUNK_SIZE};
|
||||
|
||||
/// MCLQ vertex-grid pitch: the 9×9 grid spans one 8-cell chunk.
|
||||
const UNIT: f32 = CHUNK_SIZE / 8.0;
|
||||
/// MCLQ grid side (9 verts) and cell side (8).
|
||||
const GRID: usize = 9;
|
||||
const CELLS: usize = 8;
|
||||
/// Cell-flag low nibble meaning "dry".
|
||||
const DRY_NIBBLE: u8 = 0x0f;
|
||||
/// Verts under no liquid carry FLT_MAX; `is_finite()` is true for it, so gate on magnitude.
|
||||
const HEIGHT_SENTINEL: f32 = 1.0e9;
|
||||
|
||||
/// The per-kind accumulator: the byte histogram plus the paired (byte, yard) samples.
|
||||
struct Kind {
|
||||
/// Count per raw depth byte, 0..=255.
|
||||
hist: Box<[u64; 256]>,
|
||||
/// Vertices whose terrain height resolved, for the byte→yard fit.
|
||||
paired: u64,
|
||||
/// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd — enough for a through-origin slope and an r.
|
||||
sum_by: f64,
|
||||
sum_bb: f64,
|
||||
sum_yy: f64,
|
||||
sum_b: f64,
|
||||
sum_y: f64,
|
||||
/// Blocks seen.
|
||||
blocks: u64,
|
||||
/// A spread of mid-ramp sample points: `(zone, map, x, y, byte, yards)`, at most one per
|
||||
/// 2000-yd neighbourhood, so the list names distinct coastlines instead of 200 vertices of one
|
||||
/// bay. Named by `AreaTable`, because a pin you can walk to is worth more than a coordinate.
|
||||
pins: Vec<(String, String, f32, f32, u8, f32)>,
|
||||
/// Σ yards and count per depth byte — the empirical byte→depth curve, which the single
|
||||
/// through-origin slope cannot show once a kind's byte saturates (the ocean's does, hard).
|
||||
yd_sum: Box<[f64; 256]>,
|
||||
yd_n: Box<[u64; 256]>,
|
||||
}
|
||||
|
||||
impl Default for Kind {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hist: Box::new([0; 256]),
|
||||
paired: 0,
|
||||
sum_by: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_yy: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_y: 0.0,
|
||||
blocks: 0,
|
||||
pins: Vec::new(),
|
||||
yd_sum: Box::new([0.0; 256]),
|
||||
yd_n: Box::new([0; 256]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn push(&mut self, byte: u8, yards: Option<f32>) {
|
||||
self.hist[byte as usize] += 1;
|
||||
if let Some(y) = yards {
|
||||
let (b, y) = (f64::from(byte), f64::from(y));
|
||||
self.paired += 1;
|
||||
self.yd_sum[byte as usize] += y;
|
||||
self.yd_n[byte as usize] += 1;
|
||||
self.sum_by += b * y;
|
||||
self.sum_bb += b * b;
|
||||
self.sum_yy += y * y;
|
||||
self.sum_b += b;
|
||||
self.sum_y += y;
|
||||
}
|
||||
}
|
||||
|
||||
fn total(&self) -> u64 {
|
||||
self.hist.iter().sum()
|
||||
}
|
||||
|
||||
/// Byte at the given share of the distribution.
|
||||
fn percentile(&self, p: f64) -> u8 {
|
||||
let target = (self.total() as f64 * p) as u64;
|
||||
let mut seen = 0;
|
||||
for (b, n) in self.hist.iter().enumerate() {
|
||||
seen += n;
|
||||
if seen >= target {
|
||||
return b as u8;
|
||||
}
|
||||
}
|
||||
255
|
||||
}
|
||||
|
||||
/// Least-squares slope of `byte = k · yards` through the origin, and Pearson r of the pair.
|
||||
fn slope_and_r(&self) -> Option<(f64, f64)> {
|
||||
if self.paired < 32 || self.sum_yy <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let n = self.paired as f64;
|
||||
let slope = self.sum_by / self.sum_yy;
|
||||
let cov = self.sum_by / n - (self.sum_b / n) * (self.sum_y / n);
|
||||
let vb = self.sum_bb / n - (self.sum_b / n).powi(2);
|
||||
let vy = self.sum_yy / n - (self.sum_y / n).powi(2);
|
||||
let r = if vb > 0.0 && vy > 0.0 {
|
||||
cov / (vb.sqrt() * vy.sqrt())
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Some((slope, r))
|
||||
}
|
||||
|
||||
/// Least-squares `byte = k · yards` through the origin over a byte window only. The ocean
|
||||
/// pins 83 % of its vertices at 255, which drags a whole-range fit toward the pile and hides
|
||||
/// the ramp that actually paints the shore; this reads the ramp where it varies.
|
||||
fn slope_in(&self, lo: u8, hi: u8) -> Option<(f64, u64)> {
|
||||
let (mut sby, mut syy, mut n) = (0.0, 0.0, 0u64);
|
||||
for b in lo..=hi {
|
||||
let cnt = self.yd_n[b as usize];
|
||||
if cnt == 0 {
|
||||
continue;
|
||||
}
|
||||
// Bucket mean stands for its members: Σ b·y and Σ y² over the bucket, using the mean y.
|
||||
let ybar = self.yd_sum[b as usize] / cnt as f64;
|
||||
sby += f64::from(b) * ybar * cnt as f64;
|
||||
syy += ybar * ybar * cnt as f64;
|
||||
n += cnt;
|
||||
}
|
||||
(n >= 32 && syy > 0.0).then(|| (sby / syy, n))
|
||||
}
|
||||
|
||||
/// What a divisor does to the picture: (share on the deep row, share on the shallow row, mean V).
|
||||
fn under(&self, div: f64) -> (f64, f64, f64) {
|
||||
let total = self.total().max(1) as f64;
|
||||
let (mut deep, mut shallow, mut sum) = (0.0, 0.0, 0.0);
|
||||
for (b, &n) in self.hist.iter().enumerate() {
|
||||
let v = (b as f64 / div).clamp(0.0, 1.0);
|
||||
let n = n as f64;
|
||||
if v >= 0.95 {
|
||||
deep += n;
|
||||
}
|
||||
if v <= 0.05 {
|
||||
shallow += n;
|
||||
}
|
||||
sum += v * n;
|
||||
}
|
||||
(deep / total, shallow / total, sum / total)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let maps: Vec<String> = {
|
||||
let a: Vec<String> = std::env::args().skip(1).collect();
|
||||
if a.is_empty() {
|
||||
vec!["Azeroth".into(), "Kalimdor".into()]
|
||||
} else {
|
||||
a
|
||||
}
|
||||
};
|
||||
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
|
||||
// Names for the shore-band pins. Optional: a census still reads without a DBC.
|
||||
let areas = benilla_formats::load_area_table_catalog(&mut chain).ok();
|
||||
|
||||
let mut kinds: BTreeMap<String, Kind> = BTreeMap::new();
|
||||
for map in &maps {
|
||||
let tiles = benilla_formats::MapTiles::load(&mut chain, map)?;
|
||||
// Tile (32, 32) is world (0, 0); radius 32 covers the whole 64×64 grid.
|
||||
let coords = tiles.existing_in_radius(0.0, 0.0, 32);
|
||||
eprintln!("{map}: {} tiles", coords.len());
|
||||
for (tx, ty) in coords {
|
||||
let path = format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt");
|
||||
let Ok(bytes) = chain.read_file(&path) else {
|
||||
continue;
|
||||
};
|
||||
// The mesh is only the TERRAIN reference here — the depth bytes come off the raw parse,
|
||||
// so the census can never be a restatement of the divisor it is measuring.
|
||||
let Ok(mesh) = benilla_formats::adt_to_tile_mesh(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let mut cur = std::io::Cursor::new(bytes.as_slice());
|
||||
let Ok(benilla_adt::ParsedAdt::Root(root)) = benilla_adt::parse_adt(&mut cur) else {
|
||||
continue;
|
||||
};
|
||||
for mcnk in &root.mcnk_chunks {
|
||||
let [wx, wy, _] = mcnk.header.position;
|
||||
for block in &mcnk.liquids {
|
||||
if block.vertices.len() < GRID * GRID {
|
||||
continue;
|
||||
}
|
||||
// Kind from the majority WET cell nibble — the verified type source, and the
|
||||
// same rule `build_liquid_mesh` uses, so the census bins as the renderer draws.
|
||||
let mut counts = [0u32; 16];
|
||||
let mut wet = [false; CELLS * CELLS];
|
||||
for (c, w) in wet.iter_mut().enumerate() {
|
||||
let nib = block.tile_flags[c] & 0x0f;
|
||||
if nib == DRY_NIBBLE {
|
||||
continue;
|
||||
}
|
||||
counts[nib as usize] += 1;
|
||||
*w = true;
|
||||
}
|
||||
let Some((maj, _)) = counts.iter().enumerate().max_by_key(|(_, &c)| c) else {
|
||||
continue;
|
||||
};
|
||||
if counts[maj] == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = LiquidKind::from_adt_nibble(maj as u8) else {
|
||||
continue;
|
||||
};
|
||||
// Magma spends the same union bytes on texture coords, not a depth.
|
||||
if kind == LiquidKind::Magma {
|
||||
continue;
|
||||
}
|
||||
let acc = kinds.entry(format!("{kind:?}")).or_default();
|
||||
acc.blocks += 1;
|
||||
for n in 0..GRID * GRID {
|
||||
let (row, col) = (n / GRID, n % GRID);
|
||||
// Only verts that corner a wet cell carry a real height and a real byte.
|
||||
let touches = [
|
||||
(row.wrapping_sub(1), col.wrapping_sub(1)),
|
||||
(row.wrapping_sub(1), col),
|
||||
(row, col.wrapping_sub(1)),
|
||||
(row, col),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|(r, c)| r < CELLS && c < CELLS && wet[r * CELLS + c]);
|
||||
if !touches {
|
||||
continue;
|
||||
}
|
||||
let h = block.vertices[n].height;
|
||||
if !h.is_finite() || h.abs() >= HEIGHT_SENTINEL {
|
||||
continue;
|
||||
}
|
||||
let x = wx - row as f32 * UNIT;
|
||||
let y = wy - col as f32 * UNIT;
|
||||
// Nudge off the exact lattice line so the terrain lookup lands inside a
|
||||
// triangle rather than on a chunk seam.
|
||||
let ground = terrain_height_at(&mesh.chunks, [x - 0.01, y - 0.01, 0.0]);
|
||||
let yards = ground.map(|g| h - g).filter(|d| *d > 0.0 && *d < 500.0);
|
||||
let byte = block.vertices[n].depth_byte();
|
||||
// One mid-ramp pin per tile: where the ramp is neither pinned deep nor at
|
||||
// the pale edge, which is the only band an A/B can see.
|
||||
if (40..=200).contains(&byte)
|
||||
&& acc.pins.len() < 64
|
||||
&& !acc.pins.iter().any(|p| {
|
||||
p.1 == *map && (p.2 - x).abs() < 2000.0 && (p.3 - y).abs() < 2000.0
|
||||
})
|
||||
{
|
||||
let zone = benilla_formats::area_id_at(&mesh.chunks, [x, y, 0.0])
|
||||
.and_then(|id| areas.as_ref()?.name(id).map(str::to_string))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
acc.pins.push((
|
||||
zone,
|
||||
map.clone(),
|
||||
x,
|
||||
y,
|
||||
byte,
|
||||
yards.unwrap_or(f32::NAN),
|
||||
));
|
||||
}
|
||||
acc.push(byte, yards);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n=== MCLQ depth byte, over wet-cell vertices ({}) ===",
|
||||
maps.join(" + ")
|
||||
);
|
||||
for (name, k) in &kinds {
|
||||
let total = k.total();
|
||||
println!(
|
||||
"\n{name}: {} blocks, {total} wet verts ({} paired with terrain)",
|
||||
k.blocks, k.paired
|
||||
);
|
||||
print!(" byte percentiles:");
|
||||
for p in [0.01, 0.10, 0.25, 0.50, 0.75, 0.90, 0.99] {
|
||||
print!(" p{:02}={:3}", (p * 100.0) as u32, k.percentile(p));
|
||||
}
|
||||
println!(" max={}", k.hist.iter().rposition(|&n| n > 0).unwrap_or(0));
|
||||
match k.slope_and_r() {
|
||||
Some((slope, r)) => println!(
|
||||
" byte -> yards: {slope:.2} byte/yd (r={r:.3}) => byte 42 = {:.1} yd, byte 255 = {:.1} yd",
|
||||
42.0 / slope,
|
||||
255.0 / slope
|
||||
),
|
||||
None => println!(" byte -> yards: too few paired samples"),
|
||||
}
|
||||
for div in [42.0, 255.0] {
|
||||
let (deep, shallow, mean) = k.under(div);
|
||||
println!(
|
||||
" V = byte/{div:<5.0} -> mean {mean:.3}, {:.1}% on the DEEP row (V>=.95), {:.1}% on the SHALLOW row (V<=.05)",
|
||||
deep * 100.0,
|
||||
shallow * 100.0
|
||||
);
|
||||
}
|
||||
if let Some((slope, n)) = k.slope_in(1, 250) {
|
||||
println!(
|
||||
" UNSATURATED band (byte 1..250, {n} verts): {slope:.2} byte/yd => byte 42 = {:.2} yd, byte 255 = {:.2} yd",
|
||||
42.0 / slope,
|
||||
255.0 / slope
|
||||
);
|
||||
}
|
||||
print!(" byte -> mean geometric depth (surface-terrain), yd:");
|
||||
for b in [1u8, 8, 16, 32, 42, 64, 96, 128, 160, 192, 224, 254, 255] {
|
||||
let n = k.yd_n[b as usize];
|
||||
if n >= 8 {
|
||||
print!(" {b}={:.1}", k.yd_sum[b as usize] / n as f64);
|
||||
} else {
|
||||
print!(" {b}=-");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if !k.pins.is_empty() {
|
||||
println!(" shore-band pins (byte 40..200) — where the divisor is visible:");
|
||||
for (zone, map, x, y, b, yd) in k.pins.iter().take(12) {
|
||||
println!(" {map} {x:9.1} {y:9.1} byte {b:3} depth {yd:5.1} yd {zone}");
|
||||
}
|
||||
}
|
||||
// The coarse shape, so a bimodal or pinned distribution shows without a plot.
|
||||
print!(" histogram (16 buckets of 16):");
|
||||
for b in 0..16 {
|
||||
let n: u64 = k.hist[b * 16..(b + 1) * 16].iter().sum();
|
||||
print!(" {:.1}%", n as f64 / total.max(1) as f64 * 100.0);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, VisualStages};
|
||||
use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, TrailProc, VisualStages};
|
||||
|
||||
/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields.
|
||||
type StagePick = fn(&VisualStages) -> u32;
|
||||
@@ -33,6 +33,7 @@ fn type_name(ty: i32) -> &'static str {
|
||||
char_proc_type::ANIM_RATE => "ANIM_RATE (body anim playback rate — 0 freezes the pose)",
|
||||
char_proc_type::CHAIN_CHANNEL => "CHAIN (beam; the channel-stage key — 0955)",
|
||||
char_proc_type::CHAIN_CAST => "CHAIN (beam; the cast-stage key — 0955)",
|
||||
char_proc_type::WEAPON_TRAIL => "TRAIL (weapon swing ribbon, $WTB→$WTT)",
|
||||
_ => "(unmodelled)",
|
||||
}
|
||||
}
|
||||
@@ -118,6 +119,62 @@ pub fn run(chain: &mut Chain) -> Result<()> {
|
||||
type_name(*ty)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. The TRAIL set in full. Its shape is unlike the others': the proc carries no model and no
|
||||
// emitter, so a kit whose slot list is EMPTY still has a visual — and 18 of these are exactly
|
||||
// that, which is the single fact that decides whether the proc is worth building. The census
|
||||
// prints the slot count beside every row so that number stays checkable rather than quoted.
|
||||
let trail_kits: BTreeMap<u32, TrailProc> = visuals
|
||||
.kit_ids()
|
||||
.filter_map(|id| Some((id, visuals.kit(id)?.trail_proc()?)))
|
||||
.collect();
|
||||
let mut reach: BTreeMap<u32, BTreeSet<(&str, u32, String)>> = BTreeMap::new();
|
||||
for (spell_id, display) in spells.iter() {
|
||||
let Some(stages) = visuals.stages(display.visual) else {
|
||||
continue;
|
||||
};
|
||||
for (label, pick) in STAGES {
|
||||
let kit_id = pick(stages);
|
||||
if trail_kits.contains_key(&kit_id) {
|
||||
reach
|
||||
.entry(kit_id)
|
||||
.or_default()
|
||||
.insert((label, spell_id, display.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\nWEAPON-TRAIL kits ({} carry the proc, {} reached from a live spell, {} with NO emitter \
|
||||
slot at all):",
|
||||
trail_kits.len(),
|
||||
reach.len(),
|
||||
trail_kits
|
||||
.keys()
|
||||
.filter(|k| reach.contains_key(k))
|
||||
.filter(|k| visuals.kit(**k).is_some_and(|v| v.effects().count() == 0))
|
||||
.count(),
|
||||
);
|
||||
for (kit_id, trail) in &trail_kits {
|
||||
let kit = visuals.kit(*kit_id);
|
||||
let slots = kit.map_or(0, |k| k.effects().count());
|
||||
let anim = kit.and_then(|k| k.anim_id);
|
||||
let [r, g, b] = trail.rgb();
|
||||
let spells = reach.get(kit_id);
|
||||
println!(
|
||||
" kit {kit_id:<5} anim {:<6} slots {slots} #{r:02x}{g:02x}{b:02x} a{:<4} {:>6} ms \
|
||||
{} spell(s)",
|
||||
anim.map_or("-".to_string(), |a| a.to_string()),
|
||||
trail.alpha(),
|
||||
trail.duration_ms,
|
||||
spells.map_or(0, BTreeSet::len),
|
||||
);
|
||||
for (label, spell_id, name) in spells.into_iter().flatten().take(6) {
|
||||
println!(" {label:8} {spell_id:>6} {name}");
|
||||
}
|
||||
if spells.map_or(0, BTreeSet::len) > 6 {
|
||||
println!(" … and {} more", spells.map_or(0, BTreeSet::len) - 6);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX
|
||||
mod spell_visual;
|
||||
pub use spell_visual::{
|
||||
char_proc_small_int, char_proc_type, load_spell_visual_catalog, ChainEffect, ChainProc,
|
||||
CharProc, SpellVisualCatalog, VisualKit, VisualStages, CHAIN_MAX_BEAMS, KIT_CHAR_PROCS,
|
||||
KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG,
|
||||
CharProc, SpellVisualCatalog, TrailProc, VisualKit, VisualStages, CHAIN_MAX_BEAMS,
|
||||
KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG,
|
||||
};
|
||||
mod emit_timing;
|
||||
pub use emit_timing::{EmitParams, EmitTiming, ParamsNow};
|
||||
|
||||
@@ -162,8 +162,9 @@ pub struct LiquidMesh {
|
||||
/// rather than anything the client generates ([`benilla_adt::LiquidVertex::texcoords`]).
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
/// Per-vertex liquid swatch coord **V** `0..1` from the MCLQ `SVert` depth byte, parallel to
|
||||
/// `positions`. River/lake = `clamp(byte/42)` (VERIFIED `c81768`, saturates ~5 yd); ocean = byte/255
|
||||
/// (placeholder — ocean uses a different non-LUT path, see `build_liquid_mesh`). The reference uses
|
||||
/// `positions`. River/lake = `clamp(byte/42)` (VERIFIED `c81768`, saturates ~5 yd); ocean =
|
||||
/// `clamp(byte/255)` (VERIFIED `c7fcd8`, saturates ~148 yd — its own LUT, on its own authored
|
||||
/// byte scale; see [`OCEAN_DEPTH_V_SATURATION`]). The reference uses
|
||||
/// this single V to index the depth swatch for BOTH the body-colour band (shallow→deep water rows)
|
||||
/// and the opacity ramp (`colorTex.a` = `127+2·row`, deeper = more opaque).
|
||||
///
|
||||
@@ -216,6 +217,49 @@ const HEIGHT_SENTINEL: f32 = 1.0e9;
|
||||
/// `FUN_0068d690` that the from-above river path does not use — hence the river middle never reached
|
||||
/// the deep/teal swatch row until this. The river depth ramp is ≈8.5 byte/yd, so V saturates at ≈5 yd.)
|
||||
const RIVER_DEPTH_V_SATURATION: f32 = 42.0;
|
||||
/// Ocean depth-byte at which the swatch coord `V` saturates: `V = clamp(byte/255)`.
|
||||
/// VERIFIED — the ocean has its **own** LUT, not a missing one. `FUN_0068c4c0` builds
|
||||
/// `DAT_00c7fcd8[i] = min(i/255, 1.0)` (256 f32 entries, `fstp` @`0x68c57c`) beside the river's
|
||||
/// `c81768`, and the ocean vert-fill reads it at `0x68d718 fld [depth*4 + 0xc7fcd8]` exactly as the
|
||||
/// river fill reads its own at `0x68d818` — same shape, same `tc0 = (0.5, ramp[depthByte])`, only a
|
||||
/// different divisor (wow-re `terrain/scratch/rf81-liquid-color-lut-builder.md`, a §5 pair that
|
||||
/// converged byte-identically, corroborated independently by `water-shading-law.md` §5).
|
||||
///
|
||||
/// **The two divisors are not comparable, because the two depth bytes are not on one scale** — which
|
||||
/// is what made `/255` look 6× too slow beside the river's `/42`. Measured over every MCLQ block in
|
||||
/// Azeroth + Kalimdor (`examples/liquid_depth_census`, 17.4 M ocean and 0.55 M river wet vertices,
|
||||
/// each byte paired against its own geometric depth `surface − terrain`):
|
||||
///
|
||||
/// | | authored ramp | byte 255 is | V saturates at |
|
||||
/// |---|---|---|---|
|
||||
/// | river/lake | 8.96 byte/yd | 28 yd | byte 42 ≈ **4.7 yd** |
|
||||
/// | ocean | 1.72 byte/yd | 148 yd | byte 255 ≈ **148 yd** |
|
||||
///
|
||||
/// The artists authored the sea's byte **5.2× gentler per yard**, so the 6× gentler LUT lands the two
|
||||
/// ramps within ~15 % of each other in *yards of depth per unit of V*. `/42` on the ocean would be
|
||||
/// the correction applied twice. It is also not a subtle difference: **83.4 % of all ocean wet
|
||||
/// vertices are pinned at byte 255** — the open sea is authored fully deep, and the whole ramp is a
|
||||
/// shore band. (The river control reproduces its own independently-verified ≈8.5 byte/yd, which is
|
||||
/// what says the census measures what it claims to.)
|
||||
const OCEAN_DEPTH_V_SATURATION: f32 = 255.0;
|
||||
|
||||
/// `WOW_OCEAN_DEPTH_DIV=<divisor>` — override [`OCEAN_DEPTH_V_SATURATION`] for one run.
|
||||
///
|
||||
/// The ocean depth ramp is 97 % of the water in the world and its whole visible extent is the shore
|
||||
/// band, so "is this the right curve" is a question about a beach, and a **look** question — the
|
||||
/// director's to call, not ours to grade from a screenshot. This is the A/B: `WOW_OCEAN_DEPTH_DIV=42`
|
||||
/// puts the river's ramp on the sea (deep by ~24 yd of depth instead of ~148), so the two can be run
|
||||
/// side by side against `./run-ref-client.sh` at the same shoreline. Read once, at first use.
|
||||
fn ocean_depth_v_divisor() -> f32 {
|
||||
static DIV: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
|
||||
*DIV.get_or_init(|| {
|
||||
std::env::var("WOW_OCEAN_DEPTH_DIV")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.filter(|d| *d > 0.0)
|
||||
.unwrap_or(OCEAN_DEPTH_V_SATURATION)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a flat liquid surface mesh from one parsed MCLQ **block** at MCNK origin `position` (the
|
||||
/// chunk header's raw WoW `[x, y, z]`). A chunk can hold more than one block — see [`MclqChunk`].
|
||||
@@ -277,12 +321,13 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<
|
||||
.unwrap_or(0);
|
||||
let kind = LiquidKind::from_adt_nibble(sound_nibble)?;
|
||||
|
||||
// Depth-byte → swatch coord `V` divisor (VERIFIED). River/lake (the `c81768`/`FUN_0068d790` path)
|
||||
// saturates at byte 42 = ~5 yd, so the channel middle reaches the deep/teal swatch row. Ocean uses
|
||||
// a DIFFERENT mechanism (`FUN_0068d890` reads explicit per-vertex MCLQ UVs, not a depth LUT) — left
|
||||
// at /255 here pending its own RE + an in-game A/B; an ocean A/B has not been done since this fix.
|
||||
// Depth-byte → swatch coord `V` divisor. BOTH are VERIFIED, and they are two LUTs built side by
|
||||
// side in `FUN_0068c4c0`, not one law and one stand-in: river/lake `c81768` = `min(d/42, 1)` read
|
||||
// by the river fill `FUN_0068d790`, ocean `c7fcd8` = `min(d/255, 1)` read by the ocean fill
|
||||
// `FUN_0068d690`. They differ because the two authored depth bytes are on different scales — see
|
||||
// [`OCEAN_DEPTH_V_SATURATION`] for the census that measures both.
|
||||
let depth_v_div = match kind {
|
||||
LiquidKind::Ocean => 255.0,
|
||||
LiquidKind::Ocean => ocean_depth_v_divisor(),
|
||||
_ => RIVER_DEPTH_V_SATURATION,
|
||||
};
|
||||
// Magma reads its UVs off the vertex and its depth off nothing at all (see `LiquidMesh::uvs` /
|
||||
@@ -607,6 +652,106 @@ mod tests {
|
||||
|
||||
/// Golden test against a real Elwynn tile: Crystal Lake area `Azeroth_32_48.adt` has 42 MCLQ
|
||||
/// water chunks (all river/still, surface ≈ 143.99 yd). Skips when the client isn't present.
|
||||
/// Every liquid mesh a real tile builds, **paired with the raw MCLQ block it came from** — so a
|
||||
/// depth assertion can be made against the bytes on disk rather than against the mesh's own
|
||||
/// arithmetic run backwards.
|
||||
fn tile_liquids_with_source(
|
||||
chain: &mut crate::Chain,
|
||||
map: &str,
|
||||
tx: u32,
|
||||
ty: u32,
|
||||
) -> Vec<(benilla_adt::MclqChunk, LiquidMesh)> {
|
||||
let bytes = chain
|
||||
.read_file(&format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt"))
|
||||
.expect("read adt");
|
||||
let ParsedAdt::Root(root) =
|
||||
parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt");
|
||||
root.mcnk_chunks
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
m.liquids.iter().filter_map(|lq| {
|
||||
build_liquid_mesh(lq, m.header.position).map(|mesh| (lq.clone(), mesh))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **The two depth divisors are two verified LUTs, not one law and one stand-in.** The ocean's
|
||||
/// `/255` spent months labelled a placeholder on a misattributed address (`0x68d890` — the
|
||||
/// *magma* vert-fill), so this pins both against real shipped data: an ocean tile off Baradin
|
||||
/// Bay whose depth bytes run the full ramp, and Elwynn's rivers as the control.
|
||||
///
|
||||
/// The V assertions read the **raw MCLQ depth byte**, never a byte recovered from the mesh's own
|
||||
/// V (that would assert the arithmetic against itself and pass under any divisor). The shore-band
|
||||
/// assertion is the one with teeth: the sea's byte is authored ~5× gentler per yard than a
|
||||
/// river's, so putting the river's `/42` on the ocean saturates a shore band that should still
|
||||
/// be ramping — which is exactly the look change this ramp is not.
|
||||
#[test]
|
||||
fn each_liquid_kind_rides_its_own_verified_depth_divisor() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
|
||||
// Baradin Bay: shelf water whose bytes span the ocean ramp rather than pinning at 255.
|
||||
let ocean: Vec<(benilla_adt::MclqChunk, LiquidMesh)> =
|
||||
tile_liquids_with_source(&mut chain, "Azeroth", 41, 41)
|
||||
.into_iter()
|
||||
.filter(|(_, m)| m.kind == LiquidKind::Ocean)
|
||||
.collect();
|
||||
assert!(!ocean.is_empty(), "Azeroth_41_41 carries ocean");
|
||||
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
for (src, mesh) in &ocean {
|
||||
for (n, v) in mesh.depths.iter().enumerate() {
|
||||
let byte = src.vertices[n].depth_byte();
|
||||
raw.push(byte);
|
||||
assert!(
|
||||
(*v - f32::from(byte) / OCEAN_DEPTH_V_SATURATION).abs() < 1e-4,
|
||||
"ocean V is the raw byte over 255 (VERIFIED `c7fcd8`); byte {byte} gave V={v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The shore band exists and is NOT saturated — the whole point of the gentler divisor. Under
|
||||
// `/42` every one of these mid-band verts would already read the deep row.
|
||||
let mid = raw.iter().filter(|&&b| (42..=200).contains(&b)).count();
|
||||
let pinned = raw.iter().filter(|&&b| b == u8::MAX).count();
|
||||
assert!(
|
||||
mid > 100,
|
||||
"this tile must carry a real shore ramp to test, saw {mid} mid-band verts"
|
||||
);
|
||||
assert!(
|
||||
mid * 4 > pinned,
|
||||
"the `/42` divisor would flatten a shore band this tile spends {mid} verts ramping \
|
||||
through (against {pinned} genuinely pinned-deep) — the look change the ocean ramp is not"
|
||||
);
|
||||
|
||||
// Control: rivers/lakes DO ride `/42`, and Elwynn's channel middles reach the deep row.
|
||||
let river: Vec<(benilla_adt::MclqChunk, LiquidMesh)> =
|
||||
tile_liquids_with_source(&mut chain, "Azeroth", 32, 48)
|
||||
.into_iter()
|
||||
.filter(|(_, m)| m.kind == LiquidKind::Still)
|
||||
.collect();
|
||||
assert!(!river.is_empty(), "Azeroth_32_48 carries river/lake water");
|
||||
let mut saturated_below_255 = false;
|
||||
for (src, mesh) in &river {
|
||||
for (n, v) in mesh.depths.iter().enumerate() {
|
||||
let byte = src.vertices[n].depth_byte();
|
||||
let want = (f32::from(byte) / RIVER_DEPTH_V_SATURATION).clamp(0.0, 1.0);
|
||||
assert!(
|
||||
(*v - want).abs() < 1e-4,
|
||||
"river V is clamp(byte/42) (VERIFIED `c81768`); byte {byte} gave V={v}"
|
||||
);
|
||||
// The teeth on the river side: a byte well under 255 already reads fully deep,
|
||||
// which `/255` could never produce.
|
||||
saturated_below_255 |= (42..200).contains(&byte) && *v >= 0.999;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saturated_below_255,
|
||||
"a river channel middle saturates to the deep row by byte 42, not byte 255"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_elwynn_lake_tile() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
|
||||
@@ -158,6 +158,39 @@ impl CharProc {
|
||||
char_proc_small_int(self.params[i])
|
||||
}
|
||||
|
||||
/// This slot decoded as the **weapon swing trail** proc, or `None` when it is not one.
|
||||
///
|
||||
/// The dispatcher's type-8 arm (`0x60d80a`–`0x60d83b`) is three `_ftol` calls and two stores,
|
||||
/// and it is the one proc that uses **plain truncation** rather than
|
||||
/// [`char_proc_small_int`] — so this decode must not borrow that idiom:
|
||||
///
|
||||
/// ```text
|
||||
/// 60d80a fld [edi-0x20] ; call 0x40a2b0 ; ftol(CharParamZero)
|
||||
/// 60d812 fld [edi+0x10] ; call 0x40a2b0 ; ftol(CharParamThree)
|
||||
/// 60d823 shl eax,0x18 ; or ecx,eax
|
||||
/// 60d828 mov [esi+0xd1c],ecx ; unit+0xd1c = zero | (three << 24)
|
||||
/// 60d82e fld [edi] ; call 0x40a2b0 ; ftol(CharParamTwo)
|
||||
/// 60d835 mov [esi+0xd20],eax ; unit+0xd20 = two
|
||||
/// ```
|
||||
///
|
||||
/// `CharParamOne` is **not read by this arm** — stated because every shipped type-8 row
|
||||
/// carries `20.0` there and a reader will look for its use (wow-re `charproc8-weapon-trail.md`
|
||||
/// §1).
|
||||
///
|
||||
/// `None` for a zero duration: `0x5fe494 cmp eax,edi` / `je 0x5fe4b7` is the reference's own
|
||||
/// test, and a zero-duration arm fires nothing.
|
||||
pub fn as_weapon_trail(&self) -> Option<TrailProc> {
|
||||
if self.ty != char_proc_type::WEAPON_TRAIL {
|
||||
return None;
|
||||
}
|
||||
let ftol = |f: f32| f.trunc() as i32 as u32;
|
||||
let duration_ms = ftol(self.params[2]);
|
||||
(duration_ms != 0).then(|| TrailProc {
|
||||
packed: ftol(self.params[0]) | (ftol(self.params[3]) << 24),
|
||||
duration_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// This slot decoded as a chain/beam proc, or `None` when it is not one — either the type key
|
||||
/// is not a chain key, or it is but `CharParamZero` decodes to `0`, which names no
|
||||
/// `SpellChainEffects` row and is how the shipped table writes an unused slot (`chain`'s
|
||||
@@ -188,6 +221,40 @@ pub fn char_proc_small_int(param: f32) -> u32 {
|
||||
(param + 512.0).to_bits() >> 14 & 0xff
|
||||
}
|
||||
|
||||
/// A [`char_proc_type::WEAPON_TRAIL`] slot decoded — the two words the dispatcher latches onto the
|
||||
/// unit (`unit+0xd1c` / `unit+0xd20`) and the trail object copies into its `SWING` record
|
||||
/// (`+0x608` / `+0x60c`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrailProc {
|
||||
/// `ftol(CharParamZero) | (ftol(CharParamThree) << 24)` — a packed **`0xAARRGGBB`**, kept as
|
||||
/// the one dword the reference actually stores. Every shipped `CharParamZero` is a
|
||||
/// `0x00RRGGBB` under `2^24`, so the OR never collides; read the channels through [`Self::rgb`]
|
||||
/// and [`Self::alpha`] rather than re-deriving the shifts.
|
||||
pub packed: u32,
|
||||
/// `ftol(CharParamTwo)` — how long the trail keeps **appending**, in milliseconds. Never `0`
|
||||
/// (that is the reference's own no-fire test, so it decodes to `None`). 600 on the ordinary
|
||||
/// melee kits, 1000 on Charge, **10000** on Whirlwind's whole spin.
|
||||
pub duration_ms: u32,
|
||||
}
|
||||
|
||||
impl TrailProc {
|
||||
/// The trail's colour, `[R, G, B]` — kit 324's `0xf82929` is `[248, 41, 41]`.
|
||||
pub fn rgb(&self) -> [u8; 3] {
|
||||
[
|
||||
(self.packed >> 16) as u8,
|
||||
(self.packed >> 8) as u8,
|
||||
self.packed as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// The trail's starting alpha, `0..=255`. `100` on every shipped row (≈ 39 %) — and it is a
|
||||
/// *clock* as much as an opacity: the fade rate, the retained segment count and the
|
||||
/// termination test are all `alpha`-derived (wow-re `charproc8-weapon-trail.md` §8).
|
||||
pub fn alpha(&self) -> u8 {
|
||||
(self.packed >> 24) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CharProc` type keys benilla models, by name. The full key space is the dispatcher's 9 cases
|
||||
/// (`0x60d7c0`); these are the ones a live 5875 **state** kit uses — every other key is either
|
||||
/// cast-stage-only or unused by the shipped table (see `charprocs` in `benilla-extract`).
|
||||
@@ -221,6 +288,20 @@ pub mod char_proc_type {
|
||||
/// column; it is passed through, not special-cased.
|
||||
pub const ANIM_RATE: i32 = 11;
|
||||
|
||||
/// **Weapon SWING TRAIL** (`0x60d80a`) — the ribbon a melee ability smears between its
|
||||
/// weapon model's `$WTB` and `$WTT` event markers. The arm is a **latch on the unit**, not a
|
||||
/// same-call effect: the proc writes `unit+0xd1c` (the packed colour) and `unit+0xd20` (the
|
||||
/// duration in ms), and the unit's **next** `PlayAnimation` (`0x5fe2f0`, the field's sole
|
||||
/// reader at `0x5fe48e`) hands both to each of its two weapon-hand trail objects and clears
|
||||
/// the duration — so it fires exactly once per arm. See [`CharProc::as_weapon_trail`] and
|
||||
/// wow-re `charproc8-weapon-trail.md`.
|
||||
///
|
||||
/// 34 of the 1772 kits carry it and for **18 of them it is the entire visual** — kit 324
|
||||
/// (Heroic Strike / Overpower / Mortal Strike / Bloodthirst / Rend, 218 spells), 3050
|
||||
/// (Hamstring), 557 (Sunder Armor) name no emitter slot at all, so a client without this proc
|
||||
/// renders nothing for them beyond the swing animation.
|
||||
pub const WEAPON_TRAIL: i32 = 8;
|
||||
|
||||
/// **Chain / beam visual**, the key the shipped table uses on **channel**-stage kits — Drain
|
||||
/// Life's rope, Mind Flay's mana beam, Health Funnel, C'Thun's eye beam. Both this and
|
||||
/// [`CHAIN_CAST`] land on the same dispatcher case (`0x60da79`); the name records the data's
|
||||
@@ -390,6 +471,13 @@ impl VisualKit {
|
||||
self.char_procs().find(|p| p.ty == ty).map(|p| p.params[0])
|
||||
}
|
||||
|
||||
/// This kit's weapon-trail proc — the **first** slot that decodes to one, matching the
|
||||
/// dispatcher's in-order walk (no shipped kit carries two type-8 slots). `None` when the kit
|
||||
/// arms no trail. See [`char_proc_type::WEAPON_TRAIL`].
|
||||
pub fn trail_proc(&self) -> Option<TrailProc> {
|
||||
self.char_procs().find_map(|p| p.as_weapon_trail())
|
||||
}
|
||||
|
||||
/// This kit's chain/beam proc — the **first** slot that decodes to one, matching the client's
|
||||
/// dispatcher walk (it runs every slot in order; no shipped kit carries two). `None` when the
|
||||
/// kit draws no beam. See [`chain_effects`] for the mechanism (decision 0955).
|
||||
|
||||
@@ -784,3 +784,56 @@ fn real_hunter_shots_take_the_bows_load_and_release_clips() {
|
||||
assert_eq!(cat.kit(7).and_then(|k| k.anim_id), Some(105), "LoadBow");
|
||||
assert_eq!(cat.kit(164).and_then(|k| k.anim_id), Some(46), "AttackBow");
|
||||
}
|
||||
|
||||
/// The type-8 arm's decode is **plain truncation** (`_ftol` at `0x40a2b0`), not the small-int
|
||||
/// idiom every other integer-carrying param uses — a slot decoded the wrong way yields a colour
|
||||
/// of 0 and a duration of 0, i.e. silently no trail at all.
|
||||
#[test]
|
||||
fn a_weapon_trail_proc_decodes_by_truncation() {
|
||||
// Kit 324's shipped row: Zero = 16263465 (`0xf82929`), One = 20, Two = 600, Three = 100.
|
||||
let proc = CharProc {
|
||||
ty: char_proc_type::WEAPON_TRAIL,
|
||||
params: [16_263_465.0, 20.0, 600.0, 100.0],
|
||||
};
|
||||
let trail = proc.as_weapon_trail().expect("a live trail proc");
|
||||
assert_eq!(trail.rgb(), [0xf8, 0x29, 0x29], "R248 G41 B41");
|
||||
assert_eq!(trail.alpha(), 100);
|
||||
assert_eq!(trail.duration_ms, 600);
|
||||
assert_eq!(
|
||||
trail.packed, 0x64f8_2929,
|
||||
"0xAARRGGBB, as `unit+0xd1c` holds it"
|
||||
);
|
||||
assert_ne!(
|
||||
proc.small_int(0),
|
||||
u32::from(trail.rgb()[0]),
|
||||
"the small-int decode is the WRONG idiom here and must not be reused"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x5fe494 cmp eax,edi ; je` — a zero duration fires nothing, so it is not a trail.
|
||||
#[test]
|
||||
fn a_zero_duration_trail_proc_is_no_trail() {
|
||||
let proc = CharProc {
|
||||
ty: char_proc_type::WEAPON_TRAIL,
|
||||
params: [16_263_465.0, 20.0, 0.0, 100.0],
|
||||
};
|
||||
assert!(proc.as_weapon_trail().is_none());
|
||||
}
|
||||
|
||||
/// Only type 8 reaches the arm — `VisualKit::trail_proc` must not answer for a tint or an alpha
|
||||
/// proc whose `params[2]` happens to be nonzero.
|
||||
#[test]
|
||||
fn only_type_eight_arms_a_trail() {
|
||||
for ty in [
|
||||
char_proc_type::TINT,
|
||||
char_proc_type::ALPHA,
|
||||
char_proc_type::ANIM_RATE,
|
||||
char_proc_type::CHAIN_CAST,
|
||||
] {
|
||||
let proc = CharProc {
|
||||
ty,
|
||||
params: [16_263_465.0, 20.0, 600.0, 100.0],
|
||||
};
|
||||
assert!(proc.as_weapon_trail().is_none(), "type {ty}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Live probe: what a **refused character login** actually looks like on the wire, and what it
|
||||
//! leaves behind.
|
||||
//!
|
||||
//! benilla used to send `CMSG_PLAYER_LOGIN` and declare itself in the world in the same breath, so
|
||||
//! `SMSG_CHARACTER_LOGIN_FAILED` (0x41) reached nothing — a refusal left the client on a loading
|
||||
//! screen that could never clear. The fix rests on three facts about the server, and this pins all
|
||||
//! three against the local vmangos:
|
||||
//!
|
||||
//! 1. the refusal **arrives**, with its result byte — vmangos sends a bare `1`
|
||||
//! (`WorldSession::HandlePlayerLoginOpcode`'s `loginFailedPacket->result = 1`), which the
|
||||
//! reference's table reads as `CHAR_LOGIN_NO_WORLD`, "World server is down";
|
||||
//! 2. the **session survives** it — still `STATUS_AUTHED`, still able to serve `CMSG_CHAR_ENUM`;
|
||||
//! 3. a **second, valid pick still works** on the same socket, so a refusal costs the player a
|
||||
//! click and not a reconnect.
|
||||
//!
|
||||
//! The refusal is provoked by the one guard that needs no server state: `!packet.guid.IsPlayer()`.
|
||||
//! A creature-typed guid is refused immediately and touches nothing else.
|
||||
//!
|
||||
//! Needs the local vmangos up; account `two`/`ptwo` (the account-X/password-pX convention).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use benilla_protocol::{logon, messages::ServerPacket, WorldSession, WORLD_PORT};
|
||||
|
||||
/// A guid the server cannot read as a player: vmangos's `ObjectGuid::IsPlayer()` tests the high
|
||||
/// type nibble, and `0xF130…` is `HIGHGUID_UNIT`. Nothing on the server is looked up by it — the
|
||||
/// guard rejects before any lookup — so the probe cannot disturb a real character.
|
||||
const NOT_A_PLAYER: u64 = 0xF130_0000_0000_0001;
|
||||
|
||||
fn connect(user: &str, pass: &str) -> anyhow::Result<WorldSession> {
|
||||
let l = logon("localhost", user, pass)?;
|
||||
let addr = l
|
||||
.realms
|
||||
.first()
|
||||
.map(|r| r.address.clone())
|
||||
.unwrap_or_else(|| format!("localhost:{WORLD_PORT}"));
|
||||
WorldSession::connect(&addr, user, l.session_key)
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let (user, pass) = ("two", "ptwo");
|
||||
let mut s = connect(user, pass)?;
|
||||
let chars = s.char_enum()?;
|
||||
println!(
|
||||
"roster: {:?}",
|
||||
chars.iter().map(|c| &c.name).collect::<Vec<_>>()
|
||||
);
|
||||
let target = chars.first().expect("the probe account has a character");
|
||||
|
||||
// ── 1 · the refusal, and its byte ──
|
||||
s.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
s.player_login(NOT_A_PLAYER)?;
|
||||
let mut refusal = None;
|
||||
for _ in 0..64 {
|
||||
match s.recv()? {
|
||||
ServerPacket::CharacterLoginFailed { result } => {
|
||||
refusal = Some(result);
|
||||
break;
|
||||
}
|
||||
other => println!(" (skipping {} while waiting)", other.name()),
|
||||
}
|
||||
}
|
||||
match refusal {
|
||||
Some(result) => println!("PASS: SMSG_CHARACTER_LOGIN_FAILED result = {result:#04x}"),
|
||||
None => anyhow::bail!("FAIL: no SMSG_CHARACTER_LOGIN_FAILED for a non-player guid"),
|
||||
}
|
||||
|
||||
// ── 2 · the session survives it ──
|
||||
let again = s.char_enum()?;
|
||||
println!(
|
||||
"PASS: post-refusal roster still serves {} char(s)",
|
||||
again.len()
|
||||
);
|
||||
|
||||
// ── 3 · a valid pick still works on the same socket ──
|
||||
s.player_login(target.guid)?;
|
||||
s.set_active_mover(target.guid)?;
|
||||
let mut entered = false;
|
||||
for _ in 0..64 {
|
||||
if let ServerPacket::LoginVerifyWorld { map, .. } = s.recv()? {
|
||||
println!("PASS: retry entered the world (SMSG_LOGIN_VERIFY_WORLD, map {map})");
|
||||
entered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !entered {
|
||||
anyhow::bail!("FAIL: the retry never reached SMSG_LOGIN_VERIFY_WORLD");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,6 +219,21 @@ pub enum SessionEvent {
|
||||
/// (decision 1976); `None` when it will arrive in the world stream instead.
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
},
|
||||
/// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — we are not
|
||||
/// in the world and never were, whatever [`Self::Connected`] said a moment ago.
|
||||
///
|
||||
/// It arrives on the world stream, *after* the IO thread has optimistically announced the
|
||||
/// connection (the pick is sent and the entry begins in the same breath, decision 0777 — the
|
||||
/// destination tiles start streaming a whole round-trip before the server's snap). So this is
|
||||
/// an entry being *revoked*, and the app answers it the way it answers a logout: tear the
|
||||
/// half-built entry down, go back to character select, and say why. The IO thread cycles the
|
||||
/// connection behind that, and a fresh [`Self::CharacterList`] follows.
|
||||
///
|
||||
/// `result` is the server's raw byte — a **1-based reason index** the reference maps through a
|
||||
/// six-entry jump table onto its `CHAR_LOGIN_*` strings. Mapping it to something a player can
|
||||
/// read is the glue layer's job ([`crate::messages::ServerPacket::CharacterLoginFailed`] has
|
||||
/// the wire law).
|
||||
CharacterLoginFailed { result: u8 },
|
||||
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select.
|
||||
/// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows.
|
||||
LoggedOut,
|
||||
|
||||
@@ -58,6 +58,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
characters,
|
||||
realm: None,
|
||||
}],
|
||||
ServerPacket::CharacterLoginFailed { result } => {
|
||||
vec![SessionEvent::CharacterLoginFailed { result }]
|
||||
}
|
||||
ServerPacket::LogoutComplete => vec![SessionEvent::LoggedOut],
|
||||
ServerPacket::LogoutResponse { reason, instant } => {
|
||||
vec![SessionEvent::LogoutResponse { reason, instant }]
|
||||
|
||||
@@ -9,6 +9,9 @@ pub const SMSG_CHAR_ENUM: u16 = 0x003B;
|
||||
// SMSG body the result byte (`CHAR_DELETE_SUCCESS` = 0x39, `SharedDefines.h` ResponseCodes).
|
||||
pub const CMSG_CHAR_DELETE: u16 = 0x0038;
|
||||
pub const SMSG_CHAR_DELETE: u16 = 0x003C;
|
||||
/// `SMSG_CHARACTER_LOGIN_FAILED` (VERIFIED vmangos `Opcodes_1_12_1.h`: 65) — the server's refusal
|
||||
/// of a `CMSG_PLAYER_LOGIN`. Body: one result byte.
|
||||
pub const SMSG_CHARACTER_LOGIN_FAILED: u16 = 0x0041;
|
||||
pub const SMSG_NAME_QUERY_RESPONSE: u16 = 0x0051; // 81
|
||||
pub const SMSG_CREATURE_QUERY_RESPONSE: u16 = 0x0061; // 97
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:86`: 83. Answers `CMSG_PET_NAME_QUERY` — the only query that
|
||||
|
||||
@@ -104,6 +104,21 @@ pub enum ServerPacket {
|
||||
CharDelete {
|
||||
result: u8,
|
||||
},
|
||||
/// `SMSG_CHARACTER_LOGIN_FAILED` — the server refused the `CMSG_PLAYER_LOGIN` we just sent.
|
||||
///
|
||||
/// The byte is a **1-based reason index**, not a `ResponseCodes` value: the client feeds it to
|
||||
/// a six-entry jump table (`0x5aae08`, behind `dec eax; cmp eax,5; ja`) that maps it onto the
|
||||
/// `CHAR_LOGIN_*` status codes, so `1` is "world server is down" and everything past `6` is
|
||||
/// the default "login failed" (VERIFIED off `WoW.exe` — wow-5875-re
|
||||
/// `system/net/scratch/char-login-failed-law.md`). Both emulators speak that dialect: vmangos
|
||||
/// sends a bare `1`, and mangos-classic's live enum is `CharLoginFailReasons` 0x01–0x08 (its
|
||||
/// `ResponseCodes` `CHAR_LOGIN_*` block is commented out).
|
||||
///
|
||||
/// Kept raw here all the same — turning it into a player-facing string is the glue layer's
|
||||
/// job, and it is the only place that holds the reference's table.
|
||||
CharacterLoginFailed {
|
||||
result: u8,
|
||||
},
|
||||
UpdateObject {
|
||||
objects: Vec<Object>,
|
||||
},
|
||||
@@ -1588,6 +1603,7 @@ impl ServerPacket {
|
||||
ServerPacket::CharEnum { .. } => "SMSG_CHAR_ENUM".into(),
|
||||
ServerPacket::CharCreate { .. } => "SMSG_CHAR_CREATE".into(),
|
||||
ServerPacket::CharDelete { .. } => "SMSG_CHAR_DELETE".into(),
|
||||
ServerPacket::CharacterLoginFailed { .. } => "SMSG_CHARACTER_LOGIN_FAILED".into(),
|
||||
ServerPacket::UpdateObject { .. } => "SMSG_UPDATE_OBJECT".into(),
|
||||
ServerPacket::CompressedMoves { .. } => "SMSG_COMPRESSED_MOVES".into(),
|
||||
ServerPacket::DestroyObject { .. } => "SMSG_DESTROY_OBJECT".into(),
|
||||
|
||||
@@ -243,6 +243,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_CHAR_CREATE => ServerPacket::CharCreate {
|
||||
result: read_u8(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_CHARACTER_LOGIN_FAILED => ServerPacket::CharacterLoginFailed {
|
||||
result: read_u8(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_UPDATE_OBJECT => ServerPacket::UpdateObject {
|
||||
objects: update_object::read_update_object(&mut r)?,
|
||||
},
|
||||
|
||||
@@ -140,3 +140,31 @@ fn logout_complete_decodes_to_logged_out() {
|
||||
let p = messages::parse_server(messages::opcode::SMSG_LOGOUT_COMPLETE, &[]).unwrap();
|
||||
assert!(matches!(decode(p)[..], [SessionEvent::LoggedOut]));
|
||||
}
|
||||
|
||||
/// The **refused** pick (`SMSG_CHARACTER_LOGIN_FAILED`, opcode 65): one result byte, straight
|
||||
/// through to the event the glue layer answers.
|
||||
///
|
||||
/// It used to parse into nothing at all — the opcode had a name and no arm — so a server that
|
||||
/// refused a character login said it to a client that could not hear it, and the entry the IO
|
||||
/// thread had already announced simply never finished. The byte travels **raw**: it is a 1-based
|
||||
/// reason index whose meaning is a table in the reference's glue layer, and the wire has no
|
||||
/// business holding that table. `0x01` is vmangos's only value; `0x08` is the top of
|
||||
/// mangos-classic's `CharLoginFailReasons`, past the client's six-entry switch.
|
||||
#[test]
|
||||
fn a_refused_character_login_parses_and_decodes() {
|
||||
for result in [0x01u8, 0x08] {
|
||||
let p = messages::parse_server(
|
||||
messages::opcode::SMSG_CHARACTER_LOGIN_FAILED,
|
||||
std::slice::from_ref(&result),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
p,
|
||||
ServerPacket::CharacterLoginFailed { result: got } if got == result
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(p)[..],
|
||||
[SessionEvent::CharacterLoginFailed { result: got }] if got == result
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@ pub(crate) fn update_ground_fx_decals(
|
||||
raster_bias: crate::sky_order::Rung::DECAL_RASTER,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: entity,
|
||||
light: None,
|
||||
},
|
||||
|
||||
@@ -166,9 +166,12 @@ pub struct WowLighting {
|
||||
}
|
||||
|
||||
impl WowLighting {
|
||||
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. The
|
||||
/// from-above depth swatch is a plain **2-endpoint linear lerp** of the zone's dedicated `Light.dbc`
|
||||
/// water rows — IntBand 16/17 (river/lake) or 14/15 (ocean), **RAW** (no ×0.711) — by the per-vertex
|
||||
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. These
|
||||
/// are the ENDPOINTS; the ramp between them is a 64-row byte-space accumulator that `liquid.wgsl`
|
||||
/// reproduces (`swatch_row`), not the plain lerp this doc used to describe — it stops one row short
|
||||
/// of `deep`, and the ocean's last row is darkened (decision 2074). The rows are the zone's
|
||||
/// dedicated `Light.dbc` water rows — IntBand 16/17 (river/lake) or 14/15 (ocean), **RAW** (no
|
||||
/// ×0.711) — indexed by the per-vertex
|
||||
/// depth `V` (river/lake `V = clamp(byte/42)`, built in `benilla-formats::liquid`). VERIFIED from WoW.exe
|
||||
/// `FUN_0068a830`, golden-vector-matched to the apitrace swatch (≤1/255 over all 64 rows). The shader
|
||||
/// (`liquid.wgsl`) lerps both colour and opacity by the *same* V, so they track together.
|
||||
@@ -183,12 +186,16 @@ impl WowLighting {
|
||||
/// **no scale** — VERIFIED `WoW.exe FUN_006b6b60` builds `ca7f10[d] = shallow + d·(deep−shallow)/256`
|
||||
/// from `gWorldLight+0x114/+0x118`, corroborated by the apitrace swatch (`α = 127 + 2·row` ⇒
|
||||
/// 0.5→1.0 for river/lake, 0.75→1.0 for ocean). The alpha and the colour ride the SAME per-vertex
|
||||
/// `V`, and the river/lake `V` is the **steep `clamp(byte/42)`** (VERIFIED `c81768` LUT /
|
||||
/// `FUN_0068d790`) — NOT `byte/255`: a river channel saturates to opaque deep teal by **byte 42 ≈
|
||||
/// 5 yd** (ramp ≈8.5 byte/yd, VERIFIED `probe_water_depth`), leaving only the shore edge
|
||||
/// see-through. Ocean uses a different non-LUT path (placeholder `/255`, pending its own RE+A/B).
|
||||
/// `V`, and **each kind rides its own verified LUT** — river/lake the steep `clamp(byte/42)`
|
||||
/// (`c81768`, `FUN_0068d790`), ocean the gentle `clamp(byte/255)` (`c7fcd8`, `FUN_0068d690`),
|
||||
/// both built side by side in `FUN_0068c4c0`. A river channel saturates to opaque deep teal by
|
||||
/// **byte 42 ≈ 5 yd** (ramp ≈8.5 byte/yd, VERIFIED `probe_water_depth`, re-measured at 8.96 over
|
||||
/// every MCLQ block in Azeroth + Kalimdor), leaving only the shore edge see-through; the sea
|
||||
/// authors its byte 5.2× gentler (1.72 byte/yd), so its `/255` saturates at ~148 yd of depth —
|
||||
/// the same ramp in yards, not a 6× slower one (decision 2069).
|
||||
/// (Earlier bugs: `×8 DEPTH_RAMP_SCALE` saturated at ~4 yd; then the gentle `byte/255` was the
|
||||
/// WRONG LUT and the river middle never reached teal.)
|
||||
/// WRONG LUT **for a river** and the river middle never reached teal — which is what got the
|
||||
/// sea's own `/255` mislabelled a placeholder for months.)
|
||||
pub(crate) fn water_colors(&self, kind: LiquidKind) -> ([f32; 3], [f32; 3], f32, f32) {
|
||||
let (shallow_rgb, deep_rgb, alpha) = if kind == LiquidKind::Ocean {
|
||||
(
|
||||
|
||||
@@ -542,6 +542,7 @@ fn push_drift(
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: true,
|
||||
no_depth_test: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
|
||||
@@ -18,10 +18,15 @@
|
||||
//! citation here used to name an "apitrace WoW.17 program 159" and a `docs/knowledge/terrain.md`,
|
||||
//! neither of which exists, and wow-re had the program attributed to a character draw. The body
|
||||
//! colour is **`primary · waterTint`**, where:
|
||||
//! - **`waterTint`** is a plain **2-endpoint linear lerp** of the zone's dedicated `Light.dbc` water
|
||||
//! - **`waterTint`** is a **64-row byte-space ramp** between the zone's dedicated `Light.dbc` water
|
||||
//! rows, RAW (no ×0.711): IntBand rows 16/17 (river/lake) or 14/15 (ocean), shallow→deep, by the
|
||||
//! per-vertex depth `V` (river/lake `V = clamp(byte/42)`, VERIFIED `c81768`/`FUN_0068d790`; saturates
|
||||
//! ~5 yd so the channel middle reaches the deep/teal row). The swatch is rebuilt **per world frame**,
|
||||
//! ~5 yd so the channel middle reaches the deep/teal row). `FUN_0068a830` builds it as an exact
|
||||
//! integer accumulator — `row(i) = c0 + floor(i·(c1−c0)/64)`, `i = 0..63` — so it **never reaches
|
||||
//! the deep endpoint**, and it is sampled LINEAR/CLAMP at texel `V·64 − 0.5`. **The ocean's last
|
||||
//! row alone is then darkened** `floor(0.9·byte)` per channel (an HSV `V ×= 0.9` that reduces to
|
||||
//! exactly that) with its alpha forced opaque — and ~80 % of the world's ocean vertices sample
|
||||
//! that one row, so it is the open sea's actual colour (decision 2074). The swatch is rebuilt **per world frame**,
|
||||
//! not baked once at bootstrap: the dirty flags `[0xc8117c]`/`[0xc81b70]` clear at
|
||||
//! `0x680b90`/`0x680b97` and refill via `0x58acd0`, so colour and opacity track the zone and the
|
||||
//! clock. (The earlier "reflected sky × 0.711 via `FUN_0068c250`" model fingered the WRONG builder —
|
||||
@@ -32,20 +37,34 @@
|
||||
//! - the animated `lake_a`/`ocean_h` frame is the **`detailTex`** (near-black RGB + ripple alpha): a
|
||||
//! faint flat lift + an achromatic shimmer on crests — NOT the body colour. Mipped + 16× aniso so the
|
||||
//! ripple averages out at distance (near-field samples mip 0, so near sparkle is the term itself).
|
||||
//! - **opacity** = the SAME `V` indexes both colour and alpha (one swatch row → RGB + A): a ramp between
|
||||
//! - **opacity** = the SAME `V` indexes both colour and alpha (one swatch row → RGB + A): the same
|
||||
//! 64-row ramp between
|
||||
//! the LightParams shallow/deep alphas — river 0.5→1.0, ocean 0.75→1.0. The river channel reaches
|
||||
//! α=1.0 (opaque, deep teal) by byte 42 ≈ 5 yd; the shore stays see-through (the pale edge band —
|
||||
//! the bottom shows). **OPEN:** we ramp it linearly (`127+2·row`), but wow-re reads the byte-verified
|
||||
//! ADT water alpha as the `0xc7fbc0` LUT's `1.6·(i/63)^8` — a curve that stays near-transparent far
|
||||
//! longer and then climbs hard. Unchanged so far: it is a look change to every ADT water surface in
|
||||
//! the game, so it belongs in one A/B with the WMO arms rather than landing on its own.
|
||||
//! the bottom shows). The linear ramp is right, and `0xc7fbc0`'s `1.6·(i/63)^8` — which this line
|
||||
//! used to flag as a competing reading — is **not on this path at all**: its only consumer is
|
||||
//! `0x68c250` (via `0x68c4a0`) building the separate texture `[0xc7fbb8]`, bound at `0x685257`
|
||||
//! inside a branch proven **dead** (`[0xc800ec]` is BSS whose only writer stores 0 and which is
|
||||
//! never a `0x58b2b0` out-param target, so both arms are controlled). It is the sky/overlay glare
|
||||
//! falloff, not a liquid opacity curve. The swatch's alpha column is the same endpoint lerp its RGB
|
||||
//! is (`0x68a830`). VERIFIED, wow-re's §5 ocean round — `terrain/scratch/ocean-depth-ramp-law.md`.
|
||||
//!
|
||||
//! River/lake `V = clamp(byte/42)` (steep `c81768` LUT, `FUN_0068d790`) — NOT `byte/255` (the `c7fcd8`
|
||||
//! LUT, a different draw list the from-above river path doesn't use; it left the river middle stuck on
|
||||
//! the shallow green row). Ocean uses a non-LUT UV path → placeholder `/255` pending its own RE+A/B.
|
||||
//! **Each kind reads its own depth LUT, and both are verified** — `FUN_0068c4c0` builds them side by
|
||||
//! side and the two vert-fills read them the same way, `tc0 = (0.5, ramp[depthByte])`: river/lake
|
||||
//! `c81768 = min(d/42, 1)` (`FUN_0068d790`, `fld` @`0x68d818`), ocean `c7fcd8 = min(d/255, 1)`
|
||||
//! (`FUN_0068d690`, `fld` @`0x68d718`). Putting the river's `/42` on a river was the 2026-05-31 fix;
|
||||
//! the ocean's `/255` was never the same mistake, and calling it a placeholder was ours, not the
|
||||
//! binary's — `0x68d890`, the address that claim rested on, is the **magma** vert-fill (authored
|
||||
//! per-vertex texcoords × 3/256), as `benilla_adt::LiquidVertex::texcoords` has always said.
|
||||
//! The two divisors differ because the two authored depth bytes do: measured over every MCLQ block in
|
||||
//! Azeroth + Kalimdor, the sea's byte ramps **1.72 byte/yd** against a river's **8.96**, so `/255`
|
||||
//! saturates at ~148 yd of depth and `/42` at ~4.7 yd — within ~15 % of each other in yards per unit
|
||||
//! of V — and 83.4 % of ocean vertices are pinned at byte 255 outright, so the open sea is authored
|
||||
//! fully deep and the ramp is a shore band. Decision 2069;
|
||||
//! `benilla-formats::examples::liquid_depth_census` is the instrument, `WOW_OCEAN_DEPTH_DIV` the A/B.
|
||||
//! (Earlier cuts: ripple-as-colour → black; `×8` → "deep too early"; FLAT colour → "completely gone";
|
||||
//! sky × 0.711 → wrong builder; `byte/255` → wrong LUT, no teal centre. Faithful = rows 14–17 raw lerp
|
||||
//! + the /42 V. 2026-05-31.)
|
||||
//! sky × 0.711 → wrong builder; `byte/255` on the RIVER → wrong LUT, no teal centre. Faithful =
|
||||
//! rows 14–17 raw lerp + the /42 V on rivers, the /255 V on the sea.)
|
||||
//!
|
||||
//! Two-sided, alpha-blended, depth-write off (Bevy's transparent pass = the verified MCLQ water render
|
||||
//! state).
|
||||
|
||||
@@ -265,6 +265,8 @@ pub struct EffectDraw {
|
||||
pub(crate) raster_slope: f32,
|
||||
/// Are this draw's vertices **already camera-relative**? See [`EffectDrawSpec::cam_relative`].
|
||||
pub(crate) cam_relative: bool,
|
||||
/// Does this draw ignore the depth buffer? See [`EffectDrawSpec::no_depth_test`].
|
||||
pub(crate) no_depth_test: bool,
|
||||
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
|
||||
pub range: Range<u32>,
|
||||
/// The producing entity — the phase probe's identity for this item (`item.entity.1`, so a
|
||||
@@ -356,6 +358,16 @@ pub struct EffectDrawSpec {
|
||||
///
|
||||
/// The sort [`Self::anchor`] stays absolute either way — it is not a vertex.
|
||||
pub cam_relative: bool,
|
||||
/// **Draw over everything** — force the depth COMPARE to `Always`, so this draw is never
|
||||
/// occluded by anything already in the depth buffer. Nothing is depth-*written* either way on
|
||||
/// a blended draw, so it occludes nothing in turn.
|
||||
///
|
||||
/// The lane's default is `false`, and it stays the default: a depth-tested transparent is what
|
||||
/// every particle, ribbon, decal and streak wants. One family sets it — the weapon swing trail,
|
||||
/// whose callback writes EGxRs id `0x10` to `0` (`0x6c686e`; GL `glDisable(GL_DEPTH_TEST)`,
|
||||
/// D3D `ZFUNC = D3DCMP_ALWAYS`) so the arc is never eaten by the swinging character's own
|
||||
/// shoulder (wow-re `charproc8-trail-draw-state.md` §1; decision 2076).
|
||||
pub no_depth_test: bool,
|
||||
pub main_entity: Entity,
|
||||
pub light: Option<Buffer>,
|
||||
}
|
||||
@@ -404,6 +416,7 @@ impl EffectQuads {
|
||||
raster_bias: spec.raster_bias,
|
||||
raster_slope: spec.raster_slope,
|
||||
cam_relative: spec.cam_relative,
|
||||
no_depth_test: spec.no_depth_test,
|
||||
range: start..end,
|
||||
main_entity: spec.main_entity,
|
||||
light: spec.light,
|
||||
@@ -519,6 +532,7 @@ mod tests {
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
@@ -617,6 +631,7 @@ impl<'w> WorldEffectDraw<'w> {
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
@@ -640,6 +655,14 @@ impl EffectBatch<'_> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Blend it over what is already drawn, `SRC_ALPHA / INV_SRC_ALPHA` — the lane's default,
|
||||
/// spelled out for a producer whose blend is a *verified* state rather than an unremarked one
|
||||
/// (the swing trail's EGxRs `0x07 = 2`, which reads like it ought to be additive and is not).
|
||||
pub fn alpha(mut self) -> Self {
|
||||
self.spec.blend = EffectBlend::Alpha;
|
||||
self
|
||||
}
|
||||
|
||||
/// Multiply it into what is already drawn — the blob shadow's darkening.
|
||||
pub fn multiply(mut self) -> Self {
|
||||
self.spec.blend = EffectBlend::Multiply;
|
||||
@@ -675,6 +698,12 @@ impl EffectBatch<'_> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw over everything, occluded by nothing — see [`EffectDrawSpec::no_depth_test`].
|
||||
pub fn over_everything(mut self) -> Self {
|
||||
self.spec.no_depth_test = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// The entity this draw belongs to, for the render world's per-item bookkeeping.
|
||||
pub fn owner(mut self, entity: Entity) -> Self {
|
||||
self.spec.main_entity = entity;
|
||||
|
||||
@@ -89,6 +89,8 @@ struct ExtractedDraw {
|
||||
/// Vertices are already camera-relative — the prepare rebase skips this draw. See
|
||||
/// [`super::buffer::EffectDrawSpec::cam_relative`].
|
||||
cam_relative: bool,
|
||||
/// This draw ignores the depth buffer. See [`super::buffer::EffectDrawSpec::no_depth_test`].
|
||||
no_depth_test: bool,
|
||||
/// Vertex range in the shared stream.
|
||||
range: Range<u32>,
|
||||
/// A booth's scene-light override (0539 §5); `None` = the world's shared light buffer.
|
||||
@@ -200,6 +202,9 @@ pub struct EffectPipelineKey {
|
||||
/// def rather than a uniform bit so the 95% unlit majority keeps the identical instruction
|
||||
/// stream it has today.
|
||||
lit: bool,
|
||||
/// Does this draw ignore the depth buffer entirely (`depth_compare = Always`)? Two values
|
||||
/// exist and only one family sets it — see [`super::buffer::EffectDrawSpec::no_depth_test`].
|
||||
no_depth_test: bool,
|
||||
}
|
||||
|
||||
impl SpecializedRenderPipeline for EffectPipeline {
|
||||
@@ -281,11 +286,12 @@ impl SpecializedRenderPipeline for EffectPipeline {
|
||||
}
|
||||
// `$WOW_PARTICLE_NODEPTH` — the occlusion A/B (B16): force the depth COMPARE to
|
||||
// `Always`, splitting "nothing is emitted" from "emitted and the depth buffer eats it".
|
||||
let depth_compare = if std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
|
||||
CompareFunction::Always
|
||||
} else {
|
||||
CompareFunction::GreaterEqual
|
||||
};
|
||||
let depth_compare =
|
||||
if key.no_depth_test || std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
|
||||
CompareFunction::Always
|
||||
} else {
|
||||
CompareFunction::GreaterEqual
|
||||
};
|
||||
RenderPipelineDescriptor {
|
||||
label: Some("wow_effect_pipeline".into()),
|
||||
layout: vec![self.view_layout.clone(), self.image_layout.clone()],
|
||||
@@ -368,6 +374,7 @@ fn extract_effects(
|
||||
raster_bias: d.raster_bias,
|
||||
raster_slope: d.raster_slope,
|
||||
cam_relative: d.cam_relative,
|
||||
no_depth_test: d.no_depth_test,
|
||||
range: d.range.clone(),
|
||||
light: d.light.clone(),
|
||||
}));
|
||||
@@ -425,6 +432,7 @@ fn queue_effects(
|
||||
raster_bias: draw.raster_bias,
|
||||
raster_slope_bits: draw.raster_slope.to_bits(),
|
||||
lit: draw.lit,
|
||||
no_depth_test: draw.no_depth_test,
|
||||
},
|
||||
);
|
||||
phase.add(Transparent3d {
|
||||
|
||||
@@ -1387,6 +1387,7 @@ pub(super) fn simulate_particles(
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
},
|
||||
@@ -1429,6 +1430,7 @@ pub(super) fn simulate_particles(
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
},
|
||||
|
||||
@@ -705,6 +705,7 @@ pub(crate) fn simulate_ribbons(
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: entity,
|
||||
light: None, // trails never carry a light override (world lane only)
|
||||
},
|
||||
|
||||
@@ -616,6 +616,7 @@ fn push_water_foam(
|
||||
// mesh, or bevy's sorted-phase batcher claims the item and rewrites its
|
||||
// `batch_range` (gpu_preprocessing.rs keys purely on `item.main_entity()`) —
|
||||
// the Goldshire-teleport crash. The chunk still keys the record grouping above.
|
||||
no_depth_test: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
},
|
||||
|
||||
@@ -667,6 +667,7 @@ fn push_precip(
|
||||
// Streaks, patters and mist are all centimetre-scale or bigger, so absolute world verts
|
||||
// cost them nothing; the flake draw below overrides this (its quads are millimetres).
|
||||
cam_relative: false,
|
||||
no_depth_test: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user