the minimap and world map go stock, and the chat loses its white plate

- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps  7.0 ms  17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:59:53 +07:00
co-authored by Claude Fable 5.1
parent edd6628329
commit 9bf64e1a6b
89 changed files with 6164 additions and 2107 deletions
+705
View File
@@ -0,0 +1,705 @@
//! The tutorial system (decision 1976; wow-re `system/ui/scratch/tutorial-flags.md`): the two
//! bit banks, the fire-once trigger, the acknowledge-and-send setter, the timers, the popup
//! sound, and the trigger sites the app can produce — everything behind the stock
//! `TutorialFrame.xml`.
//!
//! ## Two banks, not one (§1, §2)
//!
//! Both are filled byte for byte from the same `SMSG_TUTORIAL_FLAGS`, and until it lands no
//! tutorial can fire. **Bank A** is the fire-once bank: [`Tutorials::trigger`] tests it, sets it,
//! and raises `TUTORIAL_TRIGGER(id + 1)` — never sending. **Bank B** is the acknowledged bank:
//! [`Tutorials::acknowledge`] (Lua's `FlagTutorial` and the C++ auto-acknowledge sites) tests it,
//! sets the bit in BOTH banks, cancels the id's pending timer, and sends `CMSG_TUTORIAL_FLAG`.
//! `ClearTutorials`/`ResetTutorials` write both banks and send. So A ⊇ B, and doing the thing
//! (moving, chatting, adding a friend…) suppresses the tutorial about it, account-wide.
//!
//! ## The trigger (§3)
//!
//! `TriggerTutorial(id, delayMs)`: no bank → silent; bank-A bit set → silent; else the bit is
//! set and, with a zero delay, the `TutorialPopup` cue plays and the event fires now; with any
//! other delay (unsigned — the 10 s Targeting popup is the one site) a timer holds it. The
//! reference bounds-checks nothing; ours refuses an id past the bank as a no-op.
//!
//! ## The sites (§4, §5)
//!
//! Fifty-one trigger sites and seven acknowledge sites exist in the reference. The ones this app
//! can produce are wired — the self-descriptor edges here, the packets and drains at their own
//! seams through [`TutorialEvent`] — and the record names the rest with their conditions.
use std::time::{Duration, Instant};
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use crate::char_select::ClientState;
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_script::UiInput;
/// The popup cue `0x4b5390` plays on both legs (`0x846b68`), gated on `MasterSoundEffects` at
/// the mixer like every SFX kit.
const TUTORIAL_POPUP_SOUND: &str = "TutorialPopup";
/// The internal (0-based) ids of the sites this app produces, named by their published title.
pub(crate) mod id {
pub(crate) const QUESTGIVERS: u32 = 0x00;
pub(crate) const MOVEMENT: u32 = 0x01;
pub(crate) const CAMERAS: u32 = 0x02;
pub(crate) const TARGETING: u32 = 0x03;
pub(crate) const LOOTING: u32 = 0x06;
pub(crate) const BACKPACK: u32 = 0x07;
pub(crate) const TRAINERS: u32 = 0x0d;
pub(crate) const GROUPING: u32 = 0x11;
pub(crate) const VENDORS: u32 = 0x13;
pub(crate) const QUEST_LOG: u32 = 0x14;
pub(crate) const FRIENDS: u32 = 0x15;
pub(crate) const CHATTING: u32 = 0x16;
pub(crate) const EQUIPPABLE_ITEMS: u32 = 0x17;
pub(crate) const DEATH: u32 = 0x18;
pub(crate) const RESTED: u32 = 0x19;
pub(crate) const FATIGUE: u32 = 0x1a;
pub(crate) const SWIMMING: u32 = 0x1b;
pub(crate) const BREATH: u32 = 0x1c;
pub(crate) const RESTING: u32 = 0x1d;
pub(crate) const HEARTHSTONES: u32 = 0x1e;
pub(crate) const PVP_COMBAT: u32 = 0x1f;
pub(crate) const TRAVEL: u32 = 0x22;
pub(crate) const WELCOME: u32 = 0x29;
pub(crate) const RANGED_WEAPONS: u32 = 0x2b;
pub(crate) const RAID_GROUPS: u32 = 0x2d;
pub(crate) const MEETING_STONES: u32 = 0x2e;
pub(crate) const BATTLEGROUND_QUEUE: u32 = 0x2f;
pub(crate) const PORT_TO_BATTLEGROUND: u32 = 0x30;
pub(crate) const KEYRINGS: u32 = 0x31;
}
/// The level-gated arm of the `SMSG_LEVELUP_INFO` handler (§4): in the handler's own order,
/// each fires when the new level is at least its threshold.
const LEVEL_TRIGGERS: [(u32, u32); 8] = [
(3, 0x16), // Chatting
(4, 0x0d), // Trainers
(4, 0x02), // Cameras
(5, 0x26), // Groups
(7, 0x15), // Friends
(7, 0x25), // Professions
(8, 0x20), // Jumping
(10, 0x0c), // Learning Talents
];
/// The Targeting popup's delay at its one site (`0x514a8d mov edx,0x2710`).
pub(crate) const TARGETING_DELAY_MS: u32 = 10_000;
/// The Movement popup's silence window (`0x482ff7`: elapsed − stamp − 90 000 ≥ 0) — INFERRED as
/// ninety seconds after world enter with no movement input (§4's row reads the stamp getter,
/// not its meaning).
const MOVEMENT_SILENCE: Duration = Duration::from_millis(90_000);
/// A site's ask of the tutorial system — written from wherever the reference's site lives.
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TutorialEvent {
/// `TriggerTutorial(id, delayMs)`: the fire-once leg.
Trigger { id: u32, delay_ms: u32 },
/// `SetTutorialFlag(id)` (`0x4b54c0`): the acknowledge-and-send leg.
Acknowledge { id: u32 },
}
impl TutorialEvent {
pub(crate) const fn trigger(id: u32) -> Self {
Self::Trigger { id, delay_ms: 0 }
}
}
/// A send the drains owe the wire.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TutorialSend {
Flag(u32),
Clear,
Reset,
}
/// A bank: `bit = bytes[id >> 3] & (1 << (id & 7))` — the client's `word = id >> 5` on
/// little-endian dwords, byte-wise.
fn bank_bit(bank: &[u8], id: u32) -> Option<bool> {
bank.get((id >> 3) as usize)
.map(|b| b & (1 << (id & 7)) != 0)
}
fn set_bank_bit(bank: &mut [u8], id: u32) {
if let Some(b) = bank.get_mut((id >> 3) as usize) {
*b |= 1 << (id & 7);
}
}
/// The two banks, the timers, and what the frame owes the VM and the wire.
#[derive(Resource, Default)]
pub(crate) struct Tutorials {
/// Bank A (`0xb711b8`…): fire-once. `None` until the packet lands.
fire_once: Option<Vec<u8>>,
/// Bank B (`0xb711e4`…): acknowledged.
acknowledged: Option<Vec<u8>>,
/// The delayed triggers (`TUTORIALTIMER` nodes): `(id, due)`.
timers: Vec<(u32, Instant)>,
/// Published ids (`id + 1`) whose event and cue are owed this frame.
fired: Vec<u32>,
sends: Vec<TutorialSend>,
/// Bank B changed since the VM last saw it.
dirty: bool,
/// Item pushes for us since the last feed (`0x491a60`'s sites): `(entry, bag, slot)`.
pushes: Vec<(u32, u8, u32)>,
}
impl Tutorials {
/// `SMSG_TUTORIAL_FLAGS`: both banks copied from the payload.
pub(crate) fn apply_flags(&mut self, bytes: &[u8]) {
self.fire_once = Some(bytes.to_vec());
self.acknowledged = Some(bytes.to_vec());
self.dirty = true;
}
/// The bring-up `0x4b5330`: both banks zeroed if present (the bit count stays).
fn world_enter(&mut self) {
for bank in [&mut self.fire_once, &mut self.acknowledged]
.into_iter()
.flatten()
{
bank.iter_mut().for_each(|b| *b = 0);
}
self.dirty = true;
}
/// The teardown `0x4b5380`: every pending timer flushed.
fn world_leave(&mut self) {
self.timers.clear();
}
/// `TriggerTutorial(id, delayMs)` (§3).
pub(crate) fn trigger(&mut self, id: u32, delay_ms: u32, now: Instant) {
let Some(bank) = self.fire_once.as_mut() else {
return; // no bank yet: silent
};
match bank_bit(bank, id) {
Some(false) => set_bank_bit(bank, id),
_ => return, // already set — or past the bank, which the reference would overrun
}
if delay_ms == 0 {
self.fired.push(id + 1);
} else {
self.timers
.push((id, now + Duration::from_millis(u64::from(delay_ms))));
}
}
/// `SetTutorialFlag(id)` (`0x4b54c0`, §5): bank B's bit gates; both banks set; the id's timer
/// cancelled; the 0-based id sent. The reference dereferences an absent bank B; ours treats
/// that as a no-op.
pub(crate) fn acknowledge(&mut self, id: u32) {
let Some(bank_b) = self.acknowledged.as_mut() else {
return;
};
if bank_bit(bank_b, id) != Some(false) {
return;
}
self.timers.retain(|(t, _)| *t != id);
set_bank_bit(bank_b, id);
if let Some(bank_a) = self.fire_once.as_mut() {
set_bank_bit(bank_a, id);
}
self.sends.push(TutorialSend::Flag(id));
self.dirty = true;
}
/// `ClearTutorials()`: the timers flushed, every bit of both banks set, the empty clear sent.
fn clear(&mut self) {
self.timers.clear();
for bank in [&mut self.fire_once, &mut self.acknowledged]
.into_iter()
.flatten()
{
bank.iter_mut().for_each(|b| *b = 0xFF);
}
self.sends.push(TutorialSend::Clear);
self.dirty = true;
}
/// `ResetTutorials()`: every bit of both banks cleared — the timers NOT flushed — the empty
/// reset sent.
fn reset(&mut self) {
for bank in [&mut self.fire_once, &mut self.acknowledged]
.into_iter()
.flatten()
{
bank.iter_mut().for_each(|b| *b = 0);
}
self.sends.push(TutorialSend::Reset);
self.dirty = true;
}
/// `ProcessTutorialTimers`: every due node fires the same cue and event.
fn tick(&mut self, now: Instant) {
let mut due = Vec::new();
self.timers.retain(|&(id, at)| {
if at <= now {
due.push(id + 1);
false
} else {
true
}
});
self.fired.extend(due);
}
/// `SMSG_ITEM_PUSH_RESULT` for one of ours — the item-received handler's five sites, resolved
/// on the next feed with the item's template.
pub(crate) fn item_received(&mut self, entry: u32, bag: u8, slot: u32) {
self.pushes.push((entry, bag, slot));
}
#[cfg(test)]
fn bits(&self) -> (Option<&[u8]>, Option<&[u8]>) {
(self.fire_once.as_deref(), self.acknowledged.as_deref())
}
}
/// The self descriptor's last-seen fields, for the edge-triggered sites (§4): the level-up
/// arm, the ghost flag, the resting flag, the PvP flag, the rest state.
#[derive(Default)]
struct SelfWatch {
level: Option<u32>,
player_flags: Option<u32>,
unit_flags: Option<u32>,
rest_state: Option<u8>,
swimming: Option<bool>,
}
const PLAYER_FLAGS_GHOST: u32 = 0x10;
const PLAYER_FLAGS_RESTING: u32 = 0x20;
const UNIT_FLAG_PVP_ATTACKABLE: u32 = 0x1000;
/// The hearthstone's item entry (`0x1b24`).
const HEARTHSTONE: u32 = 6948;
const INVTYPE_RANGED: u32 = 15;
const CLASS_HUNTER: u8 = 3;
/// The keyring bag and its slot band (`edi == 0xff`, slot ∈ [0x51, 0x70]).
const KEYRING_BAG: u8 = 0xff;
const KEYRING_SLOTS: std::ops::RangeInclusive<u32> = 0x51..=0x70;
/// Before the script tick: the sites' asks, the descriptor edges, the timers, the cue and the
/// event, and the acknowledged bank's push to the VM.
fn feed_tutorials(
script: Option<NonSendMut<UiScript>>,
mut tutorials: ResMut<Tutorials>,
mut asks: MessageReader<TutorialEvent>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
items: Res<crate::items::Items>,
mut sounds: ResMut<crate::sound::MessageSounds>,
) {
let now = Instant::now();
for ask in asks.read() {
match *ask {
TutorialEvent::Trigger { id, delay_ms } => tutorials.trigger(id, delay_ms, now),
TutorialEvent::Acknowledge { id } => tutorials.acknowledge(id),
}
}
// The item-received handler's sites, over the item's template: Backpack unconditionally;
// Equippable Items for an inventory type with an equip slot (INFERRED as any non-zero type —
// the reference's 23-bit mask is not carved bit by bit); Hearthstones by entry; Ranged
// Weapons for a ranged type on a non-hunter; Keyrings by the slot band.
let pushes = std::mem::take(&mut tutorials.pushes);
let class = self_q.single().ok().and_then(|s| s.0.unit_class());
for (entry, bag, slot) in pushes {
tutorials.trigger(id::BACKPACK, 0, now);
if let Some(info) = items.template_cached(entry) {
if info.inventory_type != 0 {
tutorials.trigger(id::EQUIPPABLE_ITEMS, 0, now);
}
if info.inventory_type == INVTYPE_RANGED && class != Some(CLASS_HUNTER) {
tutorials.trigger(id::RANGED_WEAPONS, 0, now);
}
}
if entry == HEARTHSTONE {
tutorials.trigger(id::HEARTHSTONES, 0, now);
}
if bag == KEYRING_BAG && KEYRING_SLOTS.contains(&slot) {
tutorials.trigger(id::KEYRINGS, 0, now);
}
}
tutorials.tick(now);
let Some(mut script) = script else {
return;
};
for published in std::mem::take(&mut tutorials.fired) {
sounds.push_cue(TUTORIAL_POPUP_SOUND);
script.fire_event(
"TUTORIAL_TRIGGER",
vec![ScriptValue::Int(i64::from(published))],
);
}
if std::mem::take(&mut tutorials.dirty) {
script.set_tutorial_bank(tutorials.acknowledged.clone());
}
}
/// The self descriptor's edge sites (§4): the `SMSG_LEVELUP_INFO` arm on the level rising, the
/// ghost and resting flags, the PvP flag, the rested table's one live row, and the mover
/// entering water — a change each, never the login descriptor. The memory is per world session:
/// the bring-up resets it, so a re-login's first descriptor arms silently like the first one.
fn watch_self(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
mut cascade: ResMut<WorldEnterCascade>,
mut watch: Local<SelfWatch>,
mut asks: MessageWriter<TutorialEvent>,
) {
if std::mem::take(&mut cascade.reset_watch) {
*watch = SelfWatch::default();
}
// The descriptor edges — a change, never the login descriptor (the watchers run on
// field changes; the first sight arms silently).
if let Ok(store) = self_q.single() {
let level = store.0.unit_level();
let player_flags = store.0.player_flags();
let unit_flags = store.0.unit_flags();
let rest_state = store.0.player_rest_state();
if let (Some(prev), Some(new)) = (watch.level, level) {
if new > prev {
for (threshold, id) in LEVEL_TRIGGERS {
if new >= threshold {
asks.write(TutorialEvent::trigger(id));
}
}
}
}
if let Some(prev) = watch.player_flags {
let rose = |bit: u32| prev & bit == 0 && player_flags & bit != 0;
if rose(PLAYER_FLAGS_GHOST) {
asks.write(TutorialEvent::trigger(id::DEATH));
}
if rose(PLAYER_FLAGS_RESTING) {
asks.write(TutorialEvent::trigger(id::RESTING));
}
}
if let Some(prev) = watch.unit_flags {
if prev & UNIT_FLAG_PVP_ATTACKABLE == 0 && unit_flags & UNIT_FLAG_PVP_ATTACKABLE != 0 {
asks.write(TutorialEvent::trigger(id::PVP_COMBAT));
}
}
if let (Some(prev), Some(new)) = (watch.rest_state, rest_state) {
// The rested table: only rest state 1 names a tutorial (the other rows carry the
// sentinel), on the field's change.
if new != prev && new == 1 {
asks.write(TutorialEvent::trigger(id::RESTED));
}
}
watch.level = level;
watch.player_flags = Some(player_flags);
watch.unit_flags = Some(unit_flags);
watch.rest_state = rest_state;
}
if let Some(prev) = watch.swimming {
if !prev && player.swimming {
asks.write(TutorialEvent::trigger(id::SWIMMING));
}
}
watch.swimming = Some(player.swimming);
}
/// After the script tick: the four verbs' writes, then every send.
fn drain_tutorials(
script: Option<NonSendMut<UiScript>>,
mut tutorials: ResMut<Tutorials>,
commands: Res<NetCommands>,
) {
if let Some(mut script) = script {
for id in script.take_tutorial_flag_requests() {
tutorials.acknowledge(id);
}
for _ in 0..script.take_tutorial_clears() {
tutorials.clear();
}
for _ in 0..script.take_tutorial_resets() {
tutorials.reset();
}
}
for send in std::mem::take(&mut tutorials.sends) {
let command = match send {
TutorialSend::Flag(id) => ClientCommand::TutorialFlag { id },
TutorialSend::Clear => ClientCommand::TutorialClear,
TutorialSend::Reset => ClientCommand::TutorialReset,
};
let _ = commands.0.send(command);
}
}
/// The world-enter bring-up: the banks zeroed, then the bank captured during the login handshake
/// applied if there was one (the world stream's copy lands through `SessionEvent` otherwise).
fn on_world_enter(
mut entered: MessageReader<EnteredWorldMessage>,
mut tutorials: ResMut<Tutorials>,
mut cascade: ResMut<WorldEnterCascade>,
) {
for e in entered.read() {
tutorials.world_enter();
if let Some(bytes) = &e.tutorial_flags {
tutorials.apply_flags(bytes);
}
cascade.armed = true;
cascade.entered_at = Some(Instant::now());
cascade.moved = false;
cascade.reset_watch = true;
}
}
/// The enter-world cascade `0x4908c0`'s two unconditional triggers — Welcome (`0x490a48`) then
/// Questgivers (`0x490a51`) — run once per world session when the local player object exists,
/// which is after the bank has landed on a stock server; and the Movement popup's silence window.
#[derive(Resource, Default)]
pub(crate) struct WorldEnterCascade {
armed: bool,
entered_at: Option<Instant>,
/// A movement input happened this session (the Movement auto-acknowledge site).
pub(crate) moved: bool,
/// The descriptor watcher's memory is owed a reset (a new world session).
reset_watch: bool,
}
fn run_world_enter_cascade(
mut cascade: ResMut<WorldEnterCascade>,
mut tutorials: ResMut<Tutorials>,
self_q: Query<(), With<SelfPlayer>>,
) {
let now = Instant::now();
if cascade.armed && !self_q.is_empty() {
cascade.armed = false;
tutorials.trigger(id::WELCOME, 0, now);
tutorials.trigger(id::QUESTGIVERS, 0, now);
}
if let Some(at) = cascade.entered_at {
if !cascade.moved && now.duration_since(at) >= MOVEMENT_SILENCE {
cascade.entered_at = None;
tutorials.trigger(id::MOVEMENT, 0, now);
}
}
}
/// The world-input sites (`0x514840`, §4/§5), bundled for the controller: a movement input
/// acknowledges Movement and arms the 10 s Targeting popup; a mouse-look acknowledges Cameras.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct InputHooks<'w> {
/// Optional, so a controller test's partial app runs without the tutorial plugin.
asks: Option<MessageWriter<'w, TutorialEvent>>,
cascade: Option<ResMut<'w, WorldEnterCascade>>,
}
impl InputHooks<'_> {
/// A movement input this frame (`test ecx,0x1030` — INFERRED as the forward/side axes).
pub(crate) fn moved(&mut self) {
if let Some(c) = self.cascade.as_mut() {
c.moved = true;
}
if let Some(asks) = self.asks.as_mut() {
asks.write(TutorialEvent::Acknowledge { id: id::MOVEMENT });
asks.write(TutorialEvent::Trigger {
id: id::TARGETING,
delay_ms: TARGETING_DELAY_MS,
});
}
}
/// A mouse-look this frame (`[ebp+8] & 3`, the two buttons).
pub(crate) fn mouselooked(&mut self) {
if let Some(asks) = self.asks.as_mut() {
asks.write(TutorialEvent::Acknowledge { id: id::CAMERAS });
}
}
}
/// The window edges the reference triggers or acknowledges from inside its handlers: the
/// trainer list (Trainers acknowledged, `0x4d74ae`), the vendor list (Vendors, `0x4fad32`), the
/// taxi map (Travel, `0x4dbaa2`), and the group turning raid (Raid Groups, `0x4ba60d` — INFERRED
/// as the raid-type edge; the row names only its non-zero argument).
#[derive(Default)]
struct WindowWatch {
trainer: Option<u64>,
vendor: Option<u64>,
taxi: bool,
raid: Option<bool>,
}
fn watch_windows(
trainer: Res<crate::ui_trainer::TrainerOpen>,
merchant: Res<crate::ui_merchant::MerchantOpen>,
taxi: Res<crate::ui_taxi::TaxiState>,
group: Res<crate::ui_party::GroupState>,
mut watch: Local<WindowWatch>,
mut asks: MessageWriter<TutorialEvent>,
) {
if trainer.trainer.is_some() && trainer.trainer != watch.trainer {
asks.write(TutorialEvent::Acknowledge { id: id::TRAINERS });
}
watch.trainer = trainer.trainer;
if merchant.vendor.is_some() && merchant.vendor != watch.vendor {
asks.write(TutorialEvent::trigger(id::VENDORS));
}
watch.vendor = merchant.vendor;
let taxi_open = taxi.open.is_some();
if taxi_open && !watch.taxi {
asks.write(TutorialEvent::trigger(id::TRAVEL));
}
watch.taxi = taxi_open;
let raid = group.in_group && group.group_type == crate::ui_party::GROUPTYPE_RAID;
if raid && watch.raid == Some(false) {
asks.write(TutorialEvent::trigger(id::RAID_GROUPS));
}
watch.raid = Some(raid);
}
fn on_world_leave(mut tutorials: ResMut<Tutorials>, mut cascade: ResMut<WorldEnterCascade>) {
tutorials.world_leave();
cascade.armed = false;
cascade.entered_at = None;
}
pub(crate) struct TutorialPlugin;
impl Plugin for TutorialPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Tutorials>()
.init_resource::<WorldEnterCascade>()
.add_message::<TutorialEvent>()
.add_systems(
Update,
(
on_world_enter.before(run_world_enter_cascade),
run_world_enter_cascade.before(feed_tutorials),
watch_windows.before(feed_tutorials),
watch_self.after(on_world_enter).before(feed_tutorials),
feed_tutorials.before(UiInput),
drain_tutorials.after(UiInput),
),
)
.add_systems(OnExit(ClientState::InWorld), on_world_leave);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn banked() -> Tutorials {
let mut t = Tutorials::default();
t.apply_flags(&[0u8; 32]);
t
}
/// No bank: silent. A clear bit: set in A only, the event owed, nothing sent. Twice: once.
#[test]
fn the_trigger_fires_once_per_bank_a_and_never_sends() {
let now = Instant::now();
let mut t = Tutorials::default();
t.trigger(id::WELCOME, 0, now);
assert!(t.fired.is_empty(), "no bank yet: silent");
let mut t = banked();
t.trigger(id::WELCOME, 0, now);
t.trigger(id::WELCOME, 0, now);
assert_eq!(t.fired, vec![id::WELCOME + 1], "published as id + 1, once");
let (a, b) = t.bits();
assert_eq!(bank_bit(a.unwrap(), id::WELCOME), Some(true));
assert_eq!(
bank_bit(b.unwrap(), id::WELCOME),
Some(false),
"bank B untouched"
);
assert!(t.sends.is_empty(), "the trigger sends nothing");
t.trigger(300, 0, now);
assert_eq!(t.fired.len(), 1, "past the bank: a no-op");
}
/// The delayed leg holds the id until due; an acknowledge cancels it.
#[test]
fn a_delayed_trigger_waits_and_an_acknowledge_cancels_it() {
let now = Instant::now();
let mut t = banked();
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
assert!(t.fired.is_empty());
t.tick(now + Duration::from_millis(9_999));
assert!(t.fired.is_empty(), "not yet");
t.tick(now + Duration::from_millis(10_000));
assert_eq!(t.fired, vec![id::TARGETING + 1]);
let mut t = banked();
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
t.acknowledge(id::TARGETING);
t.tick(now + Duration::from_millis(20_000));
assert!(t.fired.is_empty(), "the acknowledge cancelled the timer");
assert_eq!(t.sends, vec![TutorialSend::Flag(id::TARGETING)]);
}
/// The acknowledge: bank B gates, both banks set, one send; a second is silent; absent bank
/// B is a no-op.
#[test]
fn the_acknowledge_sets_both_banks_and_sends_once() {
let mut t = Tutorials::default();
t.acknowledge(id::CHATTING);
assert!(t.sends.is_empty(), "no bank: nothing");
let mut t = banked();
t.acknowledge(id::CHATTING);
t.acknowledge(id::CHATTING);
assert_eq!(t.sends, vec![TutorialSend::Flag(id::CHATTING)]);
let (a, b) = t.bits();
assert_eq!(bank_bit(a.unwrap(), id::CHATTING), Some(true));
assert_eq!(bank_bit(b.unwrap(), id::CHATTING), Some(true));
// Acknowledged first: the later trigger is silent (A ⊇ B).
t.trigger(id::CHATTING, 0, Instant::now());
assert!(t.fired.is_empty());
}
/// Clear sets every bit and flushes the timers; Reset clears every bit and keeps them.
#[test]
fn clear_and_reset_write_both_banks() {
let now = Instant::now();
let mut t = banked();
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
t.clear();
assert!(t.timers.is_empty(), "clear flushes the timers");
let (a, b) = t.bits();
assert!(a.unwrap().iter().all(|&x| x == 0xFF) && b.unwrap().iter().all(|&x| x == 0xFF));
t.reset();
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
assert_eq!(
t.timers.len(),
1,
"a cleared bank lets the trigger through again"
);
t.reset();
assert_eq!(t.timers.len(), 1, "reset does NOT flush the timers");
let (a, b) = t.bits();
assert!(a.unwrap().iter().all(|&x| x == 0) && b.unwrap().iter().all(|&x| x == 0));
assert_eq!(
t.sends,
vec![
TutorialSend::Clear,
TutorialSend::Reset,
TutorialSend::Reset
]
);
}
/// World enter zeroes present banks and keeps their size; the packet fills both.
#[test]
fn world_enter_zeroes_and_the_packet_fills() {
let mut t = Tutorials::default();
t.world_enter();
assert!(t.fire_once.is_none(), "no bank: nothing to zero");
t.apply_flags(&[0xFFu8; 4]);
t.world_enter();
let (a, b) = t.bits();
assert_eq!(a.unwrap(), &[0u8; 4]);
assert_eq!(b.unwrap(), &[0u8; 4]);
assert_eq!(bank_bit(a.unwrap(), 31), Some(false));
assert_eq!(bank_bit(a.unwrap(), 32), None, "past a 4-byte bank");
}
}