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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user