Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_residency/
engine.rs

1//! The residency engine: what is wanted, what is resident, what to read next
2//! and what to evict to make room.
3
4use crate::config::Config;
5use crate::host::{Eviction, Extent, Host, ReadOutcome, ReadRequest, RequestId, Submit};
6use crate::key::{Class, ResourceKey};
7use crate::pool::{Handle, PagePool, PageRun, Placement};
8
9const NO_ENTRY: usize = usize::MAX;
10
11/// One resource the engine wants resident this frame.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub struct Wanted {
14    /// The resource.
15    pub key: ResourceKey,
16    /// How urgent it is.
17    pub class: Class,
18    /// Walk distance from the viewer, in the engine's units. Nearer is more
19    /// urgent inside a class, and farther entries are evicted first.
20    pub distance: u32,
21    /// Ticks until the data is needed (`u32::MAX` when unknown). Earlier
22    /// deadlines are requested first inside a class.
23    pub deadline_ticks: u32,
24}
25
26impl Wanted {
27    /// A wanted entry with no deadline.
28    pub const fn new(key: ResourceKey, class: Class, distance: u32) -> Self {
29        Self {
30            key,
31            class,
32            distance,
33            deadline_ticks: u32::MAX,
34        }
35    }
36
37    /// The same entry with a deadline.
38    pub const fn with_deadline_ticks(mut self, deadline_ticks: u32) -> Self {
39        self.deadline_ticks = deadline_ticks;
40        self
41    }
42
43    const EMPTY: Wanted = Wanted::new(ResourceKey::from_u32(0).unwrap(), Class::Demand, 0);
44
45    fn order(&self) -> (Class, u32, u32, u32) {
46        (
47            self.class,
48            self.deadline_ticks,
49            self.distance,
50            self.key.as_u32(),
51        )
52    }
53}
54
55/// Lifecycle of one tracked resource.
56#[derive(Copy, Clone, Debug, PartialEq, Eq)]
57#[repr(u8)]
58pub enum State {
59    /// Not loaded, no pages (an entry exists only because it is pinned).
60    Absent = 0,
61    /// A read request is in flight into reserved pages.
62    Reading,
63    /// The bytes have landed but are not verified or installed yet; nothing
64    /// may use them. The grid-free equivalent of the old "stored" state.
65    Landed,
66    /// An install (verify, relocate, upload) is running on the landed bytes.
67    Installing,
68    /// Installed and usable.
69    Resident,
70    /// The last read or install failed; retried after a backoff.
71    Failed,
72}
73
74/// Counters for one [`Residency::step`] window.
75#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
76pub struct WindowStats {
77    /// Wanted entries examined.
78    pub requests: u32,
79    /// Of those, entries not resident.
80    pub misses: u32,
81    /// Wanted entries of class lead or lower.
82    pub prefetch_requests: u32,
83    /// Reads issued to the transport.
84    pub issued: u32,
85    /// Wanted entries whose bytes are read, landing or installing.
86    pub pending_loads: u32,
87    /// Wanted entries that were already in flight or landed though nothing
88    /// wanted them last window: their work was adopted, not repeated.
89    pub adopted: u32,
90    /// In-flight requests promoted to a more urgent class.
91    pub promoted: u32,
92    /// In-flight requests cancelled to make way for a demand.
93    pub cancelled: u32,
94    /// Resources evicted.
95    pub evictions: u32,
96    /// Reads and installs that failed since the previous window.
97    pub failed_loads: u32,
98    /// Requests that found no room even after evicting every allowed victim.
99    pub protected_full: u32,
100    /// Times issuing stopped because the transport was full.
101    pub transport_full: u32,
102    /// Requests skipped because the CPU budget ran out.
103    pub deferred: u32,
104    /// Misses skipped because the resource is inside its retry backoff.
105    pub backoff_skipped: u32,
106    /// Wanted keys the host does not know.
107    pub unknown_keys: u32,
108    /// Wanted keys whose category is not configured.
109    pub unconfigured: u32,
110    /// Requests that exceed their category's page budget outright.
111    pub over_budget: u32,
112    /// Requests dropped because the entry table was full.
113    pub table_full: u32,
114    /// Wanted entries beyond the table capacity, ignored.
115    pub wanted_dropped: u32,
116    /// Requests held back because the eviction report buffer was too small.
117    pub eviction_overflow: u32,
118    /// Runs moved by compaction.
119    pub compactions: u32,
120    /// Pages moved by compaction.
121    pub pages_moved: u32,
122    /// Bookkeeping work units spent (see the crate docs).
123    pub work_used: u32,
124}
125
126/// What a [`Residency::step`] did.
127#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
128pub struct StepReport {
129    /// This window's counters.
130    pub stats: WindowStats,
131    /// Entries written to the eviction buffer.
132    pub eviction_count: usize,
133}
134
135/// Counters since creation.
136#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
137pub struct Totals {
138    /// Windows stepped.
139    pub windows: u32,
140    /// Reads issued.
141    pub issued: u32,
142    /// Reads completed successfully.
143    pub completed: u32,
144    /// Reads and installs that failed.
145    pub failed_loads: u32,
146    /// Resources evicted.
147    pub evictions: u32,
148    /// Evicted resources that were never used since they landed (their read
149    /// was wasted).
150    pub wasted_evictions: u32,
151    /// Requests cancelled.
152    pub cancelled: u32,
153}
154
155#[derive(Copy, Clone)]
156struct Entry {
157    key: ResourceKey,
158    in_use: bool,
159    state: State,
160    class: Class,
161    pin_class: Class,
162    pin_count: u8,
163    failures: u8,
164    cancel_requested: bool,
165    used_since_landed: bool,
166    slot: u16,
167    request: u32,
168    hold_until: u32,
169    last_used: u32,
170    wanted_epoch: u32,
171    distance: u32,
172    distance_epoch: u32,
173    deadline_ticks: u32,
174    bytes: u32,
175    lba_end: u32,
176}
177
178struct Budget {
179    left: u32,
180    used: u32,
181}
182
183impl Budget {
184    fn charge(&mut self, units: u32) {
185        self.used = self.used.saturating_add(units);
186        self.left = self.left.saturating_sub(units);
187    }
188
189    fn is_exhausted(&self) -> bool {
190        self.left == 0
191    }
192}
193
194struct EvictionSink<'a> {
195    buffer: &'a mut [Eviction],
196    len: usize,
197}
198
199#[derive(Copy, Clone)]
200struct Requester {
201    class: Class,
202    distance: u32,
203}
204
205/// Cost of clearing a window, smaller is better: worst tier, victim count,
206/// inverse of the nearest victim's distance, newest victim's last use, first
207/// page.
208type WindowCost = (u8, u32, u32, u32, u32);
209
210#[derive(Copy, Clone)]
211struct RunInfo {
212    first: u32,
213    count: u32,
214    entry: usize,
215    tier: Option<u8>,
216    distance: u32,
217    last_used: u32,
218}
219
220const EMPTY_RUN_INFO: RunInfo = RunInfo {
221    first: 0,
222    count: 0,
223    entry: NO_ENTRY,
224    tier: None,
225    distance: 0,
226    last_used: 0,
227};
228
229/// Residency of streamed resources over page pools.
230///
231/// `ENTRIES` is how many resources it can track at once (resident, in flight,
232/// landed, backing off or pinned), `SLOTS` the most simultaneous allocations
233/// in any one pool, `CATEGORIES` the number of category numbers and pools. The
234/// whole state is plain integers sized by those three constants, so a static
235/// lives in `.bss` and nothing allocates.
236///
237/// See the crate docs for the frame loop. In short: each frame the engine
238/// calls [`Residency::step`] with what it wants; the step issues reads through
239/// the [`Host`], evicts what must go and reports it; the transport reports
240/// outcomes with [`Residency::complete_read`]; the game installs landed
241/// payloads with [`Residency::begin_install`] and
242/// [`Residency::finish_install`].
243pub struct Residency<const ENTRIES: usize, const SLOTS: usize, const CATEGORIES: usize> {
244    config: Config<CATEGORIES>,
245    pools: [PagePool<SLOTS>; CATEGORIES],
246    /// Per pool, per slot: entry index plus one (0 means none).
247    owner: [[u16; SLOTS]; CATEGORIES],
248    entries: [Entry; ENTRIES],
249    category_pages: [u32; CATEGORIES],
250    category_entries: [u16; CATEGORIES],
251    epoch: u32,
252    next_request: u32,
253    in_flight: u32,
254    failed_mark: u32,
255    totals: Totals,
256}
257
258impl<const ENTRIES: usize, const SLOTS: usize, const CATEGORIES: usize>
259    Residency<ENTRIES, SLOTS, CATEGORIES>
260{
261    const EMPTY_POOL: PagePool<SLOTS> = PagePool::new(0, 0, Placement::FirstFit);
262    const EMPTY_ENTRY: Entry = Entry {
263        key: ResourceKey::from_u32(0).unwrap(),
264        in_use: false,
265        state: State::Absent,
266        class: Class::Demand,
267        pin_class: Class::Demand,
268        pin_count: 0,
269        failures: 0,
270        cancel_requested: false,
271        used_since_landed: false,
272        slot: 0,
273        request: 0,
274        hold_until: 0,
275        last_used: 0,
276        wanted_epoch: 0,
277        distance: 0,
278        distance_epoch: 0,
279        deadline_ticks: u32::MAX,
280        bytes: 0,
281        lba_end: 0,
282    };
283
284    /// An empty engine for `config`. `const`, so a static can hold it.
285    pub const fn new(config: Config<CATEGORIES>) -> Self {
286        const { assert!(ENTRIES < u16::MAX as usize && SLOTS < u16::MAX as usize) };
287        let mut pools = [Self::EMPTY_POOL; CATEGORIES];
288        let mut pool = 0;
289        while pool < CATEGORIES {
290            pools[pool] = PagePool::new(
291                pool as u8,
292                config.pools[pool].capacity_pages,
293                config.pools[pool].placement,
294            );
295            pool += 1;
296        }
297        Self {
298            config,
299            pools,
300            owner: [[0; SLOTS]; CATEGORIES],
301            entries: [Self::EMPTY_ENTRY; ENTRIES],
302            category_pages: [0; CATEGORIES],
303            category_entries: [0; CATEGORIES],
304            epoch: 0,
305            next_request: 1,
306            in_flight: 0,
307            failed_mark: 0,
308            totals: Totals {
309                windows: 0,
310                issued: 0,
311                completed: 0,
312                failed_loads: 0,
313                evictions: 0,
314                wasted_evictions: 0,
315                cancelled: 0,
316            },
317        }
318    }
319
320    /// The configuration.
321    pub const fn config(&self) -> &Config<CATEGORIES> {
322        &self.config
323    }
324
325    /// Counters since creation.
326    pub const fn totals(&self) -> Totals {
327        self.totals
328    }
329
330    /// Pool number `pool`, for fragmentation and occupancy reports.
331    pub fn pool(&self, pool: usize) -> Option<&PagePool<SLOTS>> {
332        self.pools.get(pool)
333    }
334
335    /// Pages `category` currently holds.
336    pub fn category_pages(&self, category: crate::Category) -> u32 {
337        self.category_pages
338            .get(category.number() as usize)
339            .copied()
340            .unwrap_or(0)
341    }
342
343    /// Entries `category` currently holds pages for.
344    pub fn category_entry_count(&self, category: crate::Category) -> usize {
345        self.category_entries
346            .get(category.number() as usize)
347            .copied()
348            .unwrap_or(0) as usize
349    }
350
351    /// Resources currently tracked.
352    pub fn tracked_count(&self) -> usize {
353        self.entries.iter().filter(|entry| entry.in_use).count()
354    }
355
356    /// Requests in flight (including ones being cancelled).
357    pub const fn in_flight_count(&self) -> usize {
358        self.in_flight as usize
359    }
360
361    /// The scheduling window number: how many steps have run.
362    pub const fn window(&self) -> u32 {
363        self.epoch
364    }
365
366    // ---- lookups -------------------------------------------------------
367
368    fn find_entry(&self, key: ResourceKey) -> Option<usize> {
369        self.entries
370            .iter()
371            .position(|entry| entry.in_use && entry.key == key)
372    }
373
374    /// Lifecycle state of `key` ([`State::Absent`] when untracked).
375    pub fn state(&self, key: ResourceKey) -> State {
376        self.find_entry(key)
377            .map_or(State::Absent, |index| self.entries[index].state)
378    }
379
380    /// Whether `key` is installed and usable.
381    pub fn is_resident(&self, key: ResourceKey) -> bool {
382        self.state(key) == State::Resident
383    }
384
385    /// Handle of `key`'s allocation, in any state that holds pages.
386    pub fn handle(&self, key: ResourceKey) -> Option<Handle> {
387        let entry = &self.entries[self.find_entry(key)?];
388        self.entry_handle(entry)
389    }
390
391    /// Handle of `key`'s allocation when it is resident.
392    pub fn resident_handle(&self, key: ResourceKey) -> Option<Handle> {
393        let entry = &self.entries[self.find_entry(key)?];
394        (entry.state == State::Resident)
395            .then(|| self.entry_handle(entry))
396            .flatten()
397    }
398
399    /// Pages `handle` names, or `None` when it is stale. The one way to turn a
400    /// handle into bytes (with [`crate::PageBuffer::bytes`]); call it on every
401    /// use unless the entry is pinned.
402    pub fn try_run(&self, handle: Handle) -> Option<PageRun> {
403        self.pools.get(handle.pool() as usize)?.try_run(handle)
404    }
405
406    /// Payload length in bytes of `key` (0 when untracked).
407    pub fn payload_bytes(&self, key: ResourceKey) -> usize {
408        self.find_entry(key)
409            .map_or(0, |index| self.entries[index].bytes as usize)
410    }
411
412    /// The request in flight for `key`.
413    pub fn request_of(&self, key: ResourceKey) -> Option<RequestId> {
414        let entry = &self.entries[self.find_entry(key)?];
415        (entry.state == State::Reading).then_some(RequestId(entry.request))
416    }
417
418    fn entry_handle(&self, entry: &Entry) -> Option<Handle> {
419        let pool = self.pool_of_key(entry.key)?;
420        self.pools[pool].handle_of_slot(entry.slot)
421    }
422
423    fn pool_of_key(&self, key: ResourceKey) -> Option<usize> {
424        let category = key.category().number() as usize;
425        let config = self.config.categories.get(category)?;
426        (config.max_entries > 0 && (config.pool as usize) < CATEGORIES)
427            .then_some(config.pool as usize)
428    }
429
430    fn holds_pages(state: State) -> bool {
431        matches!(
432            state,
433            State::Reading | State::Landed | State::Installing | State::Resident
434        )
435    }
436
437    fn hold_active(&self, entry: &Entry) -> bool {
438        entry.failures > 0 && (entry.hold_until.wrapping_sub(self.epoch) as i32) > 0
439    }
440
441    // ---- pins and use --------------------------------------------------
442
443    /// Pin `key`: it cannot be evicted or moved while pinned, and the next
444    /// steps request it at `class` if it is not resident. Pins nest (up to
445    /// 255). `false` when the category is not configured, the table is full or the pin count is saturated.
446    pub fn pin(&mut self, key: ResourceKey, class: Class) -> bool {
447        if self.pool_of_key(key).is_none() {
448            return false;
449        }
450        let Some(index) = self.ensure_entry(key) else {
451            return false;
452        };
453        let entry = &mut self.entries[index];
454        if entry.pin_count == u8::MAX {
455            return false;
456        }
457        entry.pin_class = if entry.pin_count == 0 {
458            class
459        } else {
460            entry.pin_class.min(class)
461        };
462        entry.pin_count += 1;
463        self.sync_lock(index);
464        true
465    }
466
467    /// Drop one pin on `key`. `false` when it was not pinned.
468    pub fn unpin(&mut self, key: ResourceKey) -> bool {
469        let Some(index) = self.find_entry(key) else {
470            return false;
471        };
472        if self.entries[index].pin_count == 0 {
473            return false;
474        }
475        self.entries[index].pin_count -= 1;
476        self.sync_lock(index);
477        self.release_if_idle(index);
478        true
479    }
480
481    /// Pins held on `key`.
482    pub fn pin_count(&self, key: ResourceKey) -> u8 {
483        self.find_entry(key)
484            .map_or(0, |index| self.entries[index].pin_count)
485    }
486
487    /// Mark `key` as used now (refreshes its LRU age). Resident entries that
488    /// the wanted list names are refreshed by [`Residency::step`] already.
489    pub fn touch(&mut self, key: ResourceKey) {
490        if let Some(index) = self.find_entry(key) {
491            let entry = &mut self.entries[index];
492            entry.last_used = self.epoch;
493            entry.used_since_landed = true;
494        }
495    }
496
497    fn sync_lock(&mut self, index: usize) {
498        let entry = self.entries[index];
499        if !Self::holds_pages(entry.state) {
500            return;
501        }
502        let Some(pool) = self.pool_of_key(entry.key) else {
503            return;
504        };
505        let category = entry.key.category().number() as usize;
506        let locked = matches!(entry.state, State::Reading | State::Installing)
507            || entry.pin_count > 0
508            || !self.config.categories[category].movable;
509        if let Some(handle) = self.pools[pool].handle_of_slot(entry.slot) {
510            self.pools[pool].set_locked(handle, locked);
511        }
512    }
513
514    // ---- table management ------------------------------------------------
515
516    fn ensure_entry(&mut self, key: ResourceKey) -> Option<usize> {
517        if let Some(index) = self.find_entry(key) {
518            return Some(index);
519        }
520        let free = self
521            .entries
522            .iter()
523            .position(|entry| !entry.in_use)
524            .or_else(|| {
525                // Reclaim a backed-off entry nothing wants, expired holds first.
526                let mut best: Option<(bool, usize)> = None;
527                for (index, entry) in self.entries.iter().enumerate() {
528                    if entry.state == State::Failed
529                        && entry.pin_count == 0
530                        && entry.wanted_epoch != self.epoch
531                    {
532                        let expired = !self.hold_active(entry);
533                        if best.is_none_or(|(best_expired, _)| expired && !best_expired) {
534                            best = Some((expired, index));
535                        }
536                    }
537                }
538                best.map(|(_, index)| index)
539            })?;
540        self.entries[free] = Self::EMPTY_ENTRY;
541        self.entries[free].in_use = true;
542        self.entries[free].key = key;
543        Some(free)
544    }
545
546    fn release_if_idle(&mut self, index: usize) {
547        let entry = &self.entries[index];
548        if entry.in_use
549            && entry.state == State::Absent
550            && entry.pin_count == 0
551            && entry.failures == 0
552        {
553            self.entries[index] = Self::EMPTY_ENTRY;
554        }
555    }
556
557    /// Free the pages of `index` and clear its slot bookkeeping. Returns the
558    /// handle and run it held.
559    fn free_pages_of(&mut self, index: usize) -> Option<(Handle, PageRun)> {
560        let entry = self.entries[index];
561        let pool = self.pool_of_key(entry.key)?;
562        let handle = self.pools[pool].handle_of_slot(entry.slot)?;
563        let run = self.pools[pool].try_run(handle)?;
564        self.pools[pool].free(handle);
565        self.owner[pool][entry.slot as usize] = 0;
566        let category = entry.key.category().number() as usize;
567        self.category_pages[category] -= run.page_count;
568        self.category_entries[category] -= 1;
569        Some((handle, run))
570    }
571
572    // ---- transport and install callbacks ---------------------------------
573
574    /// The transport finished `request`. Returns `false` when the id is not an
575    /// in-flight request (already reported, or never issued).
576    ///
577    /// A `Done` that arrives after the engine asked to cancel is kept: the
578    /// bytes are good, so the cancelled work is adopted.
579    pub fn complete_read(&mut self, request: RequestId, outcome: ReadOutcome) -> bool {
580        let Some(index) = self.entries.iter().position(|entry| {
581            entry.in_use && entry.state == State::Reading && entry.request == request.0
582        }) else {
583            return false;
584        };
585        self.in_flight -= 1;
586        self.entries[index].cancel_requested = false;
587        match outcome {
588            ReadOutcome::Done => {
589                let category = self.entries[index].key.category().number() as usize;
590                let installs = self.config.categories[category].installs;
591                let entry = &mut self.entries[index];
592                entry.failures = 0;
593                entry.hold_until = self.epoch;
594                entry.state = if installs {
595                    State::Landed
596                } else {
597                    State::Resident
598                };
599                entry.last_used = self.epoch;
600                entry.used_since_landed = false;
601                self.totals.completed += 1;
602                self.sync_lock(index);
603            }
604            ReadOutcome::Failed => self.fail_entry(index),
605            ReadOutcome::Cancelled => {
606                self.totals.cancelled += 1;
607                self.free_pages_of(index);
608                self.entries[index].state = State::Absent;
609                self.release_if_idle(index);
610            }
611        }
612        true
613    }
614
615    fn fail_entry(&mut self, index: usize) {
616        self.free_pages_of(index);
617        let backoff = self.config.backoff;
618        let epoch = self.epoch;
619        let entry = &mut self.entries[index];
620        entry.failures = entry.failures.saturating_add(1);
621        entry.hold_until = epoch.wrapping_add(backoff.windows(entry.failures));
622        entry.state = State::Failed;
623        self.totals.failed_loads += 1;
624    }
625
626    /// Start installing the landed payload of `key`: it is held in place and
627    /// invisible until [`Residency::finish_install`]. Returns the allocation to
628    /// install into, or `None` when `key` has not landed.
629    pub fn begin_install(&mut self, key: ResourceKey) -> Option<Handle> {
630        let index = self.find_entry(key)?;
631        if self.entries[index].state != State::Landed {
632            return None;
633        }
634        self.entries[index].state = State::Installing;
635        self.sync_lock(index);
636        let entry = self.entries[index];
637        self.entry_handle(&entry)
638    }
639
640    /// The install succeeded: `key` is resident.
641    pub fn finish_install(&mut self, key: ResourceKey) -> bool {
642        let Some(index) = self.find_entry(key) else {
643            return false;
644        };
645        if self.entries[index].state != State::Installing {
646            return false;
647        }
648        let entry = &mut self.entries[index];
649        entry.state = State::Resident;
650        entry.last_used = self.epoch;
651        self.sync_lock(index);
652        true
653    }
654
655    /// Abandon the install without blame (for example the payload is no longer
656    /// wanted): the pages are freed and the resource is simply absent.
657    pub fn abort_install(&mut self, key: ResourceKey) -> bool {
658        let Some(index) = self.find_entry(key) else {
659            return false;
660        };
661        if self.entries[index].state != State::Installing {
662            return false;
663        }
664        self.free_pages_of(index);
665        self.entries[index].state = State::Absent;
666        self.release_if_idle(index);
667        true
668    }
669
670    /// The install failed (bad checksum, malformed payload): the pages are
671    /// freed and the resource backs off before the next read.
672    pub fn fail_install(&mut self, key: ResourceKey) -> bool {
673        let Some(index) = self.find_entry(key) else {
674            return false;
675        };
676        if self.entries[index].state != State::Installing {
677            return false;
678        }
679        self.fail_entry(index);
680        true
681    }
682
683    /// The landed payload to install next: `demand` if it has landed,
684    /// otherwise the most urgent landed payload the last step wanted.
685    pub fn next_install(&self, demand: Option<ResourceKey>) -> Option<ResourceKey> {
686        if let Some(key) = demand {
687            if self.state(key) == State::Landed {
688                return Some(key);
689            }
690        }
691        self.entries
692            .iter()
693            .filter(|entry| {
694                entry.in_use && entry.state == State::Landed && entry.wanted_epoch == self.epoch
695            })
696            .min_by_key(|entry| {
697                (
698                    entry.class,
699                    entry.deadline_ticks,
700                    entry.distance,
701                    entry.key.as_u32(),
702                )
703            })
704            .map(|entry| entry.key)
705    }
706
707    /// Evict every resident or landed, unpinned resource not in `keep`.
708    /// Returns how many were evicted; stops early when `evictions` is full
709    /// (call again). Reading and installing entries are never touched.
710    pub fn evict_unlisted(&mut self, keep: &[ResourceKey], evictions: &mut [Eviction]) -> usize {
711        let mut sink = EvictionSink {
712            buffer: evictions,
713            len: 0,
714        };
715        for index in 0..ENTRIES {
716            let entry = self.entries[index];
717            if !entry.in_use
718                || !matches!(entry.state, State::Resident | State::Landed)
719                || entry.pin_count > 0
720                || keep.contains(&entry.key)
721            {
722                continue;
723            }
724            if sink.len == sink.buffer.len() {
725                break;
726            }
727            self.evict_entry(index, &mut sink);
728        }
729        sink.len
730    }
731
732    fn evict_entry(&mut self, index: usize, sink: &mut EvictionSink<'_>) {
733        let entry = self.entries[index];
734        if let Some((handle, run)) = self.free_pages_of(index) {
735            sink.buffer[sink.len] = Eviction {
736                key: entry.key,
737                handle,
738                run,
739            };
740            sink.len += 1;
741        }
742        self.totals.evictions += 1;
743        if !entry.used_since_landed {
744            self.totals.wasted_evictions += 1;
745        }
746        self.entries[index].state = State::Absent;
747        self.entries[index].failures = 0;
748        self.release_if_idle(index);
749    }
750
751    // ---- the frame step ------------------------------------------------------
752
753    /// Run one scheduling window.
754    ///
755    /// `wanted` is everything the game wants resident now, in any order; the
756    /// engine orders it by class, deadline and distance. Pinned resources are
757    /// added at their pin class. Misses are issued to `host` nearest first;
758    /// room is made by moving pages or by evicting; evictions are written to
759    /// `evictions` and the step never evicts more than fits there. Apply them
760    /// (unlink, drop cached views) before reading resident data again.
761    pub fn step<H: Host>(
762        &mut self,
763        wanted: &[Wanted],
764        host: &mut H,
765        evictions: &mut [Eviction],
766    ) -> StepReport {
767        self.epoch = self.epoch.wrapping_add(1).max(1);
768        self.totals.windows += 1;
769        let mut stats = WindowStats {
770            failed_loads: self.totals.failed_loads - self.failed_mark,
771            ..WindowStats::default()
772        };
773        self.failed_mark = self.totals.failed_loads;
774        let mut budget = Budget {
775            left: self.config.step_budget,
776            used: 0,
777        };
778        let mut sink = EvictionSink {
779            buffer: evictions,
780            len: 0,
781        };
782
783        // Gather: pins first (they must not be truncated), then the list.
784        let mut items = [Wanted::EMPTY; ENTRIES];
785        let mut count = 0usize;
786        for entry in &self.entries {
787            if entry.in_use && entry.pin_count > 0 && count < ENTRIES {
788                items[count] = Wanted::new(entry.key, entry.pin_class, 0);
789                count += 1;
790            }
791        }
792        for want in wanted {
793            let configured = self
794                .config
795                .categories
796                .get(want.key.category().number() as usize)
797                .is_some_and(|category| category.max_entries > 0);
798            if !configured {
799                stats.unconfigured += 1;
800            } else if count == ENTRIES {
801                stats.wanted_dropped += 1;
802            } else {
803                items[count] = *want;
804                count += 1;
805            }
806        }
807        budget.charge(count as u32);
808        // Insertion sort: nearly sorted input (the usual case) costs O(n).
809        for i in 1..count {
810            let mut j = i;
811            while j > 0 && items[j].order() < items[j - 1].order() {
812                items.swap(j, j - 1);
813                j -= 1;
814                budget.charge(1);
815            }
816        }
817
818        // Pass 1: mark entries wanted, count misses, adopt, promote.
819        let mut needs_issue = [false; ENTRIES];
820        for n in 0..count {
821            let want = items[n];
822            let exempt = want.class <= Class::CombatFill;
823            if !exempt && budget.is_exhausted() {
824                stats.deferred += 1;
825                continue;
826            }
827            budget.charge(1);
828            stats.requests += 1;
829            if want.class >= Class::Lead {
830                stats.prefetch_requests += 1;
831            }
832            let Some(index) = self.find_entry(want.key) else {
833                stats.misses += 1;
834                needs_issue[n] = true;
835                continue;
836            };
837            let epoch = self.epoch;
838            let first_mark = self.entries[index].wanted_epoch != epoch;
839            if first_mark {
840                let entry = self.entries[index];
841                let wanted_last_window = entry.wanted_epoch.wrapping_add(1) == epoch;
842                if matches!(
843                    entry.state,
844                    State::Reading | State::Landed | State::Installing
845                ) && !wanted_last_window
846                {
847                    stats.adopted += 1;
848                }
849                if entry.state == State::Reading && want.class < entry.class {
850                    host.promote(RequestId(entry.request), want.class);
851                    stats.promoted += 1;
852                }
853                let entry = &mut self.entries[index];
854                entry.class = if entry.state == State::Reading {
855                    entry.class.min(want.class)
856                } else {
857                    want.class
858                };
859                entry.wanted_epoch = epoch;
860                entry.distance = want.distance;
861                entry.distance_epoch = epoch;
862                entry.deadline_ticks = want.deadline_ticks;
863            }
864            let entry = self.entries[index];
865            match entry.state {
866                State::Resident => {
867                    let entry = &mut self.entries[index];
868                    entry.last_used = epoch;
869                    entry.used_since_landed = true;
870                }
871                State::Reading | State::Landed | State::Installing => {
872                    stats.misses += 1;
873                    stats.pending_loads += 1;
874                }
875                State::Failed => {
876                    stats.misses += 1;
877                    if self.hold_active(&entry) {
878                        stats.backoff_skipped += 1;
879                    } else if first_mark {
880                        needs_issue[n] = true;
881                    }
882                }
883                State::Absent => {
884                    stats.misses += 1;
885                    if first_mark {
886                        needs_issue[n] = true;
887                    }
888                }
889            }
890        }
891
892        // Pass 2: issue reads, most urgent first.
893        for n in 0..count {
894            if !needs_issue[n] {
895                continue;
896            }
897            let want = items[n];
898            let exempt = want.class <= Class::CombatFill;
899            if !exempt && budget.is_exhausted() {
900                stats.deferred += 1;
901                continue;
902            }
903            if let Some(index) = self.find_entry(want.key) {
904                let entry = self.entries[index];
905                let idle = matches!(entry.state, State::Absent)
906                    || (entry.state == State::Failed && !self.hold_active(&entry));
907                if !idle {
908                    continue;
909                }
910            }
911            if self.active_in_flight() >= self.config.max_in_flight as usize
912                && !(self.config.cancel_for_demand
913                    && want.class == Class::Demand
914                    && self.cancel_least_urgent(want, host, &mut stats))
915            {
916                stats.transport_full += 1;
917                break;
918            }
919            if !host.can_accept() {
920                stats.transport_full += 1;
921                break;
922            }
923            let Some(extent) = host.extent(want.key) else {
924                stats.unknown_keys += 1;
925                continue;
926            };
927            budget.charge(1);
928            let Some(index) = self.ensure_entry(want.key) else {
929                stats.table_full += 1;
930                continue;
931            };
932            let requester = Requester {
933                class: want.class,
934                distance: want.distance,
935            };
936            let Some((handle, run)) = self.place(
937                want.key,
938                requester,
939                extent,
940                host,
941                &mut sink,
942                &mut budget,
943                &mut stats,
944            ) else {
945                stats.protected_full += 1;
946                self.release_if_idle(index);
947                continue;
948            };
949            let id = RequestId(self.next_request);
950            self.next_request = self.next_request.wrapping_add(1).max(1);
951            let contiguous_with = self
952                .entries
953                .iter()
954                .find(|other| {
955                    other.in_use
956                        && other.state == State::Reading
957                        && !other.cancel_requested
958                        && other.lba_end == extent.lba
959                })
960                .map(|other| RequestId(other.request));
961            let request = ReadRequest {
962                id,
963                key: want.key,
964                class: want.class,
965                handle,
966                run,
967                lba: extent.lba,
968                sectors: extent.sectors,
969                deadline_ticks: want.deadline_ticks,
970                contiguous_with,
971            };
972            if host.submit(&request) == Submit::Full {
973                let pool = handle.pool() as usize;
974                self.pools[pool].free(handle);
975                stats.transport_full += 1;
976                self.release_if_idle(index);
977                break;
978            }
979            let pool = handle.pool() as usize;
980            let category = want.key.category().number() as usize;
981            self.owner[pool][handle.slot() as usize] = index as u16 + 1;
982            self.category_pages[category] += run.page_count;
983            self.category_entries[category] += 1;
984            self.in_flight += 1;
985            self.totals.issued += 1;
986            stats.issued += 1;
987            let epoch = self.epoch;
988            let entry = &mut self.entries[index];
989            entry.state = State::Reading;
990            entry.slot = handle.slot();
991            entry.class = want.class;
992            entry.request = id.0;
993            entry.bytes = extent.bytes;
994            entry.lba_end = extent.lba + extent.sectors;
995            entry.cancel_requested = false;
996            entry.used_since_landed = false;
997            entry.last_used = epoch;
998            entry.wanted_epoch = epoch;
999            entry.distance = want.distance;
1000            entry.distance_epoch = epoch;
1001            entry.deadline_ticks = want.deadline_ticks;
1002            self.sync_lock(index);
1003        }
1004
1005        stats.work_used = budget.used;
1006        StepReport {
1007            stats: WindowStats {
1008                evictions: sink.len as u32,
1009                ..stats
1010            },
1011            eviction_count: sink.len,
1012        }
1013    }
1014
1015    fn active_in_flight(&self) -> usize {
1016        self.entries
1017            .iter()
1018            .filter(|entry| {
1019                entry.in_use && entry.state == State::Reading && !entry.cancel_requested
1020            })
1021            .count()
1022    }
1023
1024    fn cancel_least_urgent<H: Host>(
1025        &mut self,
1026        demand: Wanted,
1027        host: &mut H,
1028        stats: &mut WindowStats,
1029    ) -> bool {
1030        let mut worst: Option<(usize, (Class, u32, u32))> = None;
1031        for (index, entry) in self.entries.iter().enumerate() {
1032            if !entry.in_use
1033                || entry.state != State::Reading
1034                || entry.cancel_requested
1035                || entry.pin_count > 0
1036            {
1037                continue;
1038            }
1039            if entry.class <= demand.class {
1040                continue;
1041            }
1042            let rank = (entry.class, entry.deadline_ticks, entry.distance);
1043            if worst.is_none_or(|(_, best)| rank > best) {
1044                worst = Some((index, rank));
1045            }
1046        }
1047        let Some((index, _)) = worst else {
1048            return false;
1049        };
1050        host.cancel(RequestId(self.entries[index].request));
1051        self.entries[index].cancel_requested = true;
1052        stats.cancelled += 1;
1053        true
1054    }
1055
1056    // ---- placement and eviction ------------------------------------------------
1057
1058    fn entry_pages(&self, index: usize) -> u32 {
1059        let entry = &self.entries[index];
1060        self.pool_of_key(entry.key)
1061            .and_then(|pool| self.pools[pool].handle_of_slot(entry.slot))
1062            .and_then(|handle| self.try_run(handle))
1063            .map_or(0, |run| run.page_count)
1064    }
1065
1066    fn distance_of<H: Host>(&mut self, index: usize, host: &H, budget: &mut Budget) -> u32 {
1067        let entry = &mut self.entries[index];
1068        if entry.distance_epoch != self.epoch {
1069            budget.charge(1);
1070            entry.distance = host.walk_distance(entry.key);
1071            entry.distance_epoch = self.epoch;
1072        }
1073        entry.distance
1074    }
1075
1076    /// The eviction tier of `index` for `requester`, or `None` when it may
1077    /// not be evicted for it. 0: unwanted and beyond the keep radius. 1:
1078    /// unwanted inside the hysteresis band. 2: wanted this window, but at a
1079    /// lower priority than the requester.
1080    fn victim_tier<H: Host>(
1081        &mut self,
1082        index: usize,
1083        requester: Requester,
1084        host: &H,
1085        budget: &mut Budget,
1086    ) -> Option<u8> {
1087        let entry = self.entries[index];
1088        if !entry.in_use
1089            || !matches!(entry.state, State::Resident | State::Landed)
1090            || entry.pin_count > 0
1091        {
1092            return None;
1093        }
1094        let tier = if entry.wanted_epoch == self.epoch {
1095            if self.config.protect_wanted[entry.class.as_index()] {
1096                return None;
1097            }
1098            let lower = entry.class > requester.class
1099                || (entry.class == requester.class
1100                    && entry.distance
1101                        > requester
1102                            .distance
1103                            .saturating_add(self.config.displace_margin));
1104            if !lower {
1105                return None;
1106            }
1107            2
1108        } else {
1109            let distance = self.distance_of(index, host, budget);
1110            let category = entry.key.category().number() as usize;
1111            if distance > self.config.categories[category].keep_radius {
1112                0
1113            } else {
1114                1
1115            }
1116        };
1117        (tier <= self.config.max_victim_tier[requester.class.as_index()]).then_some(tier)
1118    }
1119
1120    /// The best victim among entries of `category` not already `chosen`:
1121    /// lowest tier, then farthest, then least recently used, then lowest key.
1122    fn best_victim<H: Host>(
1123        &mut self,
1124        category: usize,
1125        requester: Requester,
1126        chosen: &[bool; ENTRIES],
1127        host: &H,
1128        budget: &mut Budget,
1129    ) -> Option<usize> {
1130        let mut best: Option<(usize, (u8, u32, u32, u32))> = None;
1131        for (index, &taken) in chosen.iter().enumerate() {
1132            let entry = self.entries[index];
1133            if !entry.in_use
1134                || taken
1135                || entry.key.category().number() as usize != category
1136                || !Self::holds_pages(entry.state)
1137            {
1138                continue;
1139            }
1140            budget.charge(1);
1141            let Some(tier) = self.victim_tier(index, requester, host, budget) else {
1142                continue;
1143            };
1144            let rank = (
1145                tier,
1146                u32::MAX - self.entries[index].distance,
1147                entry.last_used,
1148                entry.key.as_u32(),
1149            );
1150            if best.is_none_or(|(_, best_rank)| rank < best_rank) {
1151                best = Some((index, rank));
1152            }
1153        }
1154        best.map(|(index, _)| index)
1155    }
1156
1157    /// Find or make room for `extent.pages` contiguous pages for `key`.
1158    #[allow(clippy::too_many_arguments)]
1159    fn place<H: Host>(
1160        &mut self,
1161        key: ResourceKey,
1162        requester: Requester,
1163        extent: Extent,
1164        host: &mut H,
1165        sink: &mut EvictionSink<'_>,
1166        budget: &mut Budget,
1167        stats: &mut WindowStats,
1168    ) -> Option<(Handle, PageRun)> {
1169        let category = key.category().number() as usize;
1170        let config = self.config.categories[category];
1171        let pool_number = self.pool_of_key(key)?;
1172        let pages = extent.pages;
1173        if pages == 0
1174            || pages > config.max_pages
1175            || pages > self.pools[pool_number].capacity_pages()
1176        {
1177            stats.over_budget += 1;
1178            return None;
1179        }
1180
1181        // 1. Category budget: choose own-category victims until it fits.
1182        let mut chosen = [false; ENTRIES];
1183        let mut chosen_count = 0usize;
1184        let mut held_pages = self.category_pages[category];
1185        let mut held_entries = self.category_entries[category];
1186        while held_pages + pages > config.max_pages || held_entries >= config.max_entries {
1187            let victim = self.best_victim(category, requester, &chosen, host, budget)?;
1188            chosen[victim] = true;
1189            chosen_count += 1;
1190            held_pages -= self.entry_pages(victim);
1191            held_entries -= 1;
1192        }
1193
1194        // 2. Free room already fits.
1195        if chosen_count == 0 && self.pools[pool_number].live_count() < SLOTS {
1196            if let Some(allocation) = self.pools[pool_number].allocate(pages) {
1197                return Some(allocation);
1198            }
1199            // 3. Enough free pages but scattered: slide movable runs down.
1200            while self.pools[pool_number].free_pages() >= pages
1201                && self.pools[pool_number].find_fit(pages).is_none()
1202                && !budget.is_exhausted()
1203            {
1204                let moved = self.pools[pool_number].compact_step(|from, to| {
1205                    host.move_pages(pool_number as u8, from.first_page, to, from.page_count);
1206                });
1207                let Some(moved) = moved else { break };
1208                stats.compactions += 1;
1209                stats.pages_moved += moved.from.page_count;
1210                budget.charge(1 + moved.from.page_count / 8);
1211            }
1212            if let Some(allocation) = self.pools[pool_number].allocate(pages) {
1213                return Some(allocation);
1214            }
1215        }
1216
1217        // 4. Evict: the cheapest contiguous window of evictable runs.
1218        let need_slot = self.pools[pool_number].live_count() >= SLOTS && chosen_count == 0;
1219        let mut info = [EMPTY_RUN_INFO; SLOTS];
1220        let mut live = 0usize;
1221        for (slot, run, _) in self.pools[pool_number].iter_runs() {
1222            let owner = self.owner[pool_number][slot as usize] as usize;
1223            info[live] = RunInfo {
1224                first: run.first_page,
1225                count: run.page_count,
1226                entry: if owner == 0 { NO_ENTRY } else { owner - 1 },
1227                tier: None,
1228                distance: 0,
1229                last_used: 0,
1230            };
1231            live += 1;
1232        }
1233        for run in info.iter_mut().take(live) {
1234            if run.entry == NO_ENTRY {
1235                continue;
1236            }
1237            // `chosen` entries are going anyway: free of charge.
1238            let tier = if chosen[run.entry] {
1239                Some(0)
1240            } else {
1241                self.victim_tier(run.entry, requester, host, budget)
1242            };
1243            run.tier = tier;
1244            run.distance = self.entries[run.entry].distance;
1245            run.last_used = self.entries[run.entry].last_used;
1246        }
1247        let capacity = self.pools[pool_number].capacity_pages();
1248        let mut best: Option<(WindowCost, usize, usize)> = None;
1249        for start in 0..=live {
1250            let left = if start == 0 {
1251                0
1252            } else {
1253                info[start - 1].first + info[start - 1].count
1254            };
1255            let mut end = start;
1256            let mut max_tier = 0u8;
1257            let mut min_distance = u32::MAX;
1258            let mut max_used = 0u32;
1259            loop {
1260                budget.charge(1);
1261                let right = if end < live {
1262                    info[end].first
1263                } else {
1264                    capacity
1265                };
1266                let victims = end - start;
1267                if right - left >= pages && (victims > 0 || !need_slot) {
1268                    let cost = (
1269                        max_tier,
1270                        victims as u32,
1271                        if victims == 0 {
1272                            0
1273                        } else {
1274                            u32::MAX - min_distance
1275                        },
1276                        max_used,
1277                        left,
1278                    );
1279                    if best.is_none_or(|(best_cost, _, _)| cost < best_cost) {
1280                        best = Some((cost, start, end));
1281                    }
1282                    break;
1283                }
1284                if end >= live {
1285                    break;
1286                }
1287                let Some(tier) = info[end].tier else { break };
1288                max_tier = max_tier.max(tier);
1289                min_distance = min_distance.min(info[end].distance);
1290                max_used = max_used.max(info[end].last_used);
1291                end += 1;
1292            }
1293        }
1294        let (_, start, end) = best?;
1295        for run in &info[start..end] {
1296            if !chosen[run.entry] {
1297                chosen[run.entry] = true;
1298                chosen_count += 1;
1299            }
1300        }
1301        if chosen_count > sink.buffer.len() - sink.len {
1302            stats.eviction_overflow += 1;
1303            return None;
1304        }
1305        for (index, &victim) in chosen.iter().enumerate() {
1306            if victim {
1307                self.evict_entry(index, sink);
1308            }
1309        }
1310        let allocation = self.pools[pool_number].allocate(pages);
1311        debug_assert!(allocation.is_some(), "a window was cleared for this run");
1312        allocation
1313    }
1314
1315    /// Check every cross-structure invariant (host tests and debug builds).
1316    #[cfg(any(test, debug_assertions))]
1317    pub fn check_invariants(&self) {
1318        for pool in &self.pools {
1319            pool.check_invariants();
1320        }
1321        let mut pages = [0u32; CATEGORIES];
1322        let mut entries = [0u16; CATEGORIES];
1323        let mut reading = 0u32;
1324        for (index, entry) in self.entries.iter().enumerate() {
1325            if !entry.in_use {
1326                continue;
1327            }
1328            if entry.state == State::Reading {
1329                reading += 1;
1330            }
1331            if Self::holds_pages(entry.state) {
1332                let pool = self.pool_of_key(entry.key).expect("a configured category");
1333                let handle = self.pools[pool]
1334                    .handle_of_slot(entry.slot)
1335                    .expect("entry holds a live slot");
1336                let run = self.pools[pool].try_run(handle).unwrap();
1337                assert_eq!(self.owner[pool][entry.slot as usize] as usize, index + 1);
1338                let category = entry.key.category().number() as usize;
1339                pages[category] += run.page_count;
1340                entries[category] += 1;
1341                assert!(run.page_count >= entry.bytes.div_ceil(crate::PAGE_BYTES as u32));
1342            } else {
1343                assert!(
1344                    entry.state != State::Resident,
1345                    "resident entry without pages"
1346                );
1347            }
1348            // No two entries share a key.
1349            for other in &self.entries[index + 1..] {
1350                assert!(!(other.in_use && other.key == entry.key), "duplicate key");
1351            }
1352        }
1353        assert_eq!(pages, self.category_pages);
1354        assert_eq!(entries, self.category_entries);
1355        assert_eq!(reading, self.in_flight);
1356        for category in 0..CATEGORIES {
1357            let config = self.config.categories[category];
1358            assert!(self.category_pages[category] <= config.max_pages);
1359            assert!(self.category_entries[category] <= config.max_entries);
1360        }
1361        for (number, pool) in self.pools.iter().enumerate() {
1362            assert!(pool.used_pages() <= pool.capacity_pages());
1363            let live = self.owner[number]
1364                .iter()
1365                .enumerate()
1366                .filter(|(slot, owner)| **owner != 0 && pool.handle_of_slot(*slot as u16).is_some())
1367                .count();
1368            assert_eq!(live, pool.live_count(), "owner map out of step");
1369        }
1370    }
1371}