- ReloadUI() tears the UI down and rebuilds it in place without leaving the world — the reference's own pair, one frame apart - a model's particle clouds and ribbon trails are admitted by one rule — distance, far clip, frustum, window, room — and a building's light sources ride their rooms too, so torches stop lighting receivers through walls - a mouse-wheel notch is a press and its release, dispatched in the same frame as the reference does - the binding registry is checked against the install's own Bindings.xml and DefaultBindings.wtf, so its transcribed defaults cannot drift silently Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
501 lines
21 KiB
Rust
501 lines
21 KiB
Rust
//! The item-text reader session (`ItemTextFrame.xml`) — the one window every *readable* opens in:
|
|
//! a bag letter, a book in your bags, and a book/plaque lying in the world.
|
|
//!
|
|
//! **One reader, keyed on an object guid, opened locally with no permission packet.** That is the
|
|
//! reference's own shape, byte-verified (decision 1105): every route ends at `0x4e32e0(guid, flag)`,
|
|
//! which looks the guid up with typemask `1` (*any* object — item or GameObject), asks the object
|
|
//! for its page id through the shared `vtbl+0x74` getter, and pulls the text out of a cache. Which
|
|
//! of the two text sources applies is the object's own answer, not the caller's:
|
|
//!
|
|
//! - **a letter** — an item *instance* carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made permanent
|
|
//! copy). One body, no pages; it rides the ask-once `CMSG_ITEM_TEXT_QUERY` cache mail letters use
|
|
//! ([`crate::ui_mail::MailOpen::bodies`] — that map is the client's item-text cache, mail is just
|
|
//! its first tenant), and its creator line resolves `ITEM_FIELD_CREATOR` through the name cache.
|
|
//! - **a page chain** — a readable item *template*'s `PageText`, or a `GAMEOBJECT_TYPE_TEXT` world
|
|
//! object's template `data[0]`. Pages chain by `nextPageId` over the ask-once
|
|
//! [`PageTexts`] cache (`CMSG_PAGE_TEXT_QUERY`), and the reader's prev/next buttons walk them.
|
|
//! Authorless — a book has no `From,` tail.
|
|
//!
|
|
//! The routes in: `ui_items::drain::drain_container_uses` (the bag click's dispatcher arms #5 and
|
|
//! #6) and `target::click::act_on_right_click` (the world right-click's GO type-9 arm). None of
|
|
//! them sends a use/read packet — vmangos has no `GameObject::Use` case for type 9 at all, and its
|
|
//! `CMSG_READ_ITEM` handler gates on a template `PageText` the Plain Letter doesn't have, so both
|
|
//! would be answered with silence even if we asked.
|
|
//!
|
|
//! The feed mirrors the reference event flow (ItemTextFrame.lua l.10-96): open →
|
|
//! `ITEM_TEXT_BEGIN` (title known), everything fetched → `ITEM_TEXT_READY`, `CloseItemText()` →
|
|
//! session cleared + `ITEM_TEXT_CLOSED`. `ITEM_TEXT_TRANSLATION` (the foreign-language progress
|
|
//! bar) never fires — no translation mechanic on a private server's readables, and the `language`
|
|
//! column (`data[1]`/`LanguageID`) is carried by neither source here.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{ItemTextState, UiScript};
|
|
|
|
use crate::go_templates::GameObjectTemplates;
|
|
use crate::items::Items;
|
|
use crate::names::NameCache;
|
|
use crate::net::{ClientCommand, NetCommands};
|
|
use crate::ui_mail::MailOpen;
|
|
use crate::ui_script::UiInput;
|
|
|
|
/// One page of a book as the wire gave it (`SMSG_PAGE_TEXT_QUERY_RESPONSE`).
|
|
pub(crate) struct PageText {
|
|
pub(crate) text: String,
|
|
/// The next page's id; `0` = this is the last page.
|
|
pub(crate) next: u32,
|
|
}
|
|
|
|
/// The ask-once page cache (decision 1105) — the client's `PageText` dbcache (`0xc0e174`). Keyed by
|
|
/// page id, filled by [`crate::net::apply`]'s `SessionEvent::PageText` arm.
|
|
///
|
|
/// vmangos answers a single `CMSG_PAGE_TEXT_QUERY` with the **whole chain** (one response per page),
|
|
/// so asking for page 1 normally lands the entire book at once and a page turn is a cache hit. The
|
|
/// per-page fetch is still here for the server that answers one at a time.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct PageTexts {
|
|
pages: HashMap<u32, PageText>,
|
|
pending: HashSet<u32>,
|
|
}
|
|
|
|
impl PageTexts {
|
|
/// Record a page (and clear its in-flight flag).
|
|
pub(crate) fn insert(&mut self, page_id: u32, text: String, next: u32) {
|
|
self.pending.remove(&page_id);
|
|
self.pages.insert(page_id, PageText { text, next });
|
|
}
|
|
|
|
/// The cached page, asking for it once if this is the first asker. `guid` names the object
|
|
/// doing the reading (the reference writes it after the page id; vmangos discards it).
|
|
fn get_or_ask(&mut self, page_id: u32, guid: u64, commands: &NetCommands) -> Option<&PageText> {
|
|
if !self.pages.contains_key(&page_id) && self.pending.insert(page_id) {
|
|
debug!("page text: asking page {page_id} (object {guid:#x})");
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::PageTextQuery { page_id, guid });
|
|
}
|
|
self.pages.get(&page_id)
|
|
}
|
|
}
|
|
|
|
/// Which text a read session is showing — the reference's two sources, which it picks between by
|
|
/// asking the *object* (an instance text id, else a page id), not by how the click arrived.
|
|
pub(crate) enum ReadSource {
|
|
/// An item instance's `ITEM_FIELD_ITEM_TEXT_ID` — a mail-made letter. Single body, has a
|
|
/// creator line.
|
|
Letter { text_id: u32 },
|
|
/// A page chain — a readable item template's `PageText`, or a TEXT GameObject's `data[0]`.
|
|
/// `visited` is the trail of page ids from the first to the one on screen, so **Prev** can walk
|
|
/// back a chain that only links forwards (the reference keeps the same array, `[0xbc3fd0]`).
|
|
/// **Empty until the object's template answers**: the reference asks the *object* for its page
|
|
/// id at paint time (`vtbl+0x74`, with an ask-once template callback that re-enters the whole
|
|
/// open — `0x5f59d0`), so a click that lands before the template does still opens a reader.
|
|
Pages { visited: Vec<u32> },
|
|
}
|
|
|
|
/// The open read session; `None` in [`ItemTextOpen::pending`] = no reader open.
|
|
pub(crate) struct ReadSession {
|
|
/// The read object's guid — an item's or a GameObject's. Title/creator resolve off it each
|
|
/// frame until ready, and re-using the *same* object closes the reader (the reference's toggle).
|
|
pub(crate) object_guid: u64,
|
|
pub(crate) source: ReadSource,
|
|
/// What the live VM has been told about this session — keyed on the VM (decisions
|
|
/// 1290/1291), because the session is world state (the letter is still open) while the
|
|
/// fires are VM state: a `/reload` with a book up replaces the frame tree, and the fresh
|
|
/// one needs the `ITEM_TEXT_BEGIN` and repaint the old one already consumed. Before this,
|
|
/// the reader never came back after a reload while the host still believed it was open.
|
|
told: crate::ui_script::VmMemo<ItemTextTold>,
|
|
}
|
|
|
|
/// [`ReadSession::told`]'s payload — the per-VM fire latches.
|
|
#[derive(Default)]
|
|
struct ItemTextTold {
|
|
/// `ITEM_TEXT_BEGIN` fired (the reference fires it once per open, before the text lands).
|
|
begun: bool,
|
|
/// `ITEM_TEXT_READY` fired — the session is fully painted.
|
|
ready: bool,
|
|
}
|
|
|
|
/// The reader-session resource ([`ReadSession`]).
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct ItemTextOpen {
|
|
pub(crate) pending: Option<ReadSession>,
|
|
/// A [`ItemTextOpen::toggle_closed`] fired and the frame has not been told yet. The click
|
|
/// routes have no `UiScript` in reach, so the close event is handed to the feed — which owns
|
|
/// the script — rather than each caller re-implementing it.
|
|
closing: bool,
|
|
}
|
|
|
|
impl ItemTextOpen {
|
|
/// Open a read for a bag letter (dispatcher arm #6). Re-opening restarts the reference event
|
|
/// flow from `ITEM_TEXT_BEGIN`.
|
|
pub(crate) fn open_letter(&mut self, item_guid: u64, text_id: u32) {
|
|
self.open(item_guid, ReadSource::Letter { text_id });
|
|
}
|
|
|
|
/// Open a read for a page chain — a readable item template (arm #5) or a TEXT GameObject. The
|
|
/// page head is not passed in: the feed asks the object's template for it, like the reference.
|
|
pub(crate) fn open_pages(&mut self, object_guid: u64) {
|
|
self.open(
|
|
object_guid,
|
|
ReadSource::Pages {
|
|
visited: Vec::new(),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The reference's **toggle** (`0x4e32e0`'s `arg2 == 0` head, which *every* click route passes —
|
|
/// the bag readable at `0x5d8e5e` and the TEXT GameObject at `0x5f58e7` both `xor edx,edx`):
|
|
/// re-clicking the readable whose reader is already open *closes* it. Returns whether it fired,
|
|
/// so the caller stops there.
|
|
pub(crate) fn toggle_closed(&mut self, object_guid: u64) -> bool {
|
|
let open = self
|
|
.pending
|
|
.as_ref()
|
|
.is_some_and(|s| s.object_guid == object_guid);
|
|
if open {
|
|
self.pending = None;
|
|
self.closing = true;
|
|
}
|
|
open
|
|
}
|
|
|
|
/// Take the pending toggle-close, for the feed to turn into `ITEM_TEXT_CLOSED`.
|
|
fn take_closing(&mut self) -> bool {
|
|
std::mem::take(&mut self.closing)
|
|
}
|
|
|
|
fn open(&mut self, object_guid: u64, source: ReadSource) {
|
|
self.pending = Some(ReadSession {
|
|
object_guid,
|
|
source,
|
|
told: Default::default(),
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) struct UiItemTextPlugin;
|
|
|
|
impl Plugin for UiItemTextPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<ItemTextOpen>()
|
|
.init_resource::<PageTexts>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
// Feed before the input pass so an open paints the same frame; drain after it so
|
|
// a Close click clears the same frame (the ui_mail ordering).
|
|
feed_item_text.before(UiInput),
|
|
drain_item_text.after(UiInput),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// What the reader's *object* answers, whichever source its text comes from: the window title
|
|
/// (`ItemTextGetItem 0x4e38f0` → the looked-up object's `vtbl+0x70` name) and the frame material
|
|
/// (`ItemTextGetMaterial 0x4e39f0` → an item template's `PageMaterial`, a GameObject's `data[2]`),
|
|
/// plus the page head a page chain starts at. `None` = the template is still in flight.
|
|
struct Readable {
|
|
title: String,
|
|
/// `PageTextMaterial.dbc` id → basename; `None` = the Lua's Parchment default.
|
|
material: Option<String>,
|
|
/// The first `PageText` id — `0` for a letter (which has no page chain) and for a readable
|
|
/// whose template carries no page.
|
|
page_head: u32,
|
|
}
|
|
|
|
/// Resolve the object's title + material + page head, whether it is an item or a GameObject. The
|
|
/// reference does exactly this — one guid lookup with typemask 1, then virtual getters — so a
|
|
/// single resolve serves both sources.
|
|
fn readable(
|
|
guid: u64,
|
|
items: &mut Items,
|
|
go_templates: &GameObjectTemplates,
|
|
materials: Option<&PageMaterials>,
|
|
commands: &NetCommands,
|
|
) -> Option<Readable> {
|
|
let name = |id: u32| materials.and_then(|m| m.0.name(id)).map(str::to_string);
|
|
if let Some(go) = go_templates.get(guid) {
|
|
return Some(Readable {
|
|
title: go.name.clone(),
|
|
material: go.text_page.and_then(|p| name(p.material)),
|
|
page_head: go.text_page.map_or(0, |p| p.page_id),
|
|
});
|
|
}
|
|
let entry = items.object(guid)?.object_entry()?;
|
|
let t = items.template(entry, guid, commands)?;
|
|
Some(Readable {
|
|
title: t.name.clone(),
|
|
material: name(t.page_material),
|
|
page_head: t.page_text,
|
|
})
|
|
}
|
|
|
|
/// Drive the open session to `ITEM_TEXT_BEGIN`/`ITEM_TEXT_READY` as its pieces land. BEGIN holds
|
|
/// until the object's template resolves — it carries the title *and* the material, and the Lua's
|
|
/// BEGIN handler picks the page font and text colour off the material, so firing it early would
|
|
/// paint a Stone plaque in parchment ink. READY holds until the body and, for a letter, the creator
|
|
/// line are both in. (The window itself only shows on READY — `ItemTextFrame.lua` calls
|
|
/// `ShowUIPanel` there, not on BEGIN.)
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn feed_item_text(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut open: ResMut<ItemTextOpen>,
|
|
mut mail: ResMut<MailOpen>,
|
|
mut pages: ResMut<PageTexts>,
|
|
mut items: ResMut<Items>,
|
|
mut names: ResMut<NameCache>,
|
|
go_templates: Res<GameObjectTemplates>,
|
|
materials: Option<Res<PageMaterials>>,
|
|
commands: Res<NetCommands>,
|
|
// The `$`-macro subject for the page body: the local player, as at every panel seam.
|
|
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
|
states: Res<crate::world_state::WorldStates>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
// A re-click closed the reader (the reference's toggle → its `0x128`). The click route cleared
|
|
// the session; the frame is told here, where the script is.
|
|
if open.take_closing() {
|
|
script.set_item_text(None);
|
|
script.fire_event("ITEM_TEXT_CLOSED", vec![]);
|
|
}
|
|
let Some(sess) = open.pending.as_mut() else {
|
|
return;
|
|
};
|
|
if sess.told.get(&script).ready {
|
|
return;
|
|
}
|
|
let Some(readable) = readable(
|
|
sess.object_guid,
|
|
&mut items,
|
|
&go_templates,
|
|
materials.as_deref(),
|
|
&commands,
|
|
) else {
|
|
return; // template in flight — the reference re-enters the whole open when it lands
|
|
};
|
|
|
|
// A page read whose template answers with no page has nothing to show — the reference bails
|
|
// before firing anything (`0x4e341d`, both text sources zero). Drop the session silently.
|
|
if matches!(sess.source, ReadSource::Pages { .. }) && readable.page_head == 0 {
|
|
debug!(
|
|
"item text: {:#x} has no page to read — closing",
|
|
sess.object_guid
|
|
);
|
|
open.pending = None;
|
|
return;
|
|
}
|
|
|
|
if !sess.told.get(&script).begun {
|
|
script.set_item_text(Some(ItemTextState {
|
|
item: readable.title.clone(),
|
|
creator: None,
|
|
text: String::new(),
|
|
page: 1,
|
|
has_next: false,
|
|
material: readable.material.clone(),
|
|
}));
|
|
script.fire_event("ITEM_TEXT_BEGIN", vec![]);
|
|
sess.told.get(&script).begun = true;
|
|
}
|
|
|
|
let (creator, text, page, has_next) = match &mut sess.source {
|
|
ReadSource::Letter { text_id } => {
|
|
// The creator line: `ITEM_FIELD_CREATOR` → name cache (ask-once). `None` guid =
|
|
// authorless.
|
|
let creator_guid = items
|
|
.object(sess.object_guid)
|
|
.and_then(|o| o.item_creator());
|
|
let creator = match creator_guid {
|
|
None => None, // authorless
|
|
Some(guid) => match names.resolve(guid, &commands) {
|
|
Some(name) => Some(name.to_string()),
|
|
None => return, // name query in flight
|
|
},
|
|
};
|
|
// The body: the shared ask-once item-text cache (fetch if this is the first asker).
|
|
let body = mail.bodies.get(text_id).cloned();
|
|
if body.is_none() && mail.pending_bodies.insert(*text_id) {
|
|
let _ = commands.0.send(ClientCommand::ItemTextQuery {
|
|
text_id: *text_id,
|
|
mail_id: 0,
|
|
});
|
|
}
|
|
let Some(text) = body else {
|
|
return; // body query in flight
|
|
};
|
|
(creator, text, 1, false)
|
|
}
|
|
ReadSource::Pages { visited } => {
|
|
if visited.is_empty() {
|
|
visited.push(readable.page_head);
|
|
}
|
|
let page_id = *visited.last().expect("seeded just above");
|
|
let Some(page) = pages.get_or_ask(page_id, sess.object_guid, &commands) else {
|
|
return; // page query in flight
|
|
};
|
|
// A book is authorless — the creator leg is the reference's item-instance one.
|
|
(
|
|
None,
|
|
page.text.clone(),
|
|
visited.len() as u32,
|
|
page.next != 0,
|
|
)
|
|
}
|
|
};
|
|
|
|
// Page/book text is server-authored, so it runs the `$`-macro expander — the reference does it
|
|
// from two sites in `ItemTextFrame.cpp` (decision 0754), subject = the local player.
|
|
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
|
let text = crate::npc_text::substitute(
|
|
&text,
|
|
&crate::npc_text::MacroContext {
|
|
subject: subject.as_ref(),
|
|
states: &states,
|
|
},
|
|
);
|
|
script.set_item_text(Some(ItemTextState {
|
|
item: readable.title,
|
|
creator,
|
|
text,
|
|
page,
|
|
has_next,
|
|
material: readable.material,
|
|
}));
|
|
script.fire_event("ITEM_TEXT_READY", vec![]);
|
|
sess.told.get(&script).ready = true;
|
|
}
|
|
|
|
/// `PageTextMaterial.dbc` as a resource (decision 1105) — the reader frame's material basename,
|
|
/// loaded once at startup ([`crate::entities`]); absent when the client data is, in which case every
|
|
/// readable falls to the Lua's Parchment default.
|
|
#[derive(Resource)]
|
|
pub(crate) struct PageMaterials(pub(crate) benilla_formats::PageTextMaterialCatalog);
|
|
|
|
/// Drain the reader's intents: `CloseItemText()` clears the session (+ `ITEM_TEXT_CLOSED`, the
|
|
/// reference C-side answer the frame's OnHide relies on); a page turn walks the chain — Next
|
|
/// appends the current page's `nextPageId` to the trail, Prev pops back — and re-runs the feed from
|
|
/// `ITEM_TEXT_READY` (the reference repaints the same frame, it does not re-fire BEGIN).
|
|
fn drain_item_text(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut open: ResMut<ItemTextOpen>,
|
|
pages: Res<PageTexts>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
for delta in script.take_item_text_page_turns() {
|
|
let Some(sess) = open.pending.as_mut() else {
|
|
continue;
|
|
};
|
|
let ReadSource::Pages { visited } = &mut sess.source else {
|
|
continue; // a letter has no pages; its buttons never show
|
|
};
|
|
if turn_page(visited, delta, |id| pages.pages.get(&id).map(|p| p.next)) {
|
|
// Repaint on the next feed, without re-firing BEGIN (within this VM; a fresh VM
|
|
// re-begins regardless, which is the reload repaint).
|
|
sess.told.get(&script).ready = false;
|
|
}
|
|
}
|
|
if script.take_item_text_close() && open.pending.take().is_some() {
|
|
script.set_item_text(None);
|
|
script.fire_event("ITEM_TEXT_CLOSED", vec![]);
|
|
}
|
|
}
|
|
|
|
/// Walk the visited-page trail one step. A `PageText` chain only links **forwards**, so Prev is a
|
|
/// pop off the trail rather than a lookup — the reference keeps the same array (`[0xbc3fd0]`).
|
|
/// Returns whether the page actually changed (a no-op turn must not repaint).
|
|
///
|
|
/// `next_of` is the page cache: `None` = the page hasn't landed yet, `Some(0)` = last page. Both
|
|
/// refuse the turn; neither can normally be clicked, since the Next button only shows while the
|
|
/// painted page reported a next.
|
|
fn turn_page(visited: &mut Vec<u32>, delta: i32, next_of: impl Fn(u32) -> Option<u32>) -> bool {
|
|
let Some(¤t) = visited.last() else {
|
|
return false; // head not resolved yet — no page is painted to turn from
|
|
};
|
|
if delta > 0 {
|
|
match next_of(current) {
|
|
Some(next) if next != 0 => visited.push(next),
|
|
_ => return false,
|
|
}
|
|
} else if visited.len() > 1 {
|
|
visited.pop();
|
|
} else {
|
|
return false; // already on page 1
|
|
}
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A three-page book: 100 → 101 → 102. Next walks it, Prev walks back, and the ends refuse.
|
|
#[test]
|
|
fn page_turns_walk_the_chain_both_ways() {
|
|
let chain = |id: u32| match id {
|
|
100 => Some(101),
|
|
101 => Some(102),
|
|
102 => Some(0), // last page
|
|
_ => None,
|
|
};
|
|
let mut visited = vec![100];
|
|
assert!(!turn_page(&mut visited, -1, chain), "page 1 has no Prev");
|
|
assert_eq!(visited, [100]);
|
|
|
|
assert!(turn_page(&mut visited, 1, chain));
|
|
assert!(turn_page(&mut visited, 1, chain));
|
|
assert_eq!(visited, [100, 101, 102]);
|
|
assert!(
|
|
!turn_page(&mut visited, 1, chain),
|
|
"the last page has no Next"
|
|
);
|
|
|
|
assert!(turn_page(&mut visited, -1, chain));
|
|
assert_eq!(visited, [100, 101], "Prev pops the forward-only trail");
|
|
}
|
|
|
|
/// A Next clicked before the page landed is refused rather than pushing a bogus id.
|
|
#[test]
|
|
fn a_page_still_in_flight_refuses_the_turn() {
|
|
let mut visited = vec![7];
|
|
assert!(!turn_page(&mut visited, 1, |_| None));
|
|
assert_eq!(visited, [7]);
|
|
}
|
|
|
|
/// The reference's toggle: re-clicking the readable that is already open closes it; a
|
|
/// *different* one does not.
|
|
#[test]
|
|
fn re_reading_the_same_object_toggles_closed() {
|
|
let mut open = ItemTextOpen::default();
|
|
open.open_pages(0xF110_0000_0000_0001);
|
|
assert!(!open.toggle_closed(0xF110_0000_0000_0002), "another book");
|
|
assert!(open.pending.is_some());
|
|
assert!(open.toggle_closed(0xF110_0000_0000_0001));
|
|
assert!(open.pending.is_none());
|
|
assert!(
|
|
open.take_closing(),
|
|
"the frame must be told — a cleared session alone leaves the window on screen"
|
|
);
|
|
assert!(!open.take_closing(), "drained");
|
|
assert!(!open.toggle_closed(0xF110_0000_0000_0001), "nothing open");
|
|
assert!(!open.take_closing(), "a no-op toggle closes nothing");
|
|
}
|
|
|
|
/// A page chain opens with an EMPTY trail — the head comes from the object's template as the
|
|
/// feed paints, so a click that beats the ask-once template query still opens a reader.
|
|
#[test]
|
|
fn a_page_read_opens_before_its_template_lands() {
|
|
let mut open = ItemTextOpen::default();
|
|
open.open_pages(0xF110_0000_0000_0001);
|
|
let sess = open.pending.as_ref().expect("open");
|
|
assert!(matches!(&sess.source, ReadSource::Pages { visited } if visited.is_empty()));
|
|
}
|
|
}
|