ktstr/monitor/btf_render/
mod.rs

1//! BTF-driven rendering of raw value bytes into structured output.
2//!
3//! [`render_value`] takes a BTF type id and a byte slice and produces
4//! a [`RenderedValue`] tree that mirrors the type's structure: ints,
5//! floats, enums, structs, arrays, pointers. Modifier qualifiers
6//! ([`btf_rs::Type::Volatile`], [`btf_rs::Type::Const`],
7//! [`btf_rs::Type::Restrict`], [`btf_rs::Type::Typedef`],
8//! [`btf_rs::Type::TypeTag`], [`btf_rs::Type::DeclTag`]) are peeled
9//! before dispatch. [`render_value_with_mem`] is the production
10//! entry point that additionally accepts a [`MemReader`] so the
11//! [`btf_rs::Type::Ptr`] arm and the cast-intercept path
12//! (consulting [`MemReader::cast_lookup`]) chase pointers through
13//! arena snapshots, slab/vmalloc reads, the sdt_alloc bridge, and
14//! the cross-BTF Fwd resolution index.
15//!
16//! The renderer is total: any type kind it cannot decode (Func, FuncProto,
17//! Fwd, Void, or a bytes slice shorter than the type's declared size)
18//! yields a [`RenderedValue::Unsupported`] or [`RenderedValue::Truncated`]
19//! node so the caller always gets a well-formed tree it can serialize.
20//!
21//! `BTF_KIND_DATASEC` (the type libbpf assigns as the value type of a
22//! global-section ARRAY map like `.bss` / `.data` / `.rodata`) is
23//! rendered by walking its `VarSecinfo` entries. Each VarSecinfo points
24//! at a `BTF_KIND_VAR`, which in turn references the variable's actual
25//! type. The renderer slices the section bytes at
26//! `[var_secinfo.offset()..var_secinfo.offset() + var_secinfo.size()]`
27//! and recursively renders the variable's type into that slice. The
28//! result is a [`RenderedValue::Struct`] whose `type_name` is the
29//! section name and whose `members` enumerate the section's variables
30//! by their declared names, so a failure dump's `.bss` map shows
31//! `stall=1, crash=0, ...` instead of an opaque hex dump.
32//!
33//! Bitfield handling: when [`btf_rs::Member::bitfield_size`] is `Some(w)`,
34//! the renderer reads enough bytes to cover the bitfield's bit range,
35//! shifts and masks, and applies sign extension when the underlying
36//! type is a signed Int, signed Enum, or signed Enum64 — BTF bitfields
37//! can carry any of those bases (e.g. `enum scx_exit_kind` declared
38//! with negative members).
39//!
40//! # Panics on malformed BTF
41//!
42//! [`btf_rs::Int::size`], [`btf_rs::Struct::size`] (and
43//! [`btf_rs::Union::size`] via `pub type Union = Struct;`),
44//! [`btf_rs::Enum::size`], [`btf_rs::Enum64::size`],
45//! [`btf_rs::Float::size`], and [`btf_rs::Datasec::size`] each panic
46//! when the underlying BTF type record has a kind discriminant
47//! claiming "Int/Struct/Union/Enum/etc." but a size field that the
48//! kernel's btf-type union decoded as the type-id alternative — a
49//! structural-validity violation that is not a recoverable parse
50//! error in `btf_rs` 2.0. This renderer calls those `size()`
51//! accessors after `Type::X(...)` discriminant match, which guards
52//! against wrong-kind dispatch but does NOT guard against the
53//! kind-vs-size-field skew inside a single record.
54//!
55//! Malformed BTF is treated as a producer bug (kernel BTF blob, BPF
56//! object's program BTF, or operator-supplied `--vmlinux` override).
57//! The renderer surfaces the panic loudly rather than silently
58//! rendering nonsense — aligned with the "no silent data drops"
59//! invariant. A panic here points the operator at the BTF producer,
60//! not the dump pipeline.
61
62use std::borrow::Cow;
63use std::collections::HashSet;
64
65use serde::{Deserialize, Serialize};
66
67use btf_rs::{Btf, BtfType, Member, Struct, Type};
68
69use super::dump::hex_dump;
70
71/// Maximum number of qualifier-peel iterations before [`peel_modifiers`]
72/// gives up. BTF chains are bounded in practice (struct → typedef →
73/// const → volatile is already 4); the cap protects the renderer from
74/// a malformed BTF input that introduces a self-referential cycle.
75const MAX_MODIFIER_DEPTH: u32 = 32;
76
77/// Maximum array length the renderer expands element-by-element. Larger
78/// arrays are truncated with the original length recorded so the caller
79/// can serialize a partial view rather than allocating a million
80/// [`RenderedValue`] nodes.
81const MAX_ARRAY_ELEMS: usize = 4096;
82
83/// Recursion depth limit for nested struct / array rendering. Same
84/// motivation as [`MAX_MODIFIER_DEPTH`]: bound output size on
85/// pathological BTF.
86const MAX_RENDER_DEPTH: u32 = 32;
87
88/// Structured rendering of one BTF-typed value.
89///
90/// The `kind` tag identifies the variant; field order matches the
91/// rendering pipeline (Int / Uint / Bool / Char before Float / Enum /
92/// Struct / Array / CpuList / Ptr, with Bytes / Truncated / Unsupported
93/// as the recovery path).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96#[allow(dead_code)]
97pub enum RenderedValue {
98    /// Signed integer. `bits` is the BTF-declared width.
99    ///
100    /// **Always signed by construction.** The internal `render_int`
101    /// routes `BTF_KIND_INT` with `is_signed() = false` to
102    /// [`Self::Uint`]; `is_signed() = true` to [`Self::Int`]. The
103    /// variant choice IS the signedness tag — no equivalent of
104    /// `Enum::is_signed` is needed because unsigned ints never land
105    /// here.
106    Int { bits: u32, value: i64 },
107    /// Unsigned integer. `bits` is the BTF-declared width. See the
108    /// signedness-by-variant note on [`Self::Int`].
109    Uint { bits: u32, value: u64 },
110    /// Boolean (BTF int with `is_bool()`).
111    Bool { value: bool },
112    /// Character (BTF int with `is_char()`). `value` holds the raw
113    /// byte for round-tripping; non-printable values are preserved.
114    /// Field name matches the other scalar variants (Int / Uint / Bool /
115    /// Float / Enum / Ptr) so any field-driven serializer treats them
116    /// uniformly.
117    Char { value: u8 },
118    /// IEEE-754 float (BTF_KIND_FLOAT).
119    Float { bits: u32, value: f64 },
120    /// Enum value with optional resolved variant name.
121    ///
122    /// `is_signed` tracks the BTF type's signedness so accessors
123    /// downstream can decide whether to reinterpret the bit
124    /// pattern (unsigned: `value as u64` gives the wire bits back
125    /// even when the storage width forces a negative `i64`) or
126    /// treat negative values as out-of-range (signed: a true
127    /// negative enum variant is not coercible to `u64`).
128    ///
129    /// Constructed by the BTF renderer; downstream consumers read
130    /// `is_signed` (or trust the typed accessors like
131    /// [`RenderedValue::as_u64`] to dispatch correctly) rather than
132    /// building `Enum` literals directly. `#[serde(default)]` lets
133    /// pre-`is_signed` archives (test sidecars, captured failure
134    /// dumps) deserialize as `is_signed: false`, the default. Note
135    /// that this changes behavior for old archives carrying a
136    /// negative `value`: pre-field [`RenderedValue::as_u64`] returned
137    /// `None` for any negative stored value (whether the BTF type
138    /// was signed-negative OR unsigned-with-high-bit-set, since the
139    /// renderer never tracked the distinction); post-field, a
140    /// deserialize-as-default `is_signed: false` reinterprets the
141    /// bit pattern via `as u64`. That shift is intentional — old
142    /// archives could not encode the signedness, and the conservative
143    /// "treat as unsigned bits" recovers more cases than the old
144    /// "any negative is rejected" behavior.
145    Enum {
146        bits: u32,
147        value: i64,
148        variant: Option<String>,
149        #[serde(default)]
150        is_signed: bool,
151    },
152    /// Aggregate (struct or union). For unions, only the first member
153    /// is meaningful — the renderer emits all members each backed by
154    /// the same byte range so the caller can pick.
155    Struct {
156        type_name: Option<String>,
157        members: Vec<RenderedMember>,
158    },
159    /// Array. `elements` is truncated to `MAX_ARRAY_ELEMS`.
160    Array {
161        len: usize,
162        elements: Vec<RenderedValue>,
163    },
164    /// CPU bitmask rendered as a range-collapsed list. Produced when
165    /// the renderer detects a `cpumask`, `bpf_cpumask`, or `scx_bitmap`
166    /// struct by BTF type name.
167    ///
168    /// Field-name exception: scalar variants (Int / Uint / Bool /
169    /// Char / Float / Enum / Ptr) name the inner field `value` for
170    /// uniform serialization; CpuList breaks the convention by
171    /// using `cpus` because the rendered text is type-specific
172    /// (e.g. `"0-2,5"`) and reads more naturally as `cpus` in
173    /// JSON consumers — `value` would be misleading for a non-
174    /// scalar payload. Char keeps `value` since its payload is a
175    /// single byte (the BTF int type is the underlying scalar).
176    CpuList { cpus: String },
177    /// Pointer value with optional dereferenced content. When `deref`
178    /// is Some, the pointer was chased at dump time and the target
179    /// struct is rendered inline. `deref_skipped_reason` carries the
180    /// cause when the chase was attempted but did not produce a
181    /// deref — `None` means no chase was attempted (e.g. null
182    /// pointer or no `MemReader` supplied), and a non-`None`
183    /// reason with `deref: None` means the chase was attempted but
184    /// could not complete (cross-page boundary, BTF-size truncated
185    /// against the read cap, kernel kptr that failed plausibility
186    /// gating, etc.). The reason field enables the consumer to
187    /// distinguish "we didn't try" from "we tried and failed for
188    /// reason X" without a separate flag.
189    ///
190    /// `cast_annotation` distinguishes cast-recovered pointers
191    /// (set by `render_cast_pointer` to `"cast→arena"` /
192    /// `"cast→kernel"`) from BTF-typed pointers (the
193    /// [`Type::Ptr`] arm normally leaves it `None`). Display
194    /// surfaces it as a parenthesised tag so operators can tell
195    /// at a glance whether the pointer came from native BTF
196    /// typing or the cast analyzer's recovery path.
197    ///
198    /// One [`Type::Ptr`] exception: when the renderer recovers a
199    /// `BTF_KIND_FWD` pointee's real struct id via the sdt_alloc
200    /// bridge (`MemReader::resolve_arena_type`), the arena
201    /// branch sets this field to `"sdt_alloc"` so the rendered
202    /// subtree is flagged as a recovered chase rather than a
203    /// native BTF resolve. Cast-recovered pointers that cleared
204    /// the same bridge extend the annotation to
205    /// `"cast→{addr_space} (sdt_alloc)"`.
206    ///
207    /// Storage is [`Cow<'static, str>`] so the renderer's emit
208    /// sites — every value the renderer itself produces is one
209    /// of a five-element closed set
210    /// (`"sdt_alloc"`, `"cast→arena"`, `"cast→kernel"`,
211    /// `"cast→arena (sdt_alloc)"`, `"cast→kernel (sdt_alloc)"`)
212    /// — borrow `&'static str` literals via [`Cow::Borrowed`]
213    /// without per-chase heap allocations. JSON deserialization
214    /// produces [`Cow::Owned`] (serde's [`Cow`] impl forwards
215    /// to `String`'s deserializer), so existing serialized
216    /// snapshots round-trip unchanged.
217    Ptr {
218        value: u64,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        deref: Option<Box<RenderedValue>>,
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        deref_skipped_reason: Option<String>,
223        #[serde(default, skip_serializing_if = "Option::is_none")]
224        cast_annotation: Option<Cow<'static, str>>,
225    },
226    /// Fallback hex dump for types the renderer can decode the size of
227    /// but not the structure (e.g. `BTF_KIND_FWD`). Hex is lowercase,
228    /// space-separated.
229    Bytes { hex: String },
230    /// The byte slice ended before the type's declared size. `needed`
231    /// is the required byte count; `had` is what was supplied.
232    /// `partial` carries whatever decoded successfully before the
233    /// truncation: a `Struct` with the members that fit (further
234    /// truncated members nest as their own `Truncated`), an
235    /// `Array` with the elements that fit, or a `Bytes` hex dump of
236    /// the raw bytes that were available when no structured partial
237    /// applied (e.g. a 2-byte slice for a 4-byte int).
238    Truncated {
239        needed: usize,
240        had: usize,
241        partial: Box<RenderedValue>,
242    },
243    /// BTF type kind the renderer does not handle (Func, FuncProto,
244    /// Fwd, Void, or a kind beyond the qualifier-peel cap).
245    /// `reason` carries the human-readable cause.
246    Unsupported { reason: String },
247}
248
249/// One member of a [`RenderedValue::Struct`]. `name` is the BTF name;
250/// for anonymous union members it is empty. `value` is the recursive
251/// rendering of the member's bytes.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct RenderedMember {
254    pub name: String,
255    pub value: RenderedValue,
256}
257
258impl RenderedValue {
259    /// Single-step struct-member walk. Returns the named member of a
260    /// `Struct` (or `Truncated{partial: Struct}`), peeling through
261    /// `Ptr{deref: Some}` transparently. `None` for any other variant
262    /// or when the member name is absent.
263    pub fn member(&self, name: &str) -> Option<&RenderedValue> {
264        match self {
265            RenderedValue::Struct { members, .. } => {
266                members.iter().find(|m| m.name == name).map(|m| &m.value)
267            }
268            RenderedValue::Ptr {
269                deref: Some(inner), ..
270            } => inner.member(name),
271            RenderedValue::Truncated { partial, .. } => partial.member(name),
272            _ => None,
273        }
274    }
275
276    /// Array-element walk by 0-indexed position. Returns the i-th
277    /// element of an `Array` (or `Truncated{partial: Array}` —
278    /// `None` if `i` is past what the truncation preserved), peeling
279    /// through `Ptr{deref: Some}`. `None` for any other variant or
280    /// out-of-range index.
281    pub fn index(&self, i: usize) -> Option<&RenderedValue> {
282        match self {
283            RenderedValue::Array { elements, .. } => elements.get(i),
284            RenderedValue::Ptr {
285                deref: Some(inner), ..
286            } => inner.index(i),
287            RenderedValue::Truncated { partial, .. } => partial.index(i),
288            _ => None,
289        }
290    }
291
292    /// Dotted-path walk equivalent to a chain of [`Self::member`]
293    /// calls split on `.`. Empty path returns `Some(self)`; any
294    /// component that fails to resolve returns `None`. Peels through
295    /// `Ptr{deref: Some}` and `Truncated{partial: Struct}` at every
296    /// step.
297    pub fn get(&self, path: &str) -> Option<&RenderedValue> {
298        if path.is_empty() {
299            return Some(self);
300        }
301        let mut current = self;
302        for component in path.split('.') {
303            if component.is_empty() {
304                return None;
305            }
306            current = current.member(component)?;
307        }
308        Some(current)
309    }
310
311    /// Coerce a scalar variant to `u64`. Accepts `Uint`, `Bool`
312    /// (`true=1`, `false=0`), `Char` (raw byte), and `Ptr` (numeric
313    /// address). `Int<0`, `Enum<0`, `Float`, `Struct`, `Array`,
314    /// `Bytes`, `Truncated`, and `Unsupported` return `None`. Peels
315    /// `Ptr{deref: Some}` transparently — the unsigned-pointer
316    /// numeric value still wins.
317    pub fn as_u64(&self) -> Option<u64> {
318        match self {
319            RenderedValue::Uint { value, .. } => Some(*value),
320            RenderedValue::Int { value, .. } if *value >= 0 => Some(*value as u64),
321            RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
322            RenderedValue::Char { value } => Some(*value as u64),
323            // Signed enums: reject negative values (sign loss).
324            // Unsigned enums: reinterpret the bit pattern via
325            // `as u64` so e.g. an unsigned 64-bit enum variant
326            // with value `0xFFFF_FFFF_FFFF_FFFF` (stored as `i64 = -1`
327            // by the renderer) round-trips back to its true
328            // unsigned wire value.
329            RenderedValue::Enum {
330                value,
331                is_signed: true,
332                ..
333            } if *value >= 0 => Some(*value as u64),
334            RenderedValue::Enum {
335                value,
336                is_signed: false,
337                ..
338            } => Some(*value as u64),
339            RenderedValue::Ptr { value, .. } => Some(*value),
340            _ => None,
341        }
342    }
343
344    /// Coerce a scalar variant to `i64`. Accepts `Int`, `Uint<=i64::MAX`,
345    /// `Bool`, `Char`, and `Enum`. `Uint>i64::MAX`, `Float`,
346    /// aggregate, and recovery variants return `None`.
347    pub fn as_i64(&self) -> Option<i64> {
348        match self {
349            RenderedValue::Int { value, .. } => Some(*value),
350            RenderedValue::Uint { value, .. } if *value <= i64::MAX as u64 => Some(*value as i64),
351            RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
352            RenderedValue::Char { value } => Some(*value as i64),
353            RenderedValue::Enum { value, .. } => Some(*value),
354            _ => None,
355        }
356    }
357
358    /// Coerce a scalar variant to `f64`. `Float` is the only direct
359    /// source; integer variants widen via `as f64` (with the usual
360    /// precision loss above 2^53).
361    pub fn as_f64(&self) -> Option<f64> {
362        match self {
363            RenderedValue::Float { value, .. } => Some(*value),
364            RenderedValue::Int { value, .. } => Some(*value as f64),
365            RenderedValue::Uint { value, .. } => Some(*value as f64),
366            RenderedValue::Enum { value, .. } => Some(*value as f64),
367            _ => None,
368        }
369    }
370
371    /// Coerce a scalar variant to `bool`. Direct from `Bool`; from
372    /// `Uint`/`Int`/`Char`/`Enum`/`Ptr` returns `Some(value != 0)` —
373    /// a `Ptr` coerces as a non-null test, matching `as_u64`'s
374    /// treatment of a pointer as its numeric address. `Float` and
375    /// aggregate variants return `None`.
376    ///
377    /// The `Ptr` arm keeps this in lockstep with the scalar
378    /// `SnapshotField::as_bool` accessor (scenario/snapshot/field.rs),
379    /// which already coerces `Ptr` to a non-null bool. Without it the
380    /// array path (`as_bool_array` → this method per element) rejected
381    /// a pointer element that the scalar accessor accepted, so
382    /// `field.as_bool()` and `field.as_bool_array()` disagreed on a
383    /// pointer — the same scalar-vs-array divergence the `Enum`
384    /// signedness handling in `as_u64` was added to eliminate.
385    pub fn as_bool(&self) -> Option<bool> {
386        match self {
387            RenderedValue::Bool { value } => Some(*value),
388            RenderedValue::Uint { value, .. } => Some(*value != 0),
389            RenderedValue::Int { value, .. } => Some(*value != 0),
390            RenderedValue::Char { value } => Some(*value != 0),
391            RenderedValue::Enum { value, .. } => Some(*value != 0),
392            RenderedValue::Ptr { value, .. } => Some(*value != 0),
393            _ => None,
394        }
395    }
396
397    /// Extract a homogeneous `Vec<u64>` from an `Array` whose every
398    /// element coerces via [`Self::as_u64`]. Returns `None` if self
399    /// is not an `Array` (or `Truncated{partial: Array}`), or if any
400    /// element fails the coercion — the caller cannot rely on a
401    /// partial result. Peels `Ptr{deref: Some}`.
402    pub fn as_u64_array(&self) -> Option<Vec<u64>> {
403        let elements = self.array_elements()?;
404        elements.iter().map(RenderedValue::as_u64).collect()
405    }
406
407    /// Extract a homogeneous `Vec<u32>` from an `Array` whose every
408    /// element coerces via [`Self::as_u64`] and fits in `u32`.
409    /// Out-of-range values return `None` (no silent truncation).
410    pub fn as_u32_array(&self) -> Option<Vec<u32>> {
411        let elements = self.array_elements()?;
412        elements
413            .iter()
414            .map(|e| e.as_u64().and_then(|v| u32::try_from(v).ok()))
415            .collect()
416    }
417
418    /// Extract a homogeneous `Vec<i64>` from an `Array` whose every
419    /// element coerces via [`Self::as_i64`].
420    pub fn as_i64_array(&self) -> Option<Vec<i64>> {
421        let elements = self.array_elements()?;
422        elements.iter().map(RenderedValue::as_i64).collect()
423    }
424
425    /// Extract a homogeneous `Vec<f64>` from an `Array` whose every
426    /// element coerces via [`Self::as_f64`].
427    pub fn as_f64_array(&self) -> Option<Vec<f64>> {
428        let elements = self.array_elements()?;
429        elements.iter().map(RenderedValue::as_f64).collect()
430    }
431
432    /// Extract a homogeneous `Vec<bool>` from an `Array` whose every
433    /// element coerces via [`Self::as_bool`].
434    pub fn as_bool_array(&self) -> Option<Vec<bool>> {
435        let elements = self.array_elements()?;
436        elements.iter().map(RenderedValue::as_bool).collect()
437    }
438
439    /// Internal: borrow the elements slice of an `Array` variant,
440    /// peeling `Ptr{deref: Some}` and `Truncated{partial: Array}`.
441    /// The peel matches [`Self::index`] so consumers see consistent
442    /// behavior between random-access and full-iteration paths.
443    fn array_elements(&self) -> Option<&[RenderedValue]> {
444        match self {
445            RenderedValue::Array { elements, .. } => Some(elements.as_slice()),
446            RenderedValue::Ptr {
447                deref: Some(inner), ..
448            } => inner.array_elements(),
449            RenderedValue::Truncated { partial, .. } => partial.array_elements(),
450            _ => None,
451        }
452    }
453}
454
455impl std::fmt::Display for RenderedValue {
456    /// Human-readable rendering for test-failure output. JSON remains
457    /// the programmatic form (via `serde_json`); this Display emits
458    /// pretty-printed text suitable for assertion failure messages,
459    /// e.g.
460    ///
461    /// ```text
462    /// task_ctx{weight=1024, last_runnable_at=12345678901234}
463    /// ```
464    ///
465    /// Structs that fit within the inline width budget pack onto one
466    /// line as `TypeName{field=value, field=value}`; wider structs
467    /// break to a multi-line `TypeName:` breadcrumb form with
468    /// indented `field=value` rows. Nested structs and arrays indent
469    /// by two spaces per level. Scalar-only arrays render inline
470    /// (`[1, 2, 3]`); arrays containing structs / nested arrays
471    /// render block-style with one element per line.
472    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473        write_rendered_value(f, self, 0)
474    }
475}
476
477/// Indentation prefix. Two-space steps match the example in the
478/// module-level Display doc.
479const INDENT: &str = "  ";
480
481/// Render a [`RenderedValue`] with a caller-supplied starting
482/// indentation depth. Wrapper modules (e.g.
483/// `crate::monitor::dump::display`) use this to nest a renderer
484/// output inside their own indented context — passing
485/// `depth = 1` produces output indented one level deeper than the
486/// default `Display::fmt` path (which always starts at `depth = 0`).
487pub(crate) fn write_value_at_depth(
488    f: &mut std::fmt::Formatter<'_>,
489    v: &RenderedValue,
490    depth: usize,
491) -> std::fmt::Result {
492    write_rendered_value(f, v, depth)
493}
494
495/// Recursive Display helper. Tracks current indentation `depth` so
496/// nested structs / block-style arrays line up correctly. Direct
497/// `Display::fmt` cannot pass extra state, so this is the entry
498/// point for every recursive call.
499fn write_rendered_value(
500    f: &mut std::fmt::Formatter<'_>,
501    v: &RenderedValue,
502    depth: usize,
503) -> std::fmt::Result {
504    match v {
505        RenderedValue::Int { value, .. } => {
506            let mut buf = itoa::Buffer::new();
507            f.write_str(buf.format(*value))
508        }
509        RenderedValue::Uint { value, .. } => {
510            // Genuine unsigned integers render as decimal regardless
511            // of magnitude. Pointer-typed values are handled by the
512            // [`RenderedValue::Ptr`] arm — the BTF type drives the
513            // format, not the value. A `u64` counter that happens
514            // to land in the kernel-pointer numeric range still
515            // renders as decimal because that is what the BPF
516            // programmer declared. Pointer-shaped values declared
517            // as pointers (Type::Ptr in BTF, possibly through
518            // typedefs which `peel_modifiers` collapses) reach the
519            // Ptr Display arm below and emit `0x<hex>` there.
520            let mut buf = itoa::Buffer::new();
521            f.write_str(buf.format(*value))
522        }
523        RenderedValue::Bool { value } => f.write_str(if *value { "true" } else { "false" }),
524        RenderedValue::Char { value } => {
525            if (0x20..=0x7e).contains(value) {
526                f.write_str("'")?;
527                f.write_str(core::str::from_utf8(&[*value]).unwrap_or("?"))?;
528                f.write_str("'")
529            } else {
530                write!(f, "0x{value:02x}")
531            }
532        }
533        RenderedValue::Float { value, .. } => write!(f, "{value}"),
534        RenderedValue::Enum {
535            value,
536            variant,
537            is_signed,
538            ..
539        } => {
540            // Mirror as_u64's signedness dispatch: unsigned enums
541            // render the bit-pattern u64 value (so an unsigned 64-bit
542            // enum stored as i64=-1 displays as the u64::MAX wire
543            // value, not "-1"); signed enums render the i64
544            // directly. Without this branch, the renderer printed
545            // "-1" for an unsigned u64::MAX, which is exactly the
546            // sign-loss the is_signed field exists to prevent.
547            let mut buf = itoa::Buffer::new();
548            let rendered_value: &str = if *is_signed {
549                buf.format(*value)
550            } else {
551                buf.format(*value as u64)
552            };
553            match variant {
554                Some(name) => {
555                    f.write_str(name)?;
556                    f.write_str(" (")?;
557                    f.write_str(rendered_value)?;
558                    f.write_str(")")
559                }
560                None => f.write_str(rendered_value),
561            }
562        }
563        RenderedValue::CpuList { cpus } => write!(f, "cpus={{{cpus}}}"),
564        RenderedValue::Ptr {
565            value,
566            deref,
567            deref_skipped_reason,
568            cast_annotation,
569            ..
570        } => {
571            write!(f, "0x{value:x}")?;
572            if let Some(tag) = cast_annotation {
573                write!(f, " ({tag})")?;
574            }
575            if let Some(inner) = deref {
576                f.write_str(" → ")?;
577                write_rendered_value(f, inner, depth)?;
578            } else if let Some(reason) = deref_skipped_reason {
579                // Surface the chase-skip cause inline. Cycle markers
580                // collapse to the dense `[cycle]` form — operators
581                // recognise it without needing the address repeated
582                // (the address is already in the pointer's hex
583                // value preceding this marker). Other skip reasons
584                // (cross-page failure, plausibility gate, etc.)
585                // keep the verbose `[chase: ...]` form so the
586                // specific cause is visible.
587                if reason.starts_with("cycle ") {
588                    f.write_str(" [cycle]")?;
589                } else {
590                    write!(f, " [chase: {reason}]")?;
591                }
592            }
593            Ok(())
594        }
595        RenderedValue::Bytes { hex } => f.write_str(hex),
596        RenderedValue::Truncated {
597            needed,
598            had,
599            partial,
600        } => {
601            if *had == 0 {
602                return Ok(());
603            }
604            write!(f, "<truncated needed={needed} had={had}> ")?;
605            write_rendered_value(f, partial, depth)
606        }
607        RenderedValue::Unsupported { reason } => write!(f, "<unsupported: {reason}>"),
608        RenderedValue::Array { len, elements } => {
609            if elements.is_empty() {
610                return write!(f, "[]");
611            }
612            // Detect i8/u8 arrays that are C strings: all elements
613            // are 8-bit Int/Uint, mostly printable ASCII or NUL.
614            // Detect non-empty C strings: 8-bit arrays starting with
615            // a non-NUL printable byte. All-zero arrays and arrays
616            // starting with NUL are NOT strings (they're zero data).
617            let first_byte = match &elements[0] {
618                RenderedValue::Int { bits: 8, value } => Some(*value as u8),
619                RenderedValue::Uint { bits: 8, value } => Some(*value as u8),
620                RenderedValue::Char { value } => Some(*value),
621                _ => None,
622            };
623            let is_string = first_byte.is_some_and(|b| b != 0 && is_text_byte(b))
624                && elements.len() >= 2
625                && elements.iter().all(|e| match e {
626                    RenderedValue::Int { bits: 8, value } => is_text_byte(*value as u8),
627                    RenderedValue::Uint { bits: 8, value } => is_text_byte(*value as u8),
628                    RenderedValue::Char { value } => is_text_byte(*value),
629                    _ => false,
630                });
631            if is_string {
632                // Build the string to check if it's multi-line.
633                let mut s = String::new();
634                for e in elements {
635                    let ch = match e {
636                        RenderedValue::Int { value, .. } => *value as u8,
637                        RenderedValue::Uint { value, .. } => *value as u8,
638                        RenderedValue::Char { value } => *value,
639                        _ => 0,
640                    };
641                    if ch == 0 {
642                        break;
643                    }
644                    s.push(ch as char);
645                }
646                if s.contains('\n') {
647                    // Multi-line: render with actual newlines, indented.
648                    f.write_str("|\n")?;
649                    for line in s.split('\n') {
650                        if line.is_empty() {
651                            continue;
652                        }
653                        write_indent(f, depth + 1)?;
654                        f.write_str(line)?;
655                        f.write_str("\n")?;
656                    }
657                    write_indent(f, depth)?;
658                } else {
659                    write!(f, "\"{s}\"")?;
660                }
661                return Ok(());
662            }
663            let inline = elements.iter().all(is_inline_scalar);
664            if inline {
665                // Build contiguous runs of non-zero elements. Each
666                // run carries (start_idx, end_idx_inclusive,
667                // [&values]). Zero elements break a run; the gaps
668                // between runs surface implicitly (no `(N zero)`
669                // count needed when run brackets carry the index).
670                let mut runs: Vec<(usize, usize, Vec<&RenderedValue>)> = Vec::new();
671                for (i, e) in elements.iter().enumerate() {
672                    if is_zero(e) {
673                        continue;
674                    }
675                    if let Some(last) = runs.last_mut()
676                        && last.1 + 1 == i
677                    {
678                        last.1 = i;
679                        last.2.push(e);
680                    } else {
681                        runs.push((i, i, vec![e]));
682                    }
683                }
684
685                // All-zero short-circuit. The `[all N zero]` glyph
686                // makes "every slot is zero" obvious without
687                // listing every index.
688                if runs.is_empty() {
689                    return write!(f, "[all {len} zero]");
690                }
691
692                // Pre-render every element's text form so we can
693                // measure widths and pack rows with element-
694                // boundary wrapping (no mid-value breaks).
695                let render_elem = |e: &RenderedValue| -> String {
696                    use std::fmt::Write;
697                    let mut s = String::new();
698                    match e {
699                        RenderedValue::Uint { value, bits } if *bits >= 32 => {
700                            let _ = write!(s, "{value:#x}");
701                        }
702                        _ => {
703                            let _ = write!(s, "{e}");
704                        }
705                    }
706                    s
707                };
708
709                // Special case: a single run covering the whole
710                // array starting at 0 means there are no gaps —
711                // emit a plain `[v1, v2, ...]` without index
712                // brackets. Long lists wrap at element boundaries
713                // with continuation indented to align with the
714                // first element after `[`.
715                if runs.len() == 1
716                    && runs[0].0 == 0
717                    && runs[0].1 + 1 == elements.len()
718                    && elements.len() == *len
719                {
720                    let strs: Vec<String> = runs[0].2.iter().map(|e| render_elem(e)).collect();
721                    write_inline_list_wrapped(f, "[", "]", &strs, ", ", depth)?;
722                    return Ok(());
723                }
724
725                // Sparse render: each run as `[start..end]={v, v, ...}`
726                // (or `[idx]=v` for single-element runs). Multiple
727                // runs separate with two spaces and wrap to a new
728                // line at run boundaries when the line would
729                // exceed the inline budget.
730                let run_strs: Vec<String> = runs
731                    .iter()
732                    .map(|(start, end, vals)| {
733                        if start == end {
734                            format!("[{start}]={}", render_elem(vals[0]))
735                        } else {
736                            let inner: Vec<String> = vals.iter().map(|v| render_elem(v)).collect();
737                            format!("[{start}..{end}]={{{}}}", inner.join(", "))
738                        }
739                    })
740                    .collect();
741                write_inline_list_wrapped(f, "[", "]", &run_strs, "  ", depth)?;
742                if elements.len() < *len {
743                    write!(f, " /* {} of {len} shown */", elements.len())?;
744                }
745                Ok(())
746            } else {
747                // Group identical elements by content. Show each
748                // unique value once with its index range.
749                f.write_str("[")?;
750                let mut groups: Vec<(usize, usize, &RenderedValue)> = Vec::new();
751                for (i, e) in elements.iter().enumerate() {
752                    if is_zero(e) {
753                        continue;
754                    }
755                    if let Some(g) = groups.last_mut()
756                        && g.2 == e
757                    {
758                        g.1 = i;
759                        continue;
760                    }
761                    groups.push((i, i, e));
762                }
763                // All-zero short-circuit: every element was zero-
764                // suppressed, no groups recorded. The full rendering
765                // would emit an empty bracket pair, so collapse to
766                // the dense "all N zero]" form instead.
767                if groups.is_empty() {
768                    return write!(f, "all {len} zero]");
769                }
770                // Render groups, merging consecutive similar structs
771                // (differ by <8 fields) into a single template with
772                // a per-index table for the varying fields.
773                let mut i = 0;
774                while i < groups.len() {
775                    let (start, end, val) = &groups[i];
776                    // Try to extend a run of similar singletons.
777                    if start == end
778                        && let RenderedValue::Struct {
779                            members: first_m, ..
780                        } = val
781                    {
782                        let mut run_end = i;
783                        'scan: while run_end + 1 < groups.len() {
784                            let (ns, ne, nv) = &groups[run_end + 1];
785                            if ns != ne {
786                                break;
787                            }
788                            if let RenderedValue::Struct {
789                                members: next_m, ..
790                            } = nv
791                            {
792                                if next_m.len() != first_m.len() {
793                                    break;
794                                }
795                                let diffs = first_m
796                                    .iter()
797                                    .zip(next_m.iter())
798                                    .filter(|(a, b)| a.value != b.value)
799                                    .count();
800                                if diffs >= 8 {
801                                    break 'scan;
802                                }
803                            } else {
804                                break;
805                            }
806                            run_end += 1;
807                        }
808                        if run_end > i {
809                            // Try to merge [i..=run_end] into a template.
810                            let run = &groups[i..=run_end];
811                            if try_write_struct_template(f, run, depth + 1)? {
812                                i = run_end + 1;
813                                continue;
814                            }
815                            // try_write_struct_template returned false
816                            // (run too short, no varying fields, or > 3
817                            // varying). Wrote nothing. Fall through to
818                            // the per-element block below for groups[i];
819                            // the loop renders the rest of the run one
820                            // group at a time.
821                        }
822                    }
823                    f.write_str("\n")?;
824                    write_indent(f, depth + 1)?;
825                    if start == end {
826                        write!(f, "[{start}] ")?;
827                    } else {
828                        write!(f, "[{start}-{end}] ")?;
829                    }
830                    write_rendered_value(f, val, depth + 1)?;
831                    i += 1;
832                }
833                // Zero elements are suppressed silently — the gaps
834                // between rendered groups speak for themselves; an
835                // explicit count line adds no information the
836                // operator needs.
837                f.write_str("\n")?;
838                write_indent(f, depth)?;
839                f.write_str("]")?;
840                if elements.len() < *len {
841                    write!(f, " /* {} of {len} shown */", elements.len())?;
842                }
843                Ok(())
844            }
845        }
846        RenderedValue::Struct { type_name, members } => {
847            write_struct(f, type_name.as_deref(), members, depth)
848        }
849    }
850}
851
852/// Render a Struct (or Union, which shares the same wire shape)
853/// using the column-aligned multi-line / inline-with-braces
854/// format. Inline takes priority when the rendered form fits on a
855/// single line under [`STRUCT_INLINE_WIDTH_BUDGET`]; otherwise the
856/// breadcrumb form `TypeName:` is emitted, followed by indented
857/// rows of column-aligned scalar fields and per-field lines for
858/// compound members.
859fn write_struct(
860    f: &mut std::fmt::Formatter<'_>,
861    type_name: Option<&str>,
862    members: &[RenderedMember],
863    depth: usize,
864) -> std::fmt::Result {
865    // Flatten anonymous struct members: C anonymous structs expose
866    // their inner fields directly on the parent. Without flattening,
867    // the Display code would see empty-name members wrapping inner
868    // structs and suppress them (producing `Type{}`).
869    let mut flat_members;
870    let members = if members
871        .iter()
872        .any(|m| m.name.is_empty() && matches!(m.value, RenderedValue::Struct { .. }))
873    {
874        // Pool the NAMED siblings' scalar values BEFORE flatten
875        // consumes the anonymous overlays, so a union overlay that
876        // only re-views named fields is suppressed rather than
877        // flattened into duplicate sibling columns. (Building the pool
878        // after flatten — the previous order — left it unable to see
879        // the empty-name Structs at all, so the dedup never fired.)
880        let pool = build_sibling_scalar_pool(members);
881        flat_members = Vec::with_capacity(members.len());
882        for m in members {
883            if m.name.is_empty()
884                && let RenderedValue::Struct {
885                    members: ref inner, ..
886                } = m.value
887            {
888                // Suppress an anonymous union overlay that merely
889                // duplicates named-sibling scalars; otherwise flatten
890                // its inner fields onto the parent.
891                if !pool.is_empty() && anon_duplicates_pool(&m.value, &pool) {
892                    continue;
893                }
894                flat_members.extend_from_slice(inner);
895                continue;
896            }
897            flat_members.push(m.clone());
898        }
899        flat_members.as_slice()
900    } else {
901        members
902    };
903
904    // Single-pass filter + pre-render. `visible_rendered` carries
905    // tuples of (member, rendered single-line string). The render
906    // happens exactly once per visible member; both the inline-fit
907    // probe and the final emit reuse the same string. Members
908    // whose value renders to multi-line text (an embedded `\n`
909    // signals a Struct that broke to its own breadcrumb form) get
910    // a `None` rendered string and feed the compound-line path
911    // directly — they can't pack into a column row.
912    let mut visible_rendered: Vec<(&RenderedMember, Option<String>)> =
913        Vec::with_capacity(members.len());
914    for m in members {
915        if is_deeply_zero(&m.value) {
916            continue;
917        }
918        if (m.name.contains("___fmt") || m.name.contains("____fmt")) && is_string_value(&m.value) {
919            continue;
920        }
921        // Pre-render the value to a single-line string for flat
922        // scalars and for nested Struct values whose own Display
923        // happens to fit inline (no embedded `\n`). Other compound
924        // members (Array, Ptr-with-deref, Truncated, CpuList,
925        // Unsupported) always produce multi-line output OR carry
926        // their own internal layout that the breadcrumb path
927        // re-renders directly via `write_rendered_value`, so they
928        // get `None` here. A `None` rendering causes
929        // `try_inline_from_rendered` to bail to the multi-line
930        // path. Allowing nested Structs to participate in the
931        // outer's inline form lets `outer{child=inner{a=1}}` pack
932        // onto one line when both are small enough — without it,
933        // any nested Struct would force the breadcrumb form even
934        // for trivial two-level cases.
935        let single_line = if is_flat_scalar(&m.value) {
936            Some(format!("{}", m.value))
937        } else if matches!(m.value, RenderedValue::Struct { .. }) {
938            let s = format!("{}", m.value);
939            if s.contains('\n') { None } else { Some(s) }
940        } else {
941            None
942        };
943        visible_rendered.push((m, single_line));
944    }
945
946    // Inline-fit probe: assemble `TypeName{name=val, name=val}`
947    // by joining the pre-rendered values. Bail to multi-line if
948    // any value lacks a single-line render OR the total width
949    // exceeds the budget.
950    if let Some(inline) = try_inline_from_rendered(type_name, &visible_rendered) {
951        return f.write_str(&inline);
952    }
953
954    // Multi-line breadcrumb form. Layout when type_name is
955    // present:
956    //   TypeName:
957    //     scalar1=v1   scalar2=v2   scalar3=v3
958    //     scalar4=v4
959    //     compound1 InnerType:
960    //       inner_field=...
961    //
962    // Anonymous structs (no type_name) drop the breadcrumb name
963    // and just emit `:` followed by the indented body — the
964    // visual hierarchy is preserved by the indent depth alone.
965    if let Some(name) = type_name {
966        f.write_str(name)?;
967    }
968    if visible_rendered.is_empty() {
969        // Truly empty struct (no visible fields after suppression):
970        // emit `Type{}` (or `{}` for anon). Suppressed zero /
971        // fmt-string fields produce no visible artifact, so an
972        // all-zero struct lands here as if it had no members at
973        // all.
974        f.write_str("{}")?;
975        return Ok(());
976    }
977    f.write_str(":")?;
978
979    // Partition visible into flat-scalar cells (Int/Uint/Bool/
980    // Char/Float/Enum/Ptr-without-deref) and compound members.
981    // Only flat scalars participate in column packing — values
982    // with their own internal structure (inline struct braces,
983    // pointer chases, arrays, truncated wrappers, cpu lists) get
984    // their own full-width lines so the column grid stays
985    // visually homogeneous.
986    //
987    // Reuse the pre-rendered string from `visible_rendered` for
988    // each scalar cell. The compound-member path doesn't need
989    // the rendered string — it recurses into
990    // `write_rendered_value` which will render at the deeper
991    // depth. This avoids the third-format pass.
992    let mut scalar_cells: Vec<(String, String)> = Vec::new();
993    let mut compound_members: Vec<&RenderedMember> = Vec::new();
994    for (m, rendered) in &visible_rendered {
995        if is_flat_scalar(&m.value) {
996            // Flat scalars always have a single-line rendering;
997            // unwrap the Option that was Some(_) at filter time.
998            let value_str = rendered.clone().expect(
999                "is_flat_scalar guarantees a single-line rendering; \
1000                 visible_rendered must carry Some(string) for flat scalars",
1001            );
1002            let name = if m.name.is_empty() {
1003                "<anon>".to_string()
1004            } else {
1005                m.name.clone()
1006            };
1007            scalar_cells.push((name, value_str));
1008        } else {
1009            compound_members.push(m);
1010        }
1011    }
1012
1013    // Emit scalar rows: 3 per row. Column alignment kicks in
1014    // only when there are >= 3 rows AND the field-name length
1015    // variation in a column is significant (>= 4 chars). Below
1016    // those thresholds, columns just separate with a 3-space
1017    // gap and `name=value` (no padding, no `=` alignment).
1018    if !scalar_cells.is_empty() {
1019        let cells_per_row = 3;
1020        let n = scalar_cells.len();
1021        let n_rows = n.div_ceil(cells_per_row);
1022        // Per-column max / min field-name length. `=` alignment
1023        // requires both >= 3 rows AND `max - min >= 4`. When
1024        // name lengths cluster within 3 chars of each other,
1025        // padding is overhead — the columns read fine without
1026        // it.
1027        let mut name_max = vec![0usize; cells_per_row];
1028        let mut name_min = vec![usize::MAX; cells_per_row];
1029        for row in 0..n_rows {
1030            for col in 0..cells_per_row {
1031                let idx = row * cells_per_row + col;
1032                if idx >= n {
1033                    break;
1034                }
1035                let nl = scalar_cells[idx].0.len();
1036                if nl > name_max[col] {
1037                    name_max[col] = nl;
1038                }
1039                if nl < name_min[col] {
1040                    name_min[col] = nl;
1041                }
1042            }
1043        }
1044        // Decide per-column whether to pad. Threshold: 3+ rows
1045        // AND >= 4 char variation. Below either bar, col is
1046        // unpadded.
1047        let pad_eq: Vec<bool> = (0..cells_per_row)
1048            .map(|col| {
1049                if n_rows < 3 {
1050                    return false;
1051                }
1052                let max = name_max[col];
1053                let min = name_min[col];
1054                if min == usize::MAX {
1055                    return false;
1056                }
1057                max.saturating_sub(min) >= 4
1058            })
1059            .collect();
1060        // Per-column cell-width (full `padded_name + sep + value`
1061        // length) for column-to-column alignment. Built after
1062        // pad_eq is final.
1063        let mut cell_widths = vec![0usize; cells_per_row];
1064        for row in 0..n_rows {
1065            for col in 0..cells_per_row {
1066                let idx = row * cells_per_row + col;
1067                if idx >= n {
1068                    break;
1069                }
1070                let (name, value) = &scalar_cells[idx];
1071                let cl = if pad_eq[col] {
1072                    name_max[col] + 3 + value.len() // "name    = value"
1073                } else {
1074                    name.len() + 1 + value.len() // "name=value"
1075                };
1076                if cl > cell_widths[col] {
1077                    cell_widths[col] = cl;
1078                }
1079            }
1080        }
1081        for row in 0..n_rows {
1082            f.write_str("\n")?;
1083            write_indent(f, depth + 1)?;
1084            for col in 0..cells_per_row {
1085                let idx = row * cells_per_row + col;
1086                if idx >= n {
1087                    break;
1088                }
1089                let (name, value) = &scalar_cells[idx];
1090                f.write_str(name)?;
1091                if pad_eq[col] {
1092                    // Pad name to column's max so equals signs
1093                    // line up. Use ` = ` (space-equals-space) for
1094                    // visual breathing room around the operator.
1095                    for _ in 0..name_max[col].saturating_sub(name.len()) {
1096                        f.write_str(" ")?;
1097                    }
1098                    f.write_str(" = ")?;
1099                } else {
1100                    // Compact form: bare `name=value`, no padding.
1101                    f.write_str("=")?;
1102                }
1103                f.write_str(value)?;
1104                // Trailing pad to align next column (3-space
1105                // minimum gap). The last cell on a row needs no
1106                // trailing pad — the line ends after the value.
1107                if col + 1 < cells_per_row && (row * cells_per_row + col + 1) < n {
1108                    let cell_len = if pad_eq[col] {
1109                        name_max[col] + 3 + value.len()
1110                    } else {
1111                        name.len() + 1 + value.len()
1112                    };
1113                    let pad = cell_widths[col].saturating_sub(cell_len) + 3;
1114                    for _ in 0..pad {
1115                        f.write_str(" ")?;
1116                    }
1117                }
1118            }
1119        }
1120    }
1121
1122    // Emit compound members: each on its own line at depth+1,
1123    // recursing into write_rendered_value at depth+1 so the
1124    // nested render picks up correct indentation.
1125    for m in compound_members {
1126        f.write_str("\n")?;
1127        write_indent(f, depth + 1)?;
1128        if m.name.is_empty() {
1129            f.write_str("<anon> ")?;
1130        } else {
1131            write!(f, "{} ", m.name)?;
1132        }
1133        write_rendered_value(f, &m.value, depth + 1)?;
1134    }
1135
1136    // Zero fields and bpf_printk format strings are suppressed
1137    // silently above; nothing further to emit at this level.
1138    Ok(())
1139}
1140
1141/// Width budget for the inline struct form. A struct whose
1142/// rendered single-line form exceeds this falls through to
1143/// multi-line. Matches the soft per-line cap in the dump-display
1144/// layer (so an inline struct that fits here also fits in the
1145/// failure-dump output column width).
1146const STRUCT_INLINE_WIDTH_BUDGET: usize = 120;
1147
1148/// Soft per-line budget for inline list rendering (arrays,
1149/// sparse runs). When the joined form exceeds this, the helper
1150/// wraps at element boundaries with continuation indented to the
1151/// caller's `depth + 1` so the next row aligns under the bracket
1152/// open. Picked to match the struct inline budget — the two
1153/// paths share a visual line-width target.
1154const INLINE_LIST_WRAP_BUDGET: usize = 120;
1155
1156/// Render `parts` as `<open>part0, part1, ...<close>` joined by
1157/// `sep`. Single-line form is preferred; when the joined width
1158/// exceeds [`INLINE_LIST_WRAP_BUDGET`] the helper wraps at
1159/// element boundaries — never mid-element. Continuation rows
1160/// indent to `depth + 1` so wrapping aligns with the caller's
1161/// column.
1162///
1163/// Each `parts` entry is a fully-rendered single-line string
1164/// already (no embedded `\n`). The helper does not re-render or
1165/// truncate; it only inserts line breaks at part boundaries.
1166fn write_inline_list_wrapped(
1167    f: &mut std::fmt::Formatter<'_>,
1168    open: &str,
1169    close: &str,
1170    parts: &[String],
1171    sep: &str,
1172    depth: usize,
1173) -> std::fmt::Result {
1174    if parts.is_empty() {
1175        f.write_str(open)?;
1176        return f.write_str(close);
1177    }
1178    // Probe single-line form: open + parts.join(sep) + close.
1179    let sep_len = sep.len();
1180    let mut total = open.len() + close.len();
1181    for (i, p) in parts.iter().enumerate() {
1182        if i > 0 {
1183            total += sep_len;
1184        }
1185        total += p.len();
1186    }
1187    f.write_str(open)?;
1188    if total <= INLINE_LIST_WRAP_BUDGET {
1189        // Fits on one line.
1190        for (i, p) in parts.iter().enumerate() {
1191            if i > 0 {
1192                f.write_str(sep)?;
1193            }
1194            f.write_str(p)?;
1195        }
1196        return f.write_str(close);
1197    }
1198    // Wrap mode: emit parts greedily, breaking to a new line
1199    // whenever the next part would push the current line past
1200    // budget. The first part stays on the open-bracket line so
1201    // the reader's eye doesn't have to track an empty `[`.
1202    // Continuation lines indent to depth + 1; that's deeper than
1203    // the bracket itself but matches the multi-line struct's
1204    // scalar-row indent, keeping the visual hierarchy consistent.
1205    let indent = INDENT.repeat(depth + 1);
1206    // Track current-line cursor: starts at length of `open`.
1207    let mut cursor = open.len();
1208    for (i, p) in parts.iter().enumerate() {
1209        if i == 0 {
1210            f.write_str(p)?;
1211            cursor += p.len();
1212            continue;
1213        }
1214        // Check whether adding this part (with sep prefix) would
1215        // exceed the line budget.
1216        let next_len = sep_len + p.len();
1217        if cursor + next_len > INLINE_LIST_WRAP_BUDGET {
1218            // Wrap before this part. The separator's leading
1219            // characters (e.g. `, ` → `,`) stay on the previous
1220            // line so the comma reads naturally; here we elide
1221            // the leading whitespace by starting the new line
1222            // with just indent + part.
1223            f.write_str(sep.trim_end())?;
1224            f.write_str("\n")?;
1225            f.write_str(&indent)?;
1226            f.write_str(p)?;
1227            cursor = indent.len() + p.len();
1228        } else {
1229            f.write_str(sep)?;
1230            f.write_str(p)?;
1231            cursor += next_len;
1232        }
1233    }
1234    f.write_str(close)?;
1235    Ok(())
1236}
1237
1238/// Inline-fit probe over pre-rendered member values. Returns
1239/// `Some(joined_string)` when the struct's `TypeName{f=v, f=v}`
1240/// form fits within [`STRUCT_INLINE_WIDTH_BUDGET`]; `None`
1241/// otherwise (multi-line path takes over).
1242///
1243/// Each `(member, rendered)` pair carries the value's single-line
1244/// rendering — `None` rendering means the value is multi-line and
1245/// disqualifies the struct from inline form. Zero / format-string
1246/// / overlay-dup members are already filtered out by the caller.
1247///
1248/// The render-once invariant: this probe builds the inline string
1249/// by JOINING the pre-rendered values without re-formatting them.
1250/// The final write either commits the same string (inline path)
1251/// or discards it and the multi-line path reuses the same Vec
1252/// (no second render).
1253fn try_inline_from_rendered(
1254    type_name: Option<&str>,
1255    visible_rendered: &[(&RenderedMember, Option<String>)],
1256) -> Option<String> {
1257    if visible_rendered.is_empty() {
1258        // Empty visible set: emit `Type{}` (or `{}` for anon).
1259        let s = match type_name {
1260            Some(n) => format!("{n}{{}}"),
1261            None => "{}".to_string(),
1262        };
1263        return if s.len() <= STRUCT_INLINE_WIDTH_BUDGET {
1264            Some(s)
1265        } else {
1266            None
1267        };
1268    }
1269    // Bail to multi-line if any member's value renders multi-line.
1270    let mut field_strs = Vec::with_capacity(visible_rendered.len());
1271    for (m, value_str) in visible_rendered {
1272        let v = value_str.as_deref()?;
1273        let name = if m.name.is_empty() {
1274            "<anon>"
1275        } else {
1276            m.name.as_str()
1277        };
1278        field_strs.push(format!("{name}={v}"));
1279    }
1280    let body = field_strs.join(", ");
1281    let s = match type_name {
1282        Some(n) => format!("{n}{{{body}}}"),
1283        None => format!("{{{body}}}"),
1284    };
1285    if s.len() <= STRUCT_INLINE_WIDTH_BUDGET {
1286        Some(s)
1287    } else {
1288        None
1289    }
1290}
1291
1292pub fn is_zero(v: &RenderedValue) -> bool {
1293    match v {
1294        RenderedValue::Int { value, .. } => *value == 0,
1295        RenderedValue::Uint { value, .. } => *value == 0,
1296        RenderedValue::Bool { value } => !*value,
1297        RenderedValue::Char { value } => *value == 0,
1298        RenderedValue::Float { value, .. } => *value == 0.0,
1299        RenderedValue::Enum { value, .. } => *value == 0,
1300        RenderedValue::CpuList { cpus } => cpus.is_empty(),
1301        // Ptr zero-detection: only the numeric value matters. A
1302        // null pointer with a `deref_skipped_reason` (rare but
1303        // possible if a future code path attaches a reason without
1304        // a chase) is still zero — the reason is diagnostic, not
1305        // a value carrier.
1306        RenderedValue::Ptr { value, .. } => *value == 0,
1307        // Skip recursive is_zero on compounds — the subtree traversal
1308        // is O(leaves) and doubles the total rendering cost. Compound
1309        // types are always rendered; only scalars get zero-suppressed.
1310        // Use [`is_deeply_zero`] when an all-zero compound should be
1311        // suppressed alongside scalars (e.g. struct Display arm
1312        // collapsing all-zero nested aggregates into the "(N fields
1313        // zero)" summary).
1314        _ => false,
1315    }
1316}
1317
1318/// Numeric scalar value of a [`RenderedValue`] for cross-member
1319/// dedup. Returns `Some(u64)` for the scalar variants that carry
1320/// a numeric value (Int, Uint, Bool, Char, Enum, Ptr); `None` for
1321/// the rest. Signed Int values are reinterpreted as `u64` bit
1322/// patterns to allow comparison against unsigned siblings — the
1323/// dedup heuristic compares wire bit patterns, not arithmetic
1324/// values.
1325fn scalar_numeric_value(v: &RenderedValue) -> Option<u64> {
1326    match v {
1327        RenderedValue::Int { value, .. } => Some(*value as u64),
1328        RenderedValue::Uint { value, .. } => Some(*value),
1329        RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
1330        RenderedValue::Char { value } => Some(*value as u64),
1331        RenderedValue::Enum { value, .. } => Some(*value as u64),
1332        RenderedValue::Ptr { value, .. } => Some(*value),
1333        _ => None,
1334    }
1335}
1336
1337/// Build the sibling-scalar-value pool used by anonymous-overlay
1338/// dedup. Walks `members` once, collecting non-zero scalar values
1339/// from each named sibling AND descending one level into a sibling
1340/// Struct (the common single-field-struct case
1341/// e.g. `tid: struct sdt_id { val: u64 }`). Returning the set
1342/// once lets the caller pass it into [`anon_duplicates_pool`] for
1343/// each anonymous member without re-walking the sibling list.
1344fn build_sibling_scalar_pool(members: &[RenderedMember]) -> std::collections::HashSet<u64> {
1345    let mut sibling_values: std::collections::HashSet<u64> = std::collections::HashSet::new();
1346    for s in members {
1347        // Pool only NAMED siblings: the anonymous overlays are the
1348        // values being deduped against this pool, so including their
1349        // own scalars would make the dedup self-referential.
1350        if s.name.is_empty() {
1351            continue;
1352        }
1353        if let Some(n) = scalar_numeric_value(&s.value) {
1354            if n != 0 {
1355                sibling_values.insert(n);
1356            }
1357        } else if let RenderedValue::Struct { members: sm, .. } = &s.value {
1358            for sub in sm {
1359                if let Some(n) = scalar_numeric_value(&sub.value)
1360                    && n != 0
1361                {
1362                    sibling_values.insert(n);
1363                }
1364            }
1365        }
1366    }
1367    sibling_values
1368}
1369
1370/// True when the anonymous member `anon` (a Struct overlay from a
1371/// BTF union) duplicates content already in the sibling-scalar
1372/// pool — every non-zero scalar leaf in `anon` has a value
1373/// already present in `pool`. Companion to
1374/// [`build_sibling_scalar_pool`]: caller builds the pool once,
1375/// queries it once per anonymous member.
1376///
1377/// Heuristic bound: the match is by scalar VALUE, not by field
1378/// offset, so an overlay whose every non-zero leaf coincidentally
1379/// equals an unrelated named sibling's value is also suppressed. The
1380/// effect is display-only (a hidden render row — never a
1381/// counter/verdict effect) and grows vanishingly unlikely as the leaf
1382/// count rises; offset-aware dedup would be the precise fix but is
1383/// unwarranted for a renderer.
1384fn anon_duplicates_pool(anon: &RenderedValue, pool: &std::collections::HashSet<u64>) -> bool {
1385    let RenderedValue::Struct { members, .. } = anon else {
1386        return false;
1387    };
1388    if members.is_empty() || pool.is_empty() {
1389        return false;
1390    }
1391    for m in members {
1392        match scalar_numeric_value(&m.value) {
1393            Some(0) => continue, // zero half of a wider scalar
1394            Some(n) => {
1395                if !pool.contains(&n) {
1396                    return false;
1397                }
1398            }
1399            None => return false, // compound sub-member; can't dedup
1400        }
1401    }
1402    true
1403}
1404
1405/// Recursive variant of [`is_zero`] that descends into compound
1406/// types: a `Struct` is deeply zero iff every member's value is
1407/// deeply zero (an empty member list also qualifies); an `Array`
1408/// is deeply zero iff every element is deeply zero (an empty
1409/// elements vec also qualifies). For scalars the result matches
1410/// [`is_zero`].
1411///
1412/// `Bytes`, `Truncated`, and `Unsupported` are treated as NOT
1413/// deeply zero — they carry diagnostic content (hex bytes, decoded
1414/// partial, error reason) the consumer needs to see even when the
1415/// numeric content happens to be all zeros.
1416///
1417/// Recursion is capped at depth 16 to bound pathological BTF where
1418/// rendered nesting exceeds the renderer's own
1419/// [`MAX_RENDER_DEPTH`] cap. The cap is defense-in-depth: a
1420/// well-formed render produced by [`render_value_inner`] cannot
1421/// nest deeper than [`MAX_RENDER_DEPTH`] (32), but the cap here
1422/// stops the helper from hanging on a malformed externally-supplied
1423/// `RenderedValue` tree.
1424pub(crate) fn is_deeply_zero(v: &RenderedValue) -> bool {
1425    /// Recursion cap. 16 is comfortably below
1426    /// [`MAX_RENDER_DEPTH`] (32) so a render that came from
1427    /// [`render_value_inner`] always terminates well within the
1428    /// cap; the cap protects helper callers that may construct a
1429    /// `RenderedValue` tree from sources outside the renderer's
1430    /// depth-limited path.
1431    const MAX_DEPTH: u32 = 16;
1432    fn inner(v: &RenderedValue, depth: u32) -> bool {
1433        if depth >= MAX_DEPTH {
1434            // Past the cap: refuse to commit. Returning `false`
1435            // means the caller treats the value as non-zero,
1436            // surfacing it in Display rather than silently
1437            // suppressing a deeply nested subtree we couldn't
1438            // fully verify.
1439            return false;
1440        }
1441        match v {
1442            RenderedValue::Struct { members, .. } => {
1443                members.iter().all(|m| inner(&m.value, depth + 1))
1444            }
1445            RenderedValue::Array { elements, .. } => elements.iter().all(|e| inner(e, depth + 1)),
1446            // Bytes carries diagnostic hex; Truncated carries a
1447            // partial render the operator must see; Unsupported
1448            // carries an error reason. None of these are
1449            // suppressible, regardless of the numeric content
1450            // they may or may not encode.
1451            RenderedValue::Bytes { .. }
1452            | RenderedValue::Truncated { .. }
1453            | RenderedValue::Unsupported { .. } => false,
1454            // Scalars: match the canonical is_zero.
1455            _ => is_zero(v),
1456        }
1457    }
1458    inner(v, 0)
1459}
1460
1461/// Try to render array-of-structs groups as a template: show the
1462/// struct once with per-index values for fields that vary. Returns
1463/// true if template rendering was used.
1464fn try_write_struct_template(
1465    f: &mut std::fmt::Formatter<'_>,
1466    groups: &[(usize, usize, &RenderedValue)],
1467    depth: usize,
1468) -> Result<bool, std::fmt::Error> {
1469    // All groups must be single-element Structs with the same member count.
1470    let structs: Vec<(usize, &[RenderedMember])> = groups
1471        .iter()
1472        .filter_map(|(start, end, val)| {
1473            if start != end {
1474                return None;
1475            }
1476            match val {
1477                RenderedValue::Struct { members, .. } => Some((*start, members.as_slice())),
1478                _ => None,
1479            }
1480        })
1481        .collect();
1482    if structs.len() != groups.len() || structs.len() < 3 {
1483        return Ok(false);
1484    }
1485    let member_count = structs[0].1.len();
1486    if structs.iter().any(|(_, m)| m.len() != member_count) {
1487        return Ok(false);
1488    }
1489
1490    // Find which fields vary.
1491    let first = structs[0].1;
1492    let mut varying: Vec<usize> = Vec::new();
1493    for i in 0..member_count {
1494        if structs[1..]
1495            .iter()
1496            .any(|(_, m)| m[i].value != first[i].value)
1497        {
1498            varying.push(i);
1499        }
1500    }
1501    if varying.is_empty() || varying.len() > 3 {
1502        return Ok(false);
1503    }
1504
1505    // Validate that template indices are strictly contiguous
1506    // before emitting `[start-end]` range. Zero-suppression in the
1507    // caller can drop intermediate indices (e.g. groups [0,2,4] —
1508    // 0,1,2,3,4 with zeros at 1,3 dropped to keep the rendering
1509    // compact). The `[0-4]` header would then misleadingly imply
1510    // every index 0..=4 is in the template. Bail to fallback
1511    // per-element rendering when indices aren't consecutive.
1512    if !structs.windows(2).all(|pair| pair[1].0 == pair[0].0 + 1) {
1513        return Ok(false);
1514    }
1515
1516    // Emit template: struct with common fields shown, varying
1517    // fields as a per-index value table. Header uses the
1518    // breadcrumb form `[idx-range] TypeName:`. Common fields
1519    // render as `name=value` rows; varying fields render as
1520    // `name: [idx]=val [idx]=val ...` (the `:` introduces the
1521    // per-index list, not a field assignment).
1522    let type_name = match groups[0].2 {
1523        RenderedValue::Struct { type_name, .. } => type_name.as_deref(),
1524        _ => None,
1525    };
1526    let idx_range = format!("[{}-{}]", structs[0].0, structs.last().unwrap().0);
1527    f.write_str("\n")?;
1528    write_indent(f, depth)?;
1529    match type_name {
1530        Some(name) => write!(f, "{idx_range} {name}:")?,
1531        None => write!(f, "{idx_range}:")?,
1532    }
1533
1534    for (i, m) in first.iter().enumerate() {
1535        if varying.contains(&i) {
1536            continue;
1537        }
1538        // is_deeply_zero so all-zero compound members (e.g. an
1539        // empty inner struct) suppress alongside scalars in the
1540        // template's common-fields section. Matches the main
1541        // `write_struct` filter — without this, a template would
1542        // render an `inner={}` line for the same value that the
1543        // non-template path collapses silently, producing
1544        // inconsistent output for callers that flip between
1545        // template and per-element rendering.
1546        if is_deeply_zero(&m.value) {
1547            continue;
1548        }
1549        f.write_str("\n")?;
1550        write_indent(f, depth + 1)?;
1551        write!(f, "{}=", m.name)?;
1552        write_rendered_value(f, &m.value, depth + 1)?;
1553    }
1554
1555    // Varying fields as compact per-index lines. The label form
1556    // `name:` introduces the per-index list — distinct from
1557    // `name=value` because each row carries multiple values, one
1558    // per index.
1559    for &vi in &varying {
1560        f.write_str("\n")?;
1561        write_indent(f, depth + 1)?;
1562        write!(f, "{}: ", first[vi].name)?;
1563        for (idx, members) in &structs {
1564            write!(f, "[{idx}]=")?;
1565            write_rendered_value(f, &members[vi].value, depth + 1)?;
1566            f.write_str(" ")?;
1567        }
1568    }
1569
1570    // Zero fields are suppressed silently — no count line.
1571    Ok(true)
1572}
1573
1574/// Try to render bytes as a cpumask cpu-list. Reads u64 words from
1575/// the start of `bytes`, extracts set bits, and formats as
1576/// `cpus={0,2,5-7}`. Returns None if bytes are too short.
1577///
1578/// `max_cpus` caps the highest CPU id walked: bits at positions >=
1579/// `max_cpus` are treated as out-of-range (slab padding / freelist
1580/// garbage) and stop the walk. The kernel sizes `struct cpumask`'s
1581/// `bits` array to `BITS_TO_LONGS(NR_CPUS)` words but only the first
1582/// `nr_cpu_ids` bits are meaningful — the bytes between
1583/// `nr_cpu_ids` and the slab allocation size are uninitialized or
1584/// recycled freelist data. Callers that don't have `nr_cpu_ids`
1585/// available pass `u32::MAX` (no cap).
1586fn try_render_cpumask_bits(bytes: &[u8], max_cpus: u32) -> Option<RenderedValue> {
1587    if bytes.len() < 8 {
1588        return None;
1589    }
1590    let n_words = bytes.len() / 8;
1591    let mut set_cpus: Vec<u32> = Vec::new();
1592    for word_idx in 0..n_words {
1593        let off = word_idx * 8;
1594        if off + 8 > bytes.len() {
1595            break;
1596        }
1597        let word_first_cpu = (word_idx * 64) as u64;
1598        // Once the first cpu id covered by this word is at or
1599        // beyond `max_cpus`, no further bits in this or later
1600        // words are meaningful — stop walking.
1601        if word_first_cpu >= max_cpus as u64 {
1602            break;
1603        }
1604        let word = u64::from_le_bytes(bytes[off..off + 8].try_into().unwrap());
1605        if word == 0 {
1606            continue;
1607        }
1608        // Pointer-shape gate (mirrors the kptr-cpumask path below):
1609        // slab garbage in trailing words can appear as a high-bit-set
1610        // u64 that would enumerate phantom CPU IDs; bail when a later
1611        // word looks like a kernel address (top byte 0xff). An all-ones
1612        // word is a fully-online 64-CPU chunk, NOT a pointer, so it is
1613        // decoded. The previous `word > 0xFFFF_FFFF && set_cpus.len() >
1614        // 64` gate wrongly bailed on those all-ones words, silently
1615        // dropping CPUs 128+ on a fully-online >128-CPU host. Apply
1616        // BEFORE pushing this word's bits so a suspect word never
1617        // contaminates `set_cpus`.
1618        if word != u64::MAX && word >> 56 == 0xff {
1619            break;
1620        }
1621        for bit in 0..64 {
1622            let cpu = (word_idx * 64 + bit) as u32;
1623            // Per-bit cap: skip bits at or above max_cpus. The
1624            // outer `word_first_cpu` gate handles whole-word
1625            // bailout; this catches the partial-word case where
1626            // max_cpus falls inside the current word (e.g.
1627            // max_cpus=8 with first word at cpu 0 — bits 8..63
1628            // are slab padding).
1629            if cpu >= max_cpus {
1630                break;
1631            }
1632            if word & (1u64 << bit) != 0 {
1633                set_cpus.push(cpu);
1634            }
1635        }
1636    }
1637    Some(RenderedValue::CpuList {
1638        cpus: format_cpu_list(&set_cpus),
1639    })
1640}
1641
1642/// Format a sorted list of CPU IDs as a range-collapsed string.
1643/// e.g. `[0,1,2,5,7,8,9]` → "0-2,5,7-9"
1644///
1645/// Writes to a single `String` with `fmt::Write` so each range
1646/// emits at most two integer formats and one comma — half the
1647/// allocations of the prior `Vec<String>` + `join(",")` approach.
1648fn format_cpu_list(cpus: &[u32]) -> String {
1649    use std::fmt::Write;
1650    if cpus.is_empty() {
1651        return String::new();
1652    }
1653    let mut out = String::new();
1654    let mut start = cpus[0];
1655    let mut end = cpus[0];
1656    let flush = |out: &mut String, start: u32, end: u32| {
1657        if !out.is_empty() {
1658            out.push(',');
1659        }
1660        if start == end {
1661            // unwrap is safe: write! to String never fails.
1662            let _ = write!(out, "{start}");
1663        } else {
1664            let _ = write!(out, "{start}-{end}");
1665        }
1666    };
1667    for &cpu in &cpus[1..] {
1668        if cpu == end + 1 {
1669            end = cpu;
1670        } else {
1671            flush(&mut out, start, end);
1672            start = cpu;
1673            end = cpu;
1674        }
1675    }
1676    flush(&mut out, start, end);
1677    out
1678}
1679
1680fn is_text_byte(b: u8) -> bool {
1681    // Conservative: only NUL (C string terminator), \n, and printable
1682    // ASCII. \t and \r are excluded — binary BPF arrays starting with
1683    // those bytes were misclassified as strings.
1684    b == 0 || b == b'\n' || (0x20..=0x7e).contains(&b)
1685}
1686
1687fn is_string_value(v: &RenderedValue) -> bool {
1688    match v {
1689        RenderedValue::Array { elements, .. } => {
1690            elements.len() >= 2
1691                && elements.iter().all(|e| match e {
1692                    RenderedValue::Int { bits: 8, value } => is_text_byte(*value as u8),
1693                    RenderedValue::Uint { bits: 8, value } => is_text_byte(*value as u8),
1694                    RenderedValue::Char { value } => is_text_byte(*value),
1695                    _ => false,
1696                })
1697        }
1698        _ => false,
1699    }
1700}
1701
1702/// Whether a value renders as a single line under Display. Used by
1703/// the array case to pick inline vs block layout, and by
1704/// `super::dump::display` to decide whether a struct's fields
1705/// qualify for inline-entry rendering in the FailureDumpEntry /
1706/// FailureDumpMap table layouts.
1707pub(crate) fn is_inline_scalar(v: &RenderedValue) -> bool {
1708    match v {
1709        RenderedValue::Int { .. }
1710        | RenderedValue::Uint { .. }
1711        | RenderedValue::Bool { .. }
1712        | RenderedValue::Char { .. }
1713        | RenderedValue::Float { .. }
1714        | RenderedValue::Enum { .. }
1715        | RenderedValue::Bytes { .. }
1716        | RenderedValue::Unsupported { .. } => true,
1717        RenderedValue::Ptr { deref, .. } => deref.is_none(),
1718        _ => false,
1719    }
1720}
1721
1722/// Whether a value is a "flat scalar" — a primitive carrying a
1723/// single rendered token without any internal structure. Used by
1724/// the multi-line struct path to decide which fields participate
1725/// in column packing: only flat scalars can pack 3-per-row, since
1726/// compound forms (inline struct braces, pointer derefs, arrays,
1727/// truncation wrappers, cpu lists) would mismatch the column
1728/// grid.
1729///
1730/// Stricter than [`is_inline_scalar`]: a `Ptr` with `deref:
1731/// Some(...)` is NOT flat (it carries an arrow plus a nested
1732/// render), and `Bytes` / `Unsupported` carry diagnostic content
1733/// that prefers its own line for readability. `CpuList` reads
1734/// like a structure (`cpus={0-3}`) so it stays out of the column
1735/// grid too.
1736pub(crate) fn is_flat_scalar(v: &RenderedValue) -> bool {
1737    match v {
1738        RenderedValue::Int { .. }
1739        | RenderedValue::Uint { .. }
1740        | RenderedValue::Bool { .. }
1741        | RenderedValue::Char { .. }
1742        | RenderedValue::Float { .. }
1743        | RenderedValue::Enum { .. } => true,
1744        // Ptr is flat only when there's no deref payload. A
1745        // pointer rendered as `0xADDR → ...` doesn't fit a
1746        // narrow column.
1747        RenderedValue::Ptr {
1748            deref: None,
1749            deref_skipped_reason: None,
1750            ..
1751        } => true,
1752        _ => false,
1753    }
1754}
1755
1756fn write_indent(f: &mut std::fmt::Formatter<'_>, depth: usize) -> std::fmt::Result {
1757    for _ in 0..depth {
1758        f.write_str(INDENT)?;
1759    }
1760    Ok(())
1761}
1762
1763// Re-export [`CastHit`] so the renderer's [`MemReader::cast_lookup`]
1764// trait method can name the return type without forcing every
1765// caller to import the cast_analysis module path. [`AddrSpace`] is
1766// no longer re-exported — it lives at its canonical home in
1767// [`super::cast_analysis::AddrSpace`] and the renderer treats the
1768// hint as runtime-secondary, so callers that need the variant
1769// import it directly from cast_analysis.
1770pub use super::cast_analysis::CastHit;
1771
1772/// Outcome of [`MemReader::resolve_arena_type`]: the BTF type id the
1773/// chase should render against, paired with the byte count the
1774/// chase must skip past the chased address before the payload
1775/// struct begins.
1776///
1777/// The production
1778/// `super::dump::render_map::AccessorMemReader::resolve_arena_type`
1779/// emits exactly two `header_skip` shapes — mid-slot pointers
1780/// (header-region or mid-payload offsets) return `None`:
1781///
1782/// - `header_skip == 0` (payload-start chase): the chased address
1783///   already lands at the slot's payload start, e.g. the return of
1784///   `scx_task_data(p)` cached in `cached_taskc_raw`. The renderer
1785///   reads `btf_size` bytes from the chased address and renders
1786///   directly against `target_type_id`.
1787/// - `header_skip == slot.header_size` (slot-start chase): the
1788///   chased address lands at the slot's first byte, e.g. the
1789///   `data` field of `scx_task_map_val` storing the raw return of
1790///   `sdt_alloc()`. The renderer reads `header_skip + btf_size`
1791///   bytes from the chased address and slices off the leading
1792///   `header_skip` bytes (the `union sdt_id` header) before
1793///   rendering the payload struct against `target_type_id`.
1794///
1795/// Field name `target_type_id` matches
1796/// [`CastHit::target_type_id`]'s precedent so the two
1797/// chase-routing return shapes use the same vocabulary.
1798#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1799pub struct ArenaResolveHit {
1800    /// BTF type id (in the entry BTF's id space) of the payload
1801    /// struct the chase should render against. Resolved from the
1802    /// allocator slot's `target_type_id` populated by the
1803    /// sdt_alloc pre-pass.
1804    pub target_type_id: u32,
1805    /// Byte count the chase must skip past the chased address
1806    /// before the payload struct begins. `0` for a payload-start
1807    /// chase (the chased address already lands at the payload);
1808    /// the slot's `header_size` for a slot-start chase (the chase
1809    /// must skip the leading `union sdt_id` header before
1810    /// rendering the payload).
1811    pub header_skip: usize,
1812}
1813
1814/// Aggregate kind of a `BTF_KIND_FWD` terminal: `struct foo;` vs
1815/// `union foo;`. Threaded into [`MemReader::cross_btf_resolve_fwd`]
1816/// so the resolver only matches a same-name complete body whose
1817/// aggregate kind agrees — a `Fwd` declared as `struct foo` must
1818/// NOT resolve to a `union foo` in another BTF (the wire format
1819/// permits same-name struct + union declarations, rare but legal).
1820/// Mirrors the gate [`peel_modifiers_resolving_fwd`] applies for
1821/// in-BTF resolution.
1822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1823pub enum FwdKind {
1824    /// `BTF_KIND_FWD` declared as `struct foo;`. Matches the
1825    /// `true` branch of [`btf_rs::Fwd::is_struct`].
1826    Struct,
1827    /// `BTF_KIND_FWD` declared as `union foo;`. Matches the
1828    /// `false` branch of [`btf_rs::Fwd::is_struct`].
1829    Union,
1830}
1831
1832impl FwdKind {
1833    /// Build a [`FwdKind`] from a [`btf_rs::Fwd`]'s aggregate kind
1834    /// flag. The two callers
1835    /// ([`try_cross_btf_fwd_resolve`] and
1836    /// [`peel_modifiers_resolving_fwd`]) reach the flag via
1837    /// [`btf_rs::Fwd::is_struct`].
1838    pub fn from_is_struct(is_struct: bool) -> Self {
1839        if is_struct {
1840            FwdKind::Struct
1841        } else {
1842            FwdKind::Union
1843        }
1844    }
1845}
1846
1847/// Reference to a complete struct/union definition in a BTF other
1848/// than the one the chase entered with. Returned by
1849/// [`MemReader::cross_btf_resolve_fwd`] when a `BTF_KIND_FWD`
1850/// terminal in the entry BTF resolves to a body in a sibling
1851/// object's BTF (the multi-`.bpf.objs` shape: one object declares
1852/// `struct foo;` (forward), another defines `struct foo { ... }`
1853/// (full body)).
1854///
1855/// Borrowed: the BTF reference is tied to the [`MemReader`]
1856/// implementation's owned BTF storage (typically `Arc<Btf>` retained
1857/// across the dump pass). Render code that recurses into `btf`
1858/// must thread the same [`MemReader`] through so further chases
1859/// from inside the cross-BTF subtree can also resolve cross-BTF
1860/// (same-name structs in either direction).
1861///
1862/// `type_id` is the resolved struct/union type id WITHIN `btf`'s
1863/// own id space — distinct from the entry BTF's id space. The
1864/// chase code switches the rendering BTF to `btf` for the
1865/// recursion against `type_id`.
1866///
1867/// `Copy + Clone`: a borrowed reference plus a `u32` is bitwise
1868/// copyable. The pair lets the chase paths pass the value by
1869/// `Copy` rather than by `&` and sidesteps the move-after-borrow
1870/// snags in any match that would otherwise consume the hit
1871/// twice. [`Debug`] / [`Hash`] / [`Eq`] are blocked by [`Btf`]
1872/// in `btf-rs` — it does not derive any of them — so the
1873/// minimal `Copy + Clone` set is the most we can offer until
1874/// upstream changes.
1875#[derive(Copy, Clone)]
1876pub struct CrossBtfRef<'a> {
1877    /// Sibling BTF that carries the resolved struct/union body.
1878    /// Borrowed from the [`MemReader`] implementation's owned BTF
1879    /// storage (typically `Arc<Btf>` retained across the dump
1880    /// pass) — the renderer must thread the same [`MemReader`]
1881    /// through any recursion into this BTF so further chases
1882    /// from inside the cross-BTF subtree can also resolve
1883    /// cross-BTF.
1884    pub btf: &'a Btf,
1885    /// Resolved struct/union type id WITHIN [`Self::btf`]'s own
1886    /// id space — distinct from the entry BTF's id space. The
1887    /// chase code switches the rendering BTF to [`Self::btf`]
1888    /// and recurses against this id.
1889    pub type_id: u32,
1890}
1891
1892/// # CrossBtfMemReader contract
1893///
1894/// [`CrossBtfMemReader`] wraps a `&dyn MemReader` and selectively
1895/// suppresses id-keyed lookups (cast_lookup, resolve_arena_type)
1896/// at cross-BTF boundaries. When adding a new method to this trait:
1897/// check whether the method is id-keyed (operates on BTF type IDs
1898/// from the entry BTF). If yes, CrossBtfMemReader MUST override it
1899/// to return `None`/default. If no (raw addresses, string names),
1900/// CrossBtfMemReader should delegate to inner. Failing to audit
1901/// causes silent wrong-renders in cross-BTF chase paths.
1902pub trait MemReader {
1903    fn read_kva(&self, kva: u64, len: usize) -> Option<Vec<u8>>;
1904    /// Check if an address is in the arena range. Arena pointers
1905    /// resolve into `ArenaSnapshot`'s captured page set, so the
1906    /// reader has a frozen byte view — chasing them is well-defined.
1907    /// Kernel kptrs (slab/vmalloc allocations outside the arena
1908    /// window) MAY be stale references to objects already freed by
1909    /// the time the freeze captured them; the renderer applies a
1910    /// best-effort plausibility heuristic (top-byte check on the
1911    /// first qword to reject obvious freelist next-pointer
1912    /// patterns — see [`render_cast_pointer`] and the cpumask kptr
1913    /// branch in the [`btf_rs::Type::Ptr`] arm) but cannot verify
1914    /// liveness. Cast-recovered kernel kptrs ARE chased through
1915    /// [`MemReader::read_kva`] when [`MemReader::cast_lookup`]
1916    /// returns an `AddrSpace::Kernel` hit, even though slab
1917    /// liveness is not guaranteed; the heuristic gates and the
1918    /// `deref_skipped_reason` field on [`RenderedValue::Ptr`]
1919    /// surface uncertainty without dropping the hit. Default
1920    /// returns false — pointer chasing skips arena resolution
1921    /// silently.
1922    fn is_arena_addr(&self, _addr: u64) -> bool {
1923        false
1924    }
1925    /// Read bytes from the captured arena at a user-space arena
1926    /// address. Default returns None — the Ptr deref path emits the
1927    /// raw pointer hex without chasing.
1928    ///
1929    /// Returns None if the address is unmapped or the full requested
1930    /// length cannot be read.
1931    fn read_arena(&self, _addr: u64, _len: usize) -> Option<Vec<u8>> {
1932        None
1933    }
1934    /// Guest's `nr_cpu_ids` — the number of possible CPUs the
1935    /// kernel exposes to userspace allocators (`cpumask_size()`,
1936    /// percpu arrays, etc.). The cpumask renderer caps the bit
1937    /// walk at this value: the kernel's `struct cpumask` `bits`
1938    /// slab allocation is sized to `BITS_TO_LONGS(NR_CPUS)`, but
1939    /// only the first `nr_cpu_ids` bits are meaningful — bits
1940    /// beyond that are slab-internal padding or freelist garbage
1941    /// that `SLAB_FREELIST_HARDENED` XOR-encoding can mask the
1942    /// top-byte heuristic from rejecting. Default returns
1943    /// `u32::MAX` (no cap) so callers without the value still
1944    /// produce a render.
1945    fn nr_cpu_ids(&self) -> u32 {
1946        u32::MAX
1947    }
1948    /// Look up a cast finding for `(parent_type_id, member_byte_offset)`.
1949    /// `parent_type_id` is the BTF type id of the *struct/union* that
1950    /// owns the member (already peeled through Typedef / Const /
1951    /// Volatile / Restrict / TypeTag / DeclTag — the cast analyzer
1952    /// keys on the underlying aggregate, not the modifier-wrapped
1953    /// surface type).
1954    /// `member_byte_offset` is the byte offset of the `u64` member
1955    /// inside that struct.
1956    ///
1957    /// Returning `Some(hit)` lets the renderer interpret a `u64`
1958    /// member as `Ptr(hit.target_type_id)` and chase it through the
1959    /// reader corresponding to `hit.addr_space`. Returning `None`
1960    /// (the default) leaves the renderer's existing behavior intact:
1961    /// the field renders as a plain unsigned integer. Default-`None`
1962    /// keeps every existing [`MemReader`] impl correct without an
1963    /// explicit override.
1964    fn cast_lookup(&self, _parent_type_id: u32, _member_byte_offset: u32) -> Option<CastHit> {
1965        None
1966    }
1967    /// Resolve a chased arena pointer value to the BTF type id of
1968    /// the payload it points at, plus a `header_skip` byte count
1969    /// that tells the chase how to land on the payload struct from
1970    /// the chased address.
1971    ///
1972    /// The intended trigger: a [`Type::Ptr`] (or cast-recovered
1973    /// pointer) whose declared pointee is a [`Type::Fwd`] whose body
1974    /// lives in a separate BTF object. The scheduler's program BTF
1975    /// carries only the `BTF_KIND_FWD` forward declaration — there is
1976    /// no struct body to size against, so the renderer's
1977    /// [`chase_arena_pointer`] / [`render_cast_pointer`] paths skip
1978    /// with an "unsizable" reason. The
1979    /// [`super::sdt_alloc::SdtAllocatorSnapshot`] pre-pass already
1980    /// resolves the real payload BTF type id via
1981    /// [`super::sdt_alloc::discover_payload_btf_id`] for every live
1982    /// allocator. The dump path threads a per-pass index of every
1983    /// live allocator slot into the renderer's [`MemReader`] so the
1984    /// chase can recover the real type id and the slot shape when
1985    /// the BTF-only resolve fails.
1986    ///
1987    /// `addr` is the chased pointer value as it appears in guest
1988    /// memory (i.e. the same value the renderer just read from a
1989    /// `u64` field or a [`Type::Ptr`] field). Implementations
1990    /// transform it into the index key shape they store. The
1991    /// sdt_alloc bridge stores one entry per slot keyed on
1992    /// `slot_start & 0xFFFF_FFFF`; the production
1993    /// `super::dump::render_map::AccessorMemReader` impl uses a
1994    /// range lookup to find the slot whose
1995    /// `[slot_start, slot_start + elem_size)` range contains the
1996    /// chased address.
1997    ///
1998    /// Returns `Some(ArenaResolveHit { target_type_id, header_skip })`
1999    /// when the address falls inside a known allocator slot at a
2000    /// position the renderer can chase:
2001    ///
2002    /// - **Slot-start pointer** (the chased address equals the slot
2003    ///   start, e.g. the `data` field of `scx_task_map_val` storing
2004    ///   the raw return of `sdt_alloc()`): `header_skip` is the
2005    ///   slot's header size (typically 8 — the size of `union
2006    ///   sdt_id`). The renderer reads `header_skip + btf_size`
2007    ///   bytes from `addr`, slices off the first `header_skip`
2008    ///   bytes (the sdt_id header), and renders the payload struct
2009    ///   against `target_type_id`.
2010    /// - **Payload-start pointer** (the chased address equals
2011    ///   `slot_start + header_size`, e.g. the return of
2012    ///   `scx_task_data(p)` cached in `cached_taskc_raw`):
2013    ///   `header_skip == 0`. The renderer reads `btf_size` bytes
2014    ///   from `addr` and renders directly.
2015    ///
2016    /// Returns `None` (the default) when the reader has no index,
2017    /// the address is outside every known allocator's slot range,
2018    /// the addressed slot has no resolved payload type, or the
2019    /// chased pointer landed at an interior slot position
2020    /// (mid-header or mid-payload) the renderer cannot translate
2021    /// into a struct render. Default-`None` keeps existing
2022    /// [`MemReader`] impls correct without an explicit override.
2023    fn resolve_arena_type(&self, _addr: u64) -> Option<ArenaResolveHit> {
2024        None
2025    }
2026    /// Resolve a `BTF_KIND_FWD` terminal by struct/union name to a
2027    /// complete definition in a sibling BTF.
2028    ///
2029    /// The intended trigger: the renderer's chase paths
2030    /// ([`chase_arena_pointer`] and [`render_cast_pointer`]) just
2031    /// peeled the chase target through
2032    /// [`peel_modifiers_resolving_fwd`] but the local same-BTF
2033    /// sibling search came up empty. The terminal is still a
2034    /// `Type::Fwd`, so the BTF-only chase would skip with
2035    /// "forward declaration; body not in this BTF". This method
2036    /// asks the reader whether any sibling BTF (built once per
2037    /// scheduler binary by the cast-analysis pre-pass — see
2038    /// [`crate::vmm::cast_analysis_load::CastAnalysisOutput::fwd_index`])
2039    /// carries a complete body for `name` matching the `kind`
2040    /// aggregate kind ([`FwdKind::Struct`] or [`FwdKind::Union`]).
2041    ///
2042    /// Returning `Some(CrossBtfRef { btf, type_id })` lets the
2043    /// chase switch to `btf` and recurse into `type_id` for the
2044    /// pointee render. The same [`MemReader`] is threaded into
2045    /// the inner recursion so chases originating inside the
2046    /// cross-BTF subtree can also bridge — typical for a struct
2047    /// whose members are themselves `Fwd` to another BTF.
2048    ///
2049    /// Returning `None` (the default) preserves the historical
2050    /// "forward declaration; body not in this BTF" skip path —
2051    /// keeps every existing [`MemReader`] impl correct without
2052    /// an explicit override.
2053    fn cross_btf_resolve_fwd(&self, _name: &str, _kind: FwdKind) -> Option<CrossBtfRef<'_>> {
2054        None
2055    }
2056
2057    /// Type-gated meta fallback for sdt_alloc bridge. Only called
2058    /// when `resolve_arena_type` returned None AND the chase target
2059    /// is any Fwd-typed pointee (the [`try_sdt_alloc_bridge`] gate
2060    /// matches `Type::Fwd(_)`, not a specific `Fwd` name). Returns
2061    /// the first sdt_alloc meta with a resolved payload type if the
2062    /// address is in the arena window.
2063    fn resolve_arena_type_meta_fallback(&self, _addr: u64) -> Option<ArenaResolveHit> {
2064        None
2065    }
2066
2067    /// True when `addr` points at an allocator slot the dump pre-pass
2068    /// has rendered with a resolved payload type into
2069    /// `report.sdt_allocations`. The arena chase short-circuits on
2070    /// `true` so the per-map renderer doesn't re-render the same
2071    /// allocation a second time when it follows an entry pointer
2072    /// through a TASK_STORAGE / HASH map into the same arena slot.
2073    /// Implementations restrict the underlying set to slots from
2074    /// typed allocators (resolved `target_type_id != 0`); untyped
2075    /// pre-pass renders (hex fallback) must NOT suppress per-map
2076    /// chases because the cast analyzer's shape inference may resolve
2077    /// a target type the heuristic missed. Default returns `false` —
2078    /// readers without a rendered-slot index proceed with the chase.
2079    fn is_already_rendered(&self, _addr: u64) -> bool {
2080        false
2081    }
2082
2083    /// All unique alloc_sizes captured across the CastMap's Arena
2084    /// findings. Used as a last-resort fallback when a deferred-
2085    /// resolve chase has alloc_size=None: try each captured size
2086    /// with discover_payload_btf_id and use it if exactly one
2087    /// produces a unique BTF match. Default returns empty.
2088    fn alloc_size_types(&self) -> &[(u64, String)] {
2089        &[]
2090    }
2091}
2092
2093/// Render a BTF type's bytes into a [`RenderedValue`] without an
2094/// associated guest-memory reader. Pointer dereferences degrade
2095/// gracefully — the raw pointer hex is emitted without chasing.
2096#[allow(dead_code)]
2097pub fn render_value(btf: &Btf, type_id: u32, bytes: &[u8]) -> RenderedValue {
2098    let mut visited: HashSet<u64> = HashSet::new();
2099    render_value_inner(btf, type_id, bytes, 0, None::<&dyn MemReader>, &mut visited)
2100}
2101
2102/// Render a BTF type's bytes into a [`RenderedValue`] with an
2103/// associated guest-memory reader for pointer chasing. Identical to
2104/// [`render_value`] except the supplied [`MemReader`] is threaded
2105/// through the [`btf_rs::Type::Ptr`] arm and the cast-intercept path
2106/// in `render_member`, so:
2107///
2108/// - BTF-typed pointers ([`btf_rs::Type::Ptr`]) are dereferenced via
2109///   [`MemReader::read_arena`] (when [`MemReader::is_arena_addr`]
2110///   matches) or the cpumask kptr chase via [`MemReader::read_kva`].
2111/// - `u64` fields the cast analyzer flagged via
2112///   [`MemReader::cast_lookup`] are interpreted as typed pointers and
2113///   chased through [`render_cast_pointer`] — the same chase path,
2114///   producing the same [`RenderedValue::Ptr`] shape.
2115///
2116/// Total in the same sense as [`render_value`]: any failure (unmapped
2117/// page, plausibility-gate rejection, cycle, depth cap) surfaces as a
2118/// `Ptr` with `deref: None` and a populated `deref_skipped_reason`,
2119/// never an error return.
2120pub fn render_value_with_mem(
2121    btf: &Btf,
2122    type_id: u32,
2123    bytes: &[u8],
2124    mem: &dyn MemReader,
2125) -> RenderedValue {
2126    let mut visited: HashSet<u64> = HashSet::new();
2127    render_value_inner(btf, type_id, bytes, 0, Some(mem), &mut visited)
2128}
2129
2130/// `visited` carries the set of pointer addresses already chased on
2131/// the current traversal path. The `Type::Ptr` arm checks this set
2132/// before descending: a pointer whose target address is already in
2133/// `visited` is a cycle (most commonly a linked-list `next` pointer
2134/// pointing back to a node earlier on the chain). Without the
2135/// check, a cycle recurses until [`MAX_RENDER_DEPTH`] fires,
2136/// producing a 32-deep wall of identical nested structs in the
2137/// failure dump. With the check, the renderer surfaces a `[cycle]`
2138/// marker after the pointer's hex value and stops.
2139///
2140/// `MAX_RENDER_DEPTH` remains as a backstop for non-cycle
2141/// pathological BTF (deeply nested types without an actual cycle).
2142fn render_value_inner(
2143    btf: &Btf,
2144    type_id: u32,
2145    bytes: &[u8],
2146    depth: u32,
2147    mem: Option<&dyn MemReader>,
2148    visited: &mut HashSet<u64>,
2149) -> RenderedValue {
2150    if depth >= MAX_RENDER_DEPTH {
2151        return RenderedValue::Unsupported {
2152            reason: format!("render depth {MAX_RENDER_DEPTH} exceeded"),
2153        };
2154    }
2155
2156    let Some((ty, peeled_type_id)) = peel_modifiers_with_id(btf, type_id) else {
2157        return RenderedValue::Unsupported {
2158            reason: format!("could not peel modifiers from type id {type_id}"),
2159        };
2160    };
2161
2162    match ty {
2163        Type::Int(int) => render_int(&int, bytes),
2164        Type::Float(float) => render_float(float.size(), bytes),
2165        Type::Enum(e) => {
2166            // Enum values are 32-bit on the wire (vlen entries each
2167            // holding `val: u32`), but the underlying storage size
2168            // comes from `Enum::size()`.
2169            let needed = e.size();
2170            if bytes.len() < needed {
2171                return RenderedValue::Truncated {
2172                    needed,
2173                    had: bytes.len(),
2174                    partial: Box::new(RenderedValue::Bytes {
2175                        hex: hex_dump(bytes),
2176                    }),
2177                };
2178            }
2179            let raw = read_uint_le(&bytes[..needed]);
2180            let signed = e.is_signed();
2181            let value = if signed {
2182                sign_extend(raw, needed * 8) as i64
2183            } else {
2184                raw as i64
2185            };
2186            // Width-truncate the member value to the enum's actual byte
2187            // width before comparing. `raw` is read from `needed` bytes
2188            // (zero-extended into a u64), but a member's stored value for
2189            // a signed variant is the full 32-bit value (e.g. -1 →
2190            // 0xFFFF_FFFF). For a sub-4-byte signed enum the member value
2191            // would never match the narrower `raw` without masking, so
2192            // the variant name would be silently lost. Resolved name is
2193            // best-effort: an unknown raw value yields `None` rather than
2194            // failing the render.
2195            let val_mask: u64 = if needed * 8 >= 64 {
2196                u64::MAX
2197            } else {
2198                (1u64 << (needed * 8)) - 1
2199            };
2200            let variant = e
2201                .members
2202                .iter()
2203                .find(|m| (m.val() as u64) & val_mask == raw)
2204                .and_then(|m| btf.resolve_name(m).ok());
2205            RenderedValue::Enum {
2206                bits: (needed * 8) as u32,
2207                value,
2208                variant,
2209                is_signed: signed,
2210            }
2211        }
2212        Type::Enum64(e) => {
2213            let needed = e.size();
2214            if bytes.len() < needed {
2215                return RenderedValue::Truncated {
2216                    needed,
2217                    had: bytes.len(),
2218                    partial: Box::new(RenderedValue::Bytes {
2219                        hex: hex_dump(bytes),
2220                    }),
2221                };
2222            }
2223            let raw = read_uint_le(&bytes[..needed]);
2224            let signed = e.is_signed();
2225            let value = if signed {
2226                sign_extend(raw, needed * 8) as i64
2227            } else {
2228                raw as i64
2229            };
2230            // Width-truncate the member value to the enum's byte width
2231            // (see the Enum32 arm) so sub-8-byte signed variants resolve
2232            // their name instead of falling back to the bare integer.
2233            let val_mask: u64 = if needed * 8 >= 64 {
2234                u64::MAX
2235            } else {
2236                (1u64 << (needed * 8)) - 1
2237            };
2238            let variant = e
2239                .members
2240                .iter()
2241                .find(|m| (m.val() & val_mask) == raw)
2242                .and_then(|m| btf.resolve_name(m).ok());
2243            RenderedValue::Enum {
2244                bits: (needed * 8) as u32,
2245                value,
2246                variant,
2247                is_signed: signed,
2248            }
2249        }
2250        Type::Ptr(ptr) => {
2251            if bytes.len() < 8 {
2252                return RenderedValue::Truncated {
2253                    needed: 8,
2254                    had: bytes.len(),
2255                    partial: Box::new(RenderedValue::Bytes {
2256                        hex: hex_dump(bytes),
2257                    }),
2258                };
2259            }
2260            let val = u64::from_le_bytes(bytes[..8].try_into().unwrap());
2261            // `deref_skipped_reason` carries the cause when chase is
2262            // attempted but produces no deref (cross-page failure,
2263            // 4 KiB cap, plausibility gate, cycle). It stays `None`
2264            // when no chase was attempted (null val, depth cap, no
2265            // reader). The operator distinguishes "we didn't try"
2266            // from "we tried and got nothing useful" via this
2267            // field.
2268            let mut deref_skipped_reason: Option<String> = None;
2269            // `cast_annotation` is normally `None` for BTF-typed
2270            // pointers (the Type::Ptr arm) — the field is reserved
2271            // for the cast analyzer's recovered pointers. The one
2272            // exception is a Fwd-pointee chase the renderer
2273            // recovered via [`MemReader::resolve_arena_type`] (the
2274            // sdt_alloc bridge): the chased pointer is structurally
2275            // BTF-typed but the body lives in another BTF, so the
2276            // renderer surfaces a `sdt_alloc` annotation to flag the
2277            // recovered chase even on this arm. Set inside
2278            // [`chase_arena_pointer`]'s outcome and threaded out via
2279            // the side-channel below.
2280            let mut cast_annotation: Option<Cow<'static, str>> = None;
2281            // [`chase_gate`] applies the null/cycle/depth-cap policy
2282            // shared with [`render_cast_pointer`]: null and
2283            // depth-cap take the "no chase attempted" path
2284            // (`deref` + reason both `None`); a cycle records the
2285            // `cycle → 0x{val:x}` reason and skips the chase. Only
2286            // [`ChaseGate::Proceed`] enters the `mem.and_then`
2287            // closure that performs the actual read.
2288            let deref = match chase_gate(val, depth, visited) {
2289                ChaseGate::Skip { reason } => {
2290                    deref_skipped_reason = reason;
2291                    None
2292                }
2293                ChaseGate::Proceed => mem.and_then(|m| {
2294                    let pointee_type_id = ptr.get_type_id()?;
2295                    if m.is_arena_addr(val) {
2296                        // Arena chase factored into the shared
2297                        // helper so this arm and
2298                        // [`render_cast_pointer`]'s arena branch
2299                        // produce identical [`RenderedValue::Ptr`]
2300                        // shapes (including the
2301                        // [`RenderedValue::Truncated`] wrap when
2302                        // `btf_size > POINTER_CHASE_CAP`). The
2303                        // helper computes `btf_size` from
2304                        // `pointee_type_id`, so the local
2305                        // peel/size resolution that follows runs
2306                        // only on the kptr path below.
2307                        // BTF-typed pointer chase: no captured
2308                        // `alloc_size` — the renderer arrives via the
2309                        // [`Type::Ptr`] arm with a known pointee type
2310                        // id, not via a cast finding. The size-match
2311                        // fallback only fires when the analyzer's
2312                        // STX-flow path emitted `target_type_id == 0`,
2313                        // which is the cast-finding code path below.
2314                        let outcome =
2315                            chase_arena_pointer(btf, pointee_type_id, None, val, m, depth, visited);
2316                        if outcome.reason.is_some() {
2317                            deref_skipped_reason = outcome.reason;
2318                        }
2319                        if outcome.sdt_alloc_resolved {
2320                            cast_annotation = Some(Cow::Borrowed("sdt_alloc"));
2321                        }
2322                        return outcome.deref;
2323                    }
2324                    // Use the Fwd-resolving peel so a kernel kptr
2325                    // whose declared pointee is a [`Type::Fwd`] with
2326                    // a complete sibling in the BTF lands on the
2327                    // sibling rather than failing the size gate
2328                    // below. Drops [`effective_type_id`] because
2329                    // this arm does not recurse into a struct
2330                    // render — the cpumask-name dispatch below
2331                    // works against the resolved [`Type`] alone.
2332                    let (pointee_ty, _) = peel_modifiers_resolving_fwd(btf, pointee_type_id)?;
2333                    let btf_size = type_size(btf, &pointee_ty)?;
2334                    if btf_size == 0 {
2335                        // Sanity gate: an incomplete pointee type
2336                        // is not safe to chase even on the cpumask
2337                        // path (BTF reported no bytes — the
2338                        // underlying allocation may not exist as
2339                        // declared). Match the historical Type::Ptr
2340                        // arm behavior.
2341                        deref_skipped_reason =
2342                            Some("pointee BTF size is 0 (incomplete type)".to_string());
2343                        return None;
2344                    }
2345                    // Kernel kptr: only chase cpumask pointers.
2346                    // Read up to NR_CPUS / 8 bytes from the bitmap
2347                    // backing storage and plausibility-gate the
2348                    // first word against a hardcoded heuristic
2349                    // (see below).
2350                    let is_cpumask_ptr = match &pointee_ty {
2351                        Type::Struct(s) => {
2352                            let n = btf.resolve_name(s).unwrap_or_default();
2353                            n == "bpf_cpumask" || n == "cpumask"
2354                        }
2355                        _ => false,
2356                    };
2357                    if is_cpumask_ptr {
2358                        // Read enough bytes to cover NR_CPUS up to
2359                        // 8192 (=1024 bytes = 128 u64 words). The
2360                        // kernel allocates the `struct cpumask`
2361                        // `bits` storage from a slab cache sized to
2362                        // `cpumask_size()`, which is `(NR_CPUS + 7)
2363                        // / 8` rounded up to a multiple of 8 —
2364                        // bounded by NR_CPUS at config time. 1024
2365                        // covers every modern distro kernel;
2366                        // mainline NR_CPUS_DEFAULT is 8192 for
2367                        // x86_64 / aarch64. The per-word walker
2368                        // below caps the rendered bits at the
2369                        // guest's `nr_cpu_ids` so a small guest
2370                        // (e.g. 8 CPUs) doesn't render bits
2371                        // 64..8191 from slab padding.
2372                        const CPUMASK_READ_CAP: usize = 1024;
2373                        let Some(bits_bytes) = m.read_kva(val, CPUMASK_READ_CAP) else {
2374                            deref_skipped_reason = Some(format!(
2375                                "cpumask kptr read_kva failed at 0x{val:x} \
2376                                 (unmapped page or no PTE)"
2377                            ));
2378                            return None;
2379                        };
2380                        if bits_bytes.len() < 8 {
2381                            deref_skipped_reason = Some(format!(
2382                                "cpumask kptr read returned {} bytes; need at least 8",
2383                                bits_bytes.len()
2384                            ));
2385                            return None;
2386                        }
2387                        let max_cpus = m.nr_cpu_ids();
2388                        let bits0 = u64::from_le_bytes(bits_bytes[..8].try_into().ok()?);
2389                        // Best-effort plausibility heuristic on
2390                        // `bits[0]`: a freed slab object's first
2391                        // qword is often a freelist next pointer,
2392                        // which on x86_64 / aarch64 lands in the
2393                        // canonical kernel range
2394                        // (0xffff8000_00000000+, top byte 0xff).
2395                        // Reject a top-byte-0xff first word as a
2396                        // probable stale-pointer pattern — EXCEPT an
2397                        // all-ones word (`u64::MAX`), which is a
2398                        // fully-online <=64-CPU mask. 0xFFFF..FFFF is
2399                        // non-canonical and is never a real freelist
2400                        // next pointer (a next pointer carries address
2401                        // bits, so it is never all-ones), so decoding
2402                        // it as a mask cannot alias a live kernel
2403                        // pointer. The `nr_cpu_ids` cap below
2404                        // backstops the heuristic: even when
2405                        // SLAB_FREELIST_HARDENED XOR-encodes the next
2406                        // pointer (defeating the top-byte gate), set
2407                        // bits beyond `nr_cpu_ids` are dropped rather
2408                        // than rendered as phantom cpu ids. The gate
2409                        // is intentionally cheap; a production-grade
2410                        // detector would walk the SLUB metadata to
2411                        // confirm liveness.
2412                        if bits0 == u64::MAX || bits0 >> 56 != 0xff {
2413                            let mut cpus = Vec::new();
2414                            // Walk every full u64 in the read.
2415                            // `bits_bytes.len()` is at least 8
2416                            // (gated above) and a multiple of 8 in
2417                            // practice (the read cap is a multiple
2418                            // of 8 and the read returns a contiguous
2419                            // bytes vector).
2420                            'walk: for word_idx in 0..(bits_bytes.len() / 8) {
2421                                let off = word_idx * 8;
2422                                let word_first_cpu = (word_idx * 64) as u64;
2423                                // Cap at the guest's nr_cpu_ids:
2424                                // bits beyond that are slab
2425                                // padding, not part of the
2426                                // kernel-meaningful mask.
2427                                if word_first_cpu >= max_cpus as u64 {
2428                                    break;
2429                                }
2430                                let word =
2431                                    u64::from_le_bytes(bits_bytes[off..off + 8].try_into().ok()?);
2432                                // Per-word pointer-pattern gate.
2433                                // Slab garbage in trailing words can
2434                                // appear as a high-bit-set u64 that
2435                                // would otherwise enumerate phantom
2436                                // CPU IDs; bail out of the walk when
2437                                // a later word looks like a kernel
2438                                // address rather than mask bits. An
2439                                // all-ones word is a fully-online
2440                                // 64-CPU chunk, not a pointer, so it is
2441                                // decoded (same exception as bits[0]).
2442                                if word != u64::MAX && word >> 56 == 0xff {
2443                                    break;
2444                                }
2445                                for bit in 0..64u32 {
2446                                    let cpu = (word_idx * 64) as u32 + bit;
2447                                    // Partial-word cap: max_cpus
2448                                    // can fall mid-word (e.g.
2449                                    // nr_cpu_ids=8 means bits
2450                                    // 8..63 of word 0 are padding).
2451                                    if cpu >= max_cpus {
2452                                        break 'walk;
2453                                    }
2454                                    if word & (1u64 << bit) != 0 {
2455                                        cpus.push(cpu);
2456                                    }
2457                                }
2458                            }
2459                            return Some(Box::new(RenderedValue::CpuList {
2460                                cpus: format_cpu_list(&cpus),
2461                            }));
2462                        } else {
2463                            deref_skipped_reason = Some(format!(
2464                                "cpumask kptr plausibility gate rejected: bits[0] top \
2465                                 byte is 0xff at 0x{val:x} (likely freed slab object)"
2466                            ));
2467                        }
2468                    }
2469                    None
2470                }),
2471            };
2472            RenderedValue::Ptr {
2473                value: val,
2474                deref,
2475                deref_skipped_reason,
2476                cast_annotation,
2477            }
2478        }
2479        Type::Struct(s) | Type::Union(s) => {
2480            // `peeled_type_id` is the BTF id of `s` after modifier
2481            // peel — the form [`super::cast_analysis::CastMap`] keys
2482            // its `(parent_type_id, member_byte_offset)` lookups
2483            // against. Threaded into `render_struct` so per-member
2484            // cast intercepts can consult the reader.
2485            render_struct(btf, &s, peeled_type_id, bytes, depth, mem, visited)
2486        }
2487        Type::Array(arr) => {
2488            // `Array::get_type_id` returns the element type id
2489            // directly (`btf_array.r#type`), so resolving a chained
2490            // type purely to fish for the id is redundant. The
2491            // element's *Type* (used for size) does need a chained
2492            // resolve.
2493            let len = arr.len();
2494            let Some(elem_type_id) = arr.get_type_id() else {
2495                return RenderedValue::Unsupported {
2496                    reason: "array element type id not resolvable".to_string(),
2497                };
2498            };
2499            let Ok(elem_ty) = btf.resolve_chained_type(&arr) else {
2500                return RenderedValue::Unsupported {
2501                    reason: "array element type not resolvable".to_string(),
2502                };
2503            };
2504            let Some(elem_size) = type_size(btf, &elem_ty) else {
2505                return RenderedValue::Unsupported {
2506                    reason: "array element size not resolvable".to_string(),
2507                };
2508            };
2509            // Flex array detection: BTF encodes a trailing `T[];` /
2510            // `T[0]` member as `array.len == 0`. The C-side runtime
2511            // length lives in a sibling field (e.g. struct topology's
2512            // `nr_children`), which the renderer doesn't have access
2513            // to here. Emit Unsupported with an explicit reason
2514            // rather than silently rendering as `[]` — the operator
2515            // sees that the array IS a flex array and that runtime
2516            // population is opaque to the BTF-only renderer. Best-
2517            // effort element extraction (via a sibling-field read of
2518            // nr_children, etc.) is out of scope: it requires
2519            // parent-struct context the array arm doesn't carry.
2520            //
2521            // `elem_size > 0` is required so a true zero-element
2522            // type-id-only array (synthesized by libbpf for empty
2523            // sections etc.) doesn't accidentally surface as flex.
2524            if len == 0 && elem_size > 0 && !bytes.is_empty() {
2525                return RenderedValue::Unsupported {
2526                    reason: format!(
2527                        "flex array (BTF len=0); runtime length not \
2528                         representable in BTF, {} bytes available at site",
2529                        bytes.len()
2530                    ),
2531                };
2532            }
2533            let cap = len.min(MAX_ARRAY_ELEMS);
2534            let mut elements = Vec::with_capacity(cap);
2535            for i in 0..cap {
2536                let start = i * elem_size;
2537                let end = start + elem_size;
2538                if end > bytes.len() {
2539                    let avail = &bytes[start.min(bytes.len())..];
2540                    elements.push(RenderedValue::Truncated {
2541                        needed: elem_size,
2542                        had: avail.len(),
2543                        partial: Box::new(RenderedValue::Bytes {
2544                            hex: hex_dump(avail),
2545                        }),
2546                    });
2547                    break;
2548                }
2549                elements.push(render_value_inner(
2550                    btf,
2551                    elem_type_id,
2552                    &bytes[start..end],
2553                    depth + 1,
2554                    mem,
2555                    visited,
2556                ));
2557            }
2558            RenderedValue::Array { len, elements }
2559        }
2560        Type::Fwd(_) => RenderedValue::Unsupported {
2561            reason: "forward declaration: type body not in BTF".to_string(),
2562        },
2563        Type::Func(_) | Type::FuncProto(_) => RenderedValue::Unsupported {
2564            reason: "function type: no value bytes to render".to_string(),
2565        },
2566        Type::Datasec(ds) => {
2567            // `peeled_type_id` is the BTF id of the datasec after
2568            // modifier peel — the form
2569            // [`super::cast_analysis::CastMap`] keys its
2570            // `(parent_type_id, member_byte_offset)` lookups
2571            // against. Threaded into `render_datasec` so per-
2572            // variable cast intercepts can consult the reader,
2573            // mirroring the `render_struct` path that handles
2574            // struct/union members.
2575            render_datasec(btf, &ds, peeled_type_id, bytes, depth, mem, visited)
2576        }
2577        Type::Var(var) => {
2578            // Standalone Var (i.e. asked to render a Var type id
2579            // outside a Datasec walk): forward to its underlying
2580            // type. The Var node carries a name but no storage of
2581            // its own — render its referent against the supplied
2582            // bytes. A failed type-id lookup falls back to
2583            // Unsupported rather than panicking.
2584            let Some(inner_id) = var.get_type_id() else {
2585                return RenderedValue::Unsupported {
2586                    reason: "var type id not resolvable".to_string(),
2587                };
2588            };
2589            render_value_inner(btf, inner_id, bytes, depth + 1, mem, visited)
2590        }
2591        Type::Void => RenderedValue::Unsupported {
2592            reason: "void: no value bytes to render".to_string(),
2593        },
2594        // Modifier types should have been peeled by peel_modifiers.
2595        // If one slipped through, treat it as unsupported rather than
2596        // looping forever.
2597        Type::Volatile(_)
2598        | Type::Const(_)
2599        | Type::Restrict(_)
2600        | Type::Typedef(_)
2601        | Type::TypeTag(_)
2602        | Type::DeclTag(_) => RenderedValue::Unsupported {
2603            reason: "unpeeled modifier (BTF cycle?)".to_string(),
2604        },
2605        _ => RenderedValue::Unsupported {
2606            reason: format!("unhandled BTF type kind {ty:?} for type id {peeled_type_id}"),
2607        },
2608    }
2609}
2610
2611fn render_int(int: &btf_rs::Int, bytes: &[u8]) -> RenderedValue {
2612    let needed = int.size();
2613    if bytes.len() < needed {
2614        return RenderedValue::Truncated {
2615            needed,
2616            had: bytes.len(),
2617            partial: Box::new(RenderedValue::Bytes {
2618                hex: hex_dump(bytes),
2619            }),
2620        };
2621    }
2622    if int.is_bool() && needed >= 1 {
2623        // C `_Bool` is canonically 1 byte but BTF can describe wider
2624        // boolean ints. Truthiness must consider every byte in the
2625        // declared width: a 4-byte `_Bool` set to 0x00000100 is true,
2626        // not false. The first-byte-only check predated this fix and
2627        // would silently miss any non-zero byte above the LSB.
2628        return RenderedValue::Bool {
2629            value: bytes[..needed].iter().any(|&b| b != 0),
2630        };
2631    }
2632    if int.is_char() && needed == 1 {
2633        return RenderedValue::Char { value: bytes[0] };
2634    }
2635    // BTF allows ints wider than 8 bytes (e.g. 128-bit __int128).
2636    // `read_uint_le` caps at 8 bytes, so silently feeding it a wider
2637    // span would discard the upper bits. Fall back to a Bytes hex
2638    // dump rather than producing a half-decoded numeric value.
2639    if needed > 8 {
2640        return RenderedValue::Bytes {
2641            hex: hex_dump(&bytes[..needed]),
2642        };
2643    }
2644    let raw = read_uint_le(&bytes[..needed]);
2645    if int.is_signed() {
2646        let value = sign_extend(raw, needed * 8) as i64;
2647        RenderedValue::Int {
2648            bits: (needed * 8) as u32,
2649            value,
2650        }
2651    } else {
2652        RenderedValue::Uint {
2653            bits: (needed * 8) as u32,
2654            value: raw,
2655        }
2656    }
2657}
2658
2659// Hex dump of byte slices is the `pub(crate) hex_dump` helper in
2660// [`super::dump::hex_dump`] — imported above. Single canonical
2661// implementation; renderer and dump path share the same wire shape.
2662
2663fn render_float(size: usize, bytes: &[u8]) -> RenderedValue {
2664    if bytes.len() < size {
2665        return RenderedValue::Truncated {
2666            needed: size,
2667            had: bytes.len(),
2668            partial: Box::new(RenderedValue::Bytes {
2669                hex: hex_dump(bytes),
2670            }),
2671        };
2672    }
2673    let value = match size {
2674        4 => f32::from_le_bytes(bytes[..4].try_into().unwrap()) as f64,
2675        8 => f64::from_le_bytes(bytes[..8].try_into().unwrap()),
2676        _ => {
2677            return RenderedValue::Unsupported {
2678                reason: format!("unsupported float size {size}"),
2679            };
2680        }
2681    };
2682    RenderedValue::Float {
2683        bits: (size * 8) as u32,
2684        value,
2685    }
2686}
2687
2688fn render_struct(
2689    btf: &Btf,
2690    s: &Struct,
2691    parent_type_id: u32,
2692    bytes: &[u8],
2693    depth: u32,
2694    mem: Option<&dyn MemReader>,
2695    visited: &mut HashSet<u64>,
2696) -> RenderedValue {
2697    let type_name = btf.resolve_name(s).ok().filter(|n| !n.is_empty());
2698
2699    // Intercept cpumask-family structs: render as cpu-list instead
2700    // of per-field dump. Detect by struct name. The
2701    // [`MemReader::nr_cpu_ids`] cap rejects bits past the guest's
2702    // actual CPU count — kernel cpumask slab allocations are sized
2703    // to NR_CPUS at config time but only the first `nr_cpu_ids`
2704    // bits carry meaningful data. Without the cap, slab padding /
2705    // freelist garbage in trailing words renders as phantom CPU
2706    // ids (the SLAB_FREELIST_HARDENED top-byte heuristic in the
2707    // Ptr arm doesn't apply to embedded cpumask_t bytes). When
2708    // `mem` is None (no reader plumbed), use `u32::MAX` so the
2709    // pre-existing behavior — every set bit reported — is
2710    // preserved.
2711    let max_cpus = mem.map(|m| m.nr_cpu_ids()).unwrap_or(u32::MAX);
2712    if let Some(ref name) = type_name {
2713        match name.as_str() {
2714            "cpumask" | "cpumask_t" => {
2715                if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2716                    return cpu_list;
2717                }
2718            }
2719            "bpf_cpumask" => {
2720                // bpf_cpumask = { cpumask_t cpumask; refcount_t usage; }
2721                // cpumask starts at offset 0.
2722                if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2723                    return cpu_list;
2724                }
2725            }
2726            "scx_bitmap" => {
2727                // scx_bitmap = { sdt_id tid (8 bytes); u64 bits[64]; }
2728                if bytes.len() >= 16
2729                    && let Some(cpu_list) = try_render_cpumask_bits(&bytes[8..], max_cpus)
2730                {
2731                    return cpu_list;
2732                }
2733            }
2734            "llc_cpumask" => {
2735                // scx_mitosis per-LLC inline bitmap:
2736                // `struct llc_cpumask { unsigned long bits[NR]; }` —
2737                // bits at offset 0, the whole struct IS the mask (a
2738                // plain inline array, not a bpf_cpumask kptr). Decode
2739                // the raw bytes like cpumask_t.
2740                if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2741                    return cpu_list;
2742                }
2743            }
2744            _ => {}
2745        }
2746    }
2747
2748    let truncated = bytes.len() < s.size();
2749    // Render every member regardless of whether the full struct
2750    // fits: `render_member` already emits a per-member
2751    // [`RenderedValue::Truncated`] for members that extend past
2752    // the supplied bytes. This way, the outer Truncated's
2753    // `partial` field carries the members that DID decode, instead
2754    // of discarding the whole render. See [`RenderedValue::Truncated`]
2755    // doc for the partial-render contract.
2756    let mut members = Vec::with_capacity(s.members.len());
2757    for m in &s.members {
2758        let bit_off = m.bit_offset() as usize;
2759        let byte_off = bit_off / 8;
2760        if byte_off >= bytes.len() && bytes.len() < s.size() {
2761            continue;
2762        }
2763        let name = btf.resolve_name(m).unwrap_or_default();
2764        let value = render_member(btf, m, Some(parent_type_id), bytes, depth, mem, visited);
2765        if name.is_empty()
2766            && let RenderedValue::Struct { members: inner, .. } = value
2767        {
2768            members.extend(inner);
2769            continue;
2770        }
2771        members.push(RenderedMember { name, value });
2772    }
2773    let rendered = RenderedValue::Struct { type_name, members };
2774    if truncated {
2775        RenderedValue::Truncated {
2776            needed: s.size(),
2777            had: bytes.len(),
2778            partial: Box::new(rendered),
2779        }
2780    } else {
2781        rendered
2782    }
2783}
2784
2785/// Shared BPF cast intercept gate for [`render_member`] and
2786/// [`render_datasec`]. Caller passes the (parent_id, byte_off) key
2787/// the cast analyzer would have seen. Returns `Some(rendered)` when
2788/// every gate aligns and the field is a cast-recovered typed
2789/// pointer; `None` otherwise (caller falls through to its standard
2790/// render path).
2791///
2792/// `field_bytes` must be the parent_bytes / section_bytes slice
2793/// from `byte_off` onward (i.e. the slice already advanced to the
2794/// field's start). The helper reads the first 8 bytes; a slice
2795/// shorter than 8 bytes falls through to `None`.
2796///
2797/// Gates (in order):
2798/// - `mem` is `Some` (no chase is possible without a [`MemReader`]).
2799/// - `peeled` peels to a plain unsigned 8-byte [`Type::Int`] — BPF
2800///   stores typed pointers in `u64` slots; signed, `_Bool`, `char`,
2801///   and sub-u64 widths are not the cast analyzer's output shape.
2802/// - `byte_off` fits in `u32` (datasec / struct offsets exceed
2803///   `u32::MAX` only in malformed BTF; the analyzer keys offsets as
2804///   `u32`).
2805/// - [`MemReader::cast_lookup`] returns a hit for
2806///   `(parent_type_id, byte_off)` (default `None` keeps every
2807///   reader correct without an explicit override).
2808/// - `field_bytes` is at least 8 bytes long (a truncated field
2809///   falls through to the existing partial-decode path so the
2810///   consumer still sees whatever survived).
2811fn try_cast_intercept(
2812    btf: &Btf,
2813    cast_key: (u32, usize),
2814    peeled: &Type,
2815    field_bytes: &[u8],
2816    depth: u32,
2817    mem: Option<&dyn MemReader>,
2818    visited: &mut HashSet<u64>,
2819) -> Option<RenderedValue> {
2820    let (parent_type_id, byte_off) = cast_key;
2821    let reader = mem?;
2822    let Type::Int(int) = peeled else {
2823        return None;
2824    };
2825    if int.size() != 8 || int.is_signed() || int.is_bool() || int.is_char() {
2826        return None;
2827    }
2828    let off_u32 = u32::try_from(byte_off).ok()?;
2829    let hit = reader.cast_lookup(parent_type_id, off_u32)?;
2830    let head = field_bytes.get(..8)?;
2831    let value = u64::from_le_bytes(head.try_into().ok()?);
2832    Some(render_cast_pointer(btf, hit, value, depth, reader, visited))
2833}
2834
2835/// Render a `BTF_KIND_DATASEC` (e.g. `.bss`, `.data`, `.rodata`) by
2836/// walking its `VarSecinfo` entries and rendering each variable into
2837/// the slice of section bytes its `offset()` and `size()` describe.
2838///
2839/// Each VarSecinfo's `get_type_id()` returns a `BTF_KIND_VAR` id; the
2840/// Var carries the variable's name and its underlying type's id. The
2841/// renderer slices `bytes[offset..offset+size]` and recursively
2842/// renders the underlying type into that slice. The result is a
2843/// [`RenderedValue::Struct`] whose `type_name` is the section name
2844/// (e.g. `.bss`) and whose `members` are the section's variables —
2845/// reusing `RenderedValue::Struct` rather than introducing a new
2846/// variant keeps the existing serde shape (`kind: "struct"`) and
2847/// Display layout intact, so a failure dump's `.bss` map renders
2848/// alongside ordinary structs and JSON consumers (the
2849/// `failure_dump_e2e.rs` fixture among them) iterate the variables via
2850/// `value.members[]` exactly as they iterate struct members today.
2851///
2852/// Truncation: an out-of-range `(offset, size)` for the supplied
2853/// `bytes` slice surfaces as a per-variable
2854/// [`RenderedValue::Truncated`] — the variable's name is still
2855/// recorded under [`RenderedMember::name`], so an operator sees
2856/// "variable X needed N bytes, had M" rather than the entire
2857/// section disappearing. Variables with malformed BTF (Var type id
2858/// fails to resolve, chained type isn't a Var) fall through to
2859/// [`RenderedValue::Unsupported`] with the reason recorded.
2860fn render_datasec(
2861    btf: &Btf,
2862    ds: &btf_rs::Datasec,
2863    parent_type_id: u32,
2864    bytes: &[u8],
2865    depth: u32,
2866    mem: Option<&dyn MemReader>,
2867    visited: &mut HashSet<u64>,
2868) -> RenderedValue {
2869    // Section name lives on the Datasec itself
2870    // (BTF_KIND_DATASEC.name_off via `BtfType::get_name_offset`). An
2871    // empty / unresolvable name maps to `None`, matching
2872    // [`RenderedValue::Struct::type_name`]'s contract for anonymous
2873    // aggregates.
2874    let type_name = btf.resolve_name(ds).ok().filter(|n| !n.is_empty());
2875    let mut members = Vec::with_capacity(ds.variables.len());
2876    for var_info in &ds.variables {
2877        let offset = var_info.offset() as usize;
2878        let size = var_info.size();
2879        // Resolve the chained Var so we can pull the variable's
2880        // name and its underlying type id. A non-Var here indicates
2881        // malformed BTF (libbpf always emits Var per VarSecinfo);
2882        // record the failure as an Unsupported member rather than
2883        // dropping the slot.
2884        let chained = match btf.resolve_chained_type(var_info) {
2885            Ok(t) => t,
2886            Err(_) => {
2887                members.push(RenderedMember {
2888                    name: String::new(),
2889                    value: RenderedValue::Unsupported {
2890                        reason: "datasec var type not resolvable".to_string(),
2891                    },
2892                });
2893                continue;
2894            }
2895        };
2896        let var = match chained {
2897            Type::Var(v) => v,
2898            other => {
2899                members.push(RenderedMember {
2900                    name: String::new(),
2901                    value: RenderedValue::Unsupported {
2902                        reason: format!("datasec entry resolved to non-Var ({})", other.name()),
2903                    },
2904                });
2905                continue;
2906            }
2907        };
2908        let var_name = btf.resolve_name(&var).unwrap_or_default();
2909        let inner_id = match var.get_type_id() {
2910            Some(id) => id,
2911            None => {
2912                members.push(RenderedMember {
2913                    name: var_name,
2914                    value: RenderedValue::Unsupported {
2915                        reason: "var underlying type id not resolvable".to_string(),
2916                    },
2917                });
2918                continue;
2919            }
2920        };
2921        // BPF cast intercept: a `u64` global variable that the cast
2922        // analyzer recovered as a typed pointer renders as `Ptr`
2923        // with the recovered target chased through the appropriate
2924        // address-space reader. Mirrors the gate in
2925        // [`render_member`] for struct members, but keyed on the
2926        // datasec id + variable offset (the cast analyzer's
2927        // `(parent, off)` pair for a BSS / data global). Shared
2928        // gating logic lives in [`try_cast_intercept`].
2929        let cast_intercept = peel_modifiers(btf, inner_id).and_then(|inner_ty| {
2930            let field_bytes = bytes.get(offset..).unwrap_or_default();
2931            try_cast_intercept(
2932                btf,
2933                (parent_type_id, offset),
2934                &inner_ty,
2935                field_bytes,
2936                depth,
2937                mem,
2938                visited,
2939            )
2940        });
2941        if let Some(rv) = cast_intercept {
2942            members.push(RenderedMember {
2943                name: var_name,
2944                value: rv,
2945            });
2946            continue;
2947        }
2948        // Slice the section bytes for this variable. If the section
2949        // bytes are shorter than offset+size, emit a per-member
2950        // Truncated whose `partial` is whatever the inner renderer
2951        // can decode from the available subset (mirrors
2952        // `render_member`'s short-bytes behaviour). `checked_add`
2953        // guards against pathological BTF where `offset + size`
2954        // would overflow `usize` — without it, a torn VarSecinfo
2955        // could wrap past `usize::MAX` and the `<= bytes.len()`
2956        // comparison would silently become true, indexing out of
2957        // bounds.
2958        let end = offset.checked_add(size);
2959        let value = match end {
2960            Some(end) if end <= bytes.len() => {
2961                render_value_inner(btf, inner_id, &bytes[offset..end], depth + 1, mem, visited)
2962            }
2963            _ => {
2964                let avail_start = offset.min(bytes.len());
2965                let avail = &bytes[avail_start..];
2966                let partial = render_value_inner(btf, inner_id, avail, depth + 1, mem, visited);
2967                RenderedValue::Truncated {
2968                    needed: size,
2969                    had: avail.len(),
2970                    partial: Box::new(partial),
2971                }
2972            }
2973        };
2974        members.push(RenderedMember {
2975            name: var_name,
2976            value,
2977        });
2978    }
2979    RenderedValue::Struct { type_name, members }
2980}
2981
2982fn render_member(
2983    btf: &Btf,
2984    m: &Member,
2985    parent_type_id: Option<u32>,
2986    parent_bytes: &[u8],
2987    depth: u32,
2988    mem: Option<&dyn MemReader>,
2989    visited: &mut HashSet<u64>,
2990) -> RenderedValue {
2991    let bit_off = m.bit_offset() as usize;
2992    let Some(member_type_id) = m.get_type_id() else {
2993        return RenderedValue::Unsupported {
2994            reason: "member has no type id".to_string(),
2995        };
2996    };
2997
2998    if let Some(width) = m.bitfield_size()
2999        && width > 0
3000    {
3001        return render_bitfield(btf, member_type_id, parent_bytes, bit_off, width as usize);
3002    }
3003
3004    // Non-bitfield: assume bit_off is byte-aligned. Compute the
3005    // member's size from its (peeled) type and slice.
3006    if !bit_off.is_multiple_of(8) {
3007        return RenderedValue::Unsupported {
3008            reason: format!("non-bitfield member at non-byte bit offset {bit_off}"),
3009        };
3010    }
3011    let byte_off = bit_off / 8;
3012    let Some(member_ty) = peel_modifiers(btf, member_type_id) else {
3013        return RenderedValue::Unsupported {
3014            reason: "member type modifiers unresolvable".to_string(),
3015        };
3016    };
3017    let Some(size) = type_size(btf, &member_ty) else {
3018        return RenderedValue::Unsupported {
3019            reason: "member type size unresolvable".to_string(),
3020        };
3021    };
3022
3023    // BPF cast intercept: a `u64` member that the cast analyzer
3024    // recovered as a typed pointer is rendered as `Ptr` with the
3025    // recovered target chased through the appropriate address-space
3026    // reader. Shared gating logic lives in [`try_cast_intercept`];
3027    // the additional `parent_type_id?` here ensures we are inside a
3028    // struct that [`render_struct`] dispatched (a standalone Int
3029    // render carries no parent and skips the intercept).
3030    let cast_intercept = parent_type_id.and_then(|parent| {
3031        let field_bytes = parent_bytes.get(byte_off..).unwrap_or_default();
3032        try_cast_intercept(
3033            btf,
3034            (parent, byte_off),
3035            &member_ty,
3036            field_bytes,
3037            depth,
3038            mem,
3039            visited,
3040        )
3041    });
3042    if let Some(rv) = cast_intercept {
3043        return rv;
3044    }
3045
3046    if let Some(parent) = parent_type_id
3047        && let Type::Array(arr) = &member_ty
3048        && let Some(elem_tid) = arr.get_type_id()
3049        && let Some(elem_term) = peel_modifiers(btf, elem_tid)
3050        && type_size(btf, &elem_term) == Some(8)
3051        && matches!(
3052            elem_term,
3053            Type::Int(ref i) if i.size() == 8 && !i.is_signed() && !i.is_bool() && !i.is_char()
3054        )
3055    {
3056        let arr_len = arr.len();
3057        let has_any_cast = mem.is_some_and(|m| {
3058            (0..arr_len).any(|i| {
3059                let elem_off = (byte_off + i * 8) as u32;
3060                m.cast_lookup(parent, elem_off).is_some()
3061            })
3062        });
3063        if has_any_cast {
3064            let cap = arr_len.min(MAX_ARRAY_ELEMS);
3065            let mut elements = Vec::with_capacity(cap);
3066            for i in 0..cap {
3067                let elem_off = byte_off + i * 8;
3068                let elem_bytes = parent_bytes.get(elem_off..elem_off + 8).unwrap_or_default();
3069                if let Some(rv) = try_cast_intercept(
3070                    btf,
3071                    (parent, elem_off),
3072                    &elem_term,
3073                    elem_bytes,
3074                    depth + 1,
3075                    mem,
3076                    visited,
3077                ) {
3078                    elements.push(rv);
3079                } else {
3080                    elements.push(render_value_inner(
3081                        btf,
3082                        elem_tid,
3083                        elem_bytes,
3084                        depth + 1,
3085                        mem,
3086                        visited,
3087                    ));
3088                }
3089            }
3090            return RenderedValue::Array {
3091                len: arr_len,
3092                elements,
3093            };
3094        }
3095    }
3096
3097    // `checked_add` guards against pathological BTF where
3098    // `byte_off + size` would overflow `usize` (a torn member with
3099    // a wild bit_offset / size pair). Without the check, the wrap
3100    // would silently make the `> parent_bytes.len()` test false
3101    // and the slice would index out of bounds.
3102    let end = byte_off.checked_add(size);
3103    match end {
3104        Some(end) if end <= parent_bytes.len() => render_value_inner(
3105            btf,
3106            member_type_id,
3107            &parent_bytes[byte_off..end],
3108            depth + 1,
3109            mem,
3110            visited,
3111        ),
3112        _ => {
3113            // Attempt a partial decode from whatever bytes ARE available
3114            // for this member: the inner renderer will itself emit a
3115            // Truncated/Bytes/etc. that carries the recoverable subset.
3116            // Wrapping that subset in this outer Truncated tells the
3117            // consumer "the full member needed N bytes, only M survived,
3118            // here's what we got".
3119            let avail_start = byte_off.min(parent_bytes.len());
3120            let avail = &parent_bytes[avail_start..];
3121            let partial = render_value_inner(btf, member_type_id, avail, depth + 1, mem, visited);
3122            RenderedValue::Truncated {
3123                needed: size,
3124                had: avail.len(),
3125                partial: Box::new(partial),
3126            }
3127        }
3128    }
3129}
3130
3131/// Cap on bytes any pointer chase reads from a target. Shared
3132/// between the [`Type::Ptr`] arena branch and [`render_cast_pointer`]
3133/// so a single tunable applies to every chase the renderer performs:
3134/// a single arena page is 4 KiB, the [`MemReader::read_arena`]
3135/// contract bails on cross-page reads, and `MemReader::read_kva`
3136/// callers should avoid pulling many pages of slab content into the
3137/// dump for a single recovered pointer. Targets larger than the cap
3138/// surface as a [`RenderedValue::Truncated`] wrapping the partial
3139/// decode so the consumer can tell the rendered subtree was clipped.
3140const POINTER_CHASE_CAP: usize = 4096;
3141
3142/// Outcome of [`chase_gate`]: skip the chase (with optional reason
3143/// for `deref_skipped_reason`) or proceed with the read+recurse.
3144///
3145/// Lets the [`Type::Ptr`] arm and [`render_cast_pointer`] share a
3146/// single null/cycle/depth-cap policy: null and depth-cap produce
3147/// `Skip { reason: None }` (no chase attempted, no reason emitted);
3148/// cycle produces `Skip { reason: Some("cycle → 0x{val:x}") }` so
3149/// Display shows the cycle marker.
3150enum ChaseGate {
3151    /// Skip the chase. `reason` populates `deref_skipped_reason` on
3152    /// the resulting [`RenderedValue::Ptr`]; `None` means no chase
3153    /// was attempted and the operator sees an unannotated raw
3154    /// pointer.
3155    Skip { reason: Option<String> },
3156    /// All gates passed; the caller should perform the chase.
3157    Proceed,
3158}
3159
3160/// Pre-chase gate shared between the [`Type::Ptr`] arm and
3161/// [`render_cast_pointer`]. Returns [`ChaseGate::Skip`] when the
3162/// renderer must not chase `val` (null, already-visited cycle, or
3163/// recursion depth cap), [`ChaseGate::Proceed`] otherwise.
3164///
3165/// Order of checks matches both call sites' historical behavior:
3166/// null first, then cycle, then depth. `val == 0` short-circuits
3167/// before consulting `visited`, so a stray zero entry in the set
3168/// (which should not occur) does not surface as a phantom cycle.
3169fn chase_gate(val: u64, depth: u32, visited: &HashSet<u64>) -> ChaseGate {
3170    if val == 0 {
3171        return ChaseGate::Skip { reason: None };
3172    }
3173    if visited.contains(&val) {
3174        return ChaseGate::Skip {
3175            reason: Some(format!("cycle → 0x{val:x}")),
3176        };
3177    }
3178    if depth >= MAX_RENDER_DEPTH {
3179        return ChaseGate::Skip { reason: None };
3180    }
3181    ChaseGate::Proceed
3182}
3183
3184/// Compose a `deref_skipped_reason` string for a pointer chase
3185/// whose peeled target type has no BTF-resolvable storage size.
3186///
3187/// [`type_size`] returns `None` for [`Type::Fwd`] (forward-declared
3188/// struct/union with body in another BTF), [`Type::Func`],
3189/// [`Type::FuncProto`], [`Type::Datasec`], [`Type::Var`],
3190/// [`Type::Void`], and [`Type::DeclTag`] (which `peel_modifiers`
3191/// peels in practice — listed for completeness in case a future
3192/// analyzer or BTF shape leaks one through). Each variant has a
3193/// distinct cause; surfacing the variant name plus, when available,
3194/// the BTF-declared name of the type lets operators correlate the
3195/// failure with their source layout (e.g. `struct sdt_data` lives
3196/// in the sdt_alloc library's BTF and surfaces as Fwd in the
3197/// scheduler's own BTF).
3198///
3199/// `kind_label` is the call site's chase prefix (`"arena chase"` /
3200/// `"kernel cast"`) so the reason matches the existing message
3201/// style at each site without forcing each caller to thread the
3202/// label through a `format!`.
3203fn unsizable_chase_reason(
3204    btf: &Btf,
3205    kind_label: &'static str,
3206    target_type_id: u32,
3207    target_ty: &Type,
3208) -> String {
3209    match target_ty {
3210        Type::Fwd(fwd) => {
3211            // A Fwd is `struct X;` or `union X;` with no body in
3212            // this BTF — typical when a scheduler library defines
3213            // the struct (e.g. `struct sdt_data` in the sdt_alloc
3214            // library) and the using program only references it
3215            // via pointer. The chase has no BTF-declared size to
3216            // bound the read, so it skips when both the
3217            // sdt_alloc bridge ([`MemReader::resolve_arena_type`])
3218            // and the cross-BTF Fwd resolution index
3219            // ([`MemReader::cross_btf_resolve_fwd`]) have already
3220            // been consulted and neither produced a hit — i.e.
3221            // the body lives in some BTF the renderer cannot
3222            // reach with the available indexes.
3223            let aggregate = if fwd.is_union() { "union" } else { "struct" };
3224            let name = btf.resolve_name(fwd).ok().filter(|n| !n.is_empty());
3225            match name {
3226                Some(n) => format!(
3227                    "{kind_label} target {aggregate} {n} (type id \
3228                     {target_type_id}) is a forward declaration; \
3229                     body not in this BTF"
3230                ),
3231                None => format!(
3232                    "{kind_label} target type id {target_type_id} \
3233                     is an anonymous {aggregate} forward declaration; \
3234                     body not in this BTF"
3235                ),
3236            }
3237        }
3238        Type::Func(_) => format!(
3239            "{kind_label} target type id {target_type_id} is a \
3240             function (BTF_KIND_FUNC); functions have no storage size"
3241        ),
3242        Type::FuncProto(_) => format!(
3243            "{kind_label} target type id {target_type_id} is a \
3244             function prototype (BTF_KIND_FUNC_PROTO); prototypes \
3245             have no storage size"
3246        ),
3247        Type::Datasec(_) => format!(
3248            "{kind_label} target type id {target_type_id} is a \
3249             datasec (BTF_KIND_DATASEC); not a pointer chase target"
3250        ),
3251        Type::Var(_) => format!(
3252            "{kind_label} target type id {target_type_id} is a \
3253             var (BTF_KIND_VAR); not a pointer chase target"
3254        ),
3255        Type::Void => format!(
3256            "{kind_label} target type id {target_type_id} is void; \
3257             chasing a void* requires runtime type info"
3258        ),
3259        // `peel_modifiers` peels DeclTag in practice, so reaching
3260        // it here implies a malformed BTF chain — keep the
3261        // diagnostic explicit rather than collapsing into the
3262        // generic fall-through.
3263        Type::DeclTag(_) => format!(
3264            "{kind_label} target type id {target_type_id} is a \
3265             decl-tag (BTF_KIND_DECL_TAG); modifiers should have \
3266             peeled (malformed BTF chain?)"
3267        ),
3268        // Defense-in-depth fall-through: every other variant
3269        // ([`Type::Int`], [`Type::Float`], [`Type::Enum`],
3270        // [`Type::Enum64`], [`Type::Struct`], [`Type::Union`],
3271        // [`Type::Ptr`], [`Type::Array`], and the modifier
3272        // wrappers `peel_modifiers` strips) returns `Some` from
3273        // [`type_size`], so reaching this arm means a future
3274        // [`Type`] variant slipped through without a sizing rule.
3275        // Keep the legacy generic message rather than pretending
3276        // we know the cause.
3277        _ => format!(
3278            "{kind_label} target type id {target_type_id} has \
3279             unresolvable size"
3280        ),
3281    }
3282}
3283
3284/// Outcome of an arena pointer chase. Exactly one of `deref` /
3285/// `reason` carries content: `deref` is the rendered target subtree
3286/// when the chase succeeded; `reason` populates
3287/// [`RenderedValue::Ptr::deref_skipped_reason`] when the chase was
3288/// attempted but did not land. `sdt_alloc_resolved` records whether
3289/// the renderer recovered the chase target's BTF type id from the
3290/// [`MemReader::resolve_arena_type`] sdt_alloc bridge instead of the
3291/// pointer's own BTF declaration — callers surface this through
3292/// [`RenderedValue::Ptr::cast_annotation`] so operators can
3293/// distinguish `BTF-typed → Fwd-resolved-via-sdt_alloc` chases from
3294/// ordinary BTF-typed chases at a glance.
3295struct ArenaChaseOutcome {
3296    deref: Option<Box<RenderedValue>>,
3297    reason: Option<String>,
3298    sdt_alloc_resolved: bool,
3299}
3300
3301/// Try the sdt_alloc bridge for a `BTF_KIND_FWD` chase target.
3302///
3303/// Returns the raw [`ArenaResolveHit`] when the chased address
3304/// falls in a known sdt_alloc slot. The caller feeds the hit's
3305/// `target_type_id` through the same peel → cross-BTF → size
3306/// pipeline it runs for the direct target, so the bridge hit
3307/// gets the full resolution chain (including cross-BTF Fwd
3308/// fallback) instead of being silently dropped when the resolved
3309/// type is also Fwd in the entry BTF.
3310fn try_sdt_alloc_bridge(
3311    mem: &dyn MemReader,
3312    val: u64,
3313    target_ty: &Type,
3314) -> Option<ArenaResolveHit> {
3315    if !matches!(target_ty, Type::Fwd(_)) {
3316        return None;
3317    }
3318    if let Some(hit) = mem.resolve_arena_type(val) {
3319        return Some(hit);
3320    }
3321    // Index miss — the sdt_alloc tree walker may have found 0
3322    // live allocations (race with scheduler unregistration
3323    // freeing all slots before the freeze captures bitmaps).
3324    // Fall back to the sdt_alloc meta when the chased address
3325    // is in the arena window. This is TYPE-GATED (only fires
3326    // for Fwd pointee chases from typed Ptr fields, not for
3327    // arbitrary u64 fields) so it cannot produce the "false
3328    // positive factory" behavior the blanket meta fallback had.
3329    mem.resolve_arena_type_meta_fallback(val)
3330}
3331
3332/// Slice past `header_skip` bytes when the sdt_alloc bridge fires.
3333///
3334/// Returns the byte slice starting at `header_skip` within
3335/// `raw_bytes`, or `None` when the read returned fewer bytes than
3336/// the header skip needs (page-tail truncation, short read). Both
3337/// chase arms — [`chase_arena_pointer`] and [`render_cast_pointer`]
3338/// — apply this slice after their per-arm read so the callers
3339/// share a single underrun guard.
3340fn apply_header_skip(raw_bytes: &[u8], header_skip: usize) -> Option<&[u8]> {
3341    raw_bytes.get(header_skip..)
3342}
3343
3344/// [`MemReader`] adapter that suppresses any lookup whose result
3345/// is keyed against the entry BTF's id space, while delegating
3346/// every other method.
3347///
3348/// Used by [`chase_arena_pointer`] and [`render_cast_pointer`] when
3349/// [`try_cross_btf_fwd_resolve`] succeeds and the chase recursion
3350/// switches from the entry BTF to a sibling BTF.
3351///
3352/// Three methods carry entry-BTF-keyed payloads and MUST be
3353/// suppressed when the chase has crossed a BTF boundary:
3354///
3355/// - [`MemReader::cast_lookup`]: the cast analyzer's
3356///   [`super::cast_analysis::CastMap`] keys on `(parent_type_id,
3357///   member_byte_offset)` against the entry BTF's id space. Sibling
3358///   BTFs re-use the same numeric id space, so a cast hit at
3359///   `(entry_id=N, off=K)` would incorrectly fire when the renderer
3360///   reaches a sibling BTF's struct that happens to carry id `N`. The
3361///   renderer would treat a plain `u64` field in the sibling struct
3362///   as a cast-recovered pointer, producing a phantom `Ptr` render
3363///   with a "type id unresolvable" skip reason against an id that is
3364///   only meaningful in the entry BTF.
3365///
3366/// - [`MemReader::resolve_arena_type`][]: the
3367///   `super::dump::render_map::ArenaSlotIndex` populates
3368///   [`ArenaResolveHit::target_type_id`] with BTF type ids resolved
3369///   against the **entry BTF** at index-build time (the sdt_alloc
3370///   pre-pass runs against the program BTF, which is the entry BTF
3371///   for the chase that initiated the dump). When a cross-BTF chase
3372///   recurses into a sibling BTF and encounters another `Type::Fwd`,
3373///   [`try_sdt_alloc_bridge`] would call `mem.resolve_arena_type`
3374///   and receive an entry-BTF id; passing that id to
3375///   [`peel_modifiers_resolving_fwd`]`(sibling_btf, …)` looks up the
3376///   wrong id in the sibling BTF's space — silent wrong-render (the
3377///   sibling BTF carries a different type at that id) or silent skip
3378///   (the sibling BTF lacks the id). The "no invalid data made"
3379///   contract requires this to be a hard `None`.
3380///
3381/// - [`MemReader::resolve_arena_type_meta_fallback`][]: same rationale
3382///   as `resolve_arena_type` — the sdt_alloc meta payload's
3383///   `target_type_id` is resolved against the entry BTF at index-
3384///   build time. A cross-BTF recursion that fell through to this
3385///   override would receive an entry-BTF id and feed it into the
3386///   sibling BTF's id space, the same wrong-render / wrong-skip
3387///   failure mode. Inherits the trait default `None` (the impl
3388///   does not delegate to `inner`), keeping the suppression
3389///   coextensive with `resolve_arena_type`.
3390///
3391/// The remaining [`MemReader`] methods (read_kva / read_arena /
3392/// is_arena_addr / nr_cpu_ids / cross_btf_resolve_fwd /
3393/// is_already_rendered) carry no entry-BTF id payload — they
3394/// operate on raw addresses and string names — so they stay live.
3395/// Chases originating inside the cross-BTF subtree still resolve
3396/// through the same arena snapshot, kernel page-walker, cross-BTF
3397/// Fwd index, and rendered-slot dedup set. The suppression is
3398/// narrowly scoped to the three id-keyed lookups.
3399struct CrossBtfMemReader<'a> {
3400    inner: &'a dyn MemReader,
3401}
3402
3403impl MemReader for CrossBtfMemReader<'_> {
3404    fn read_kva(&self, kva: u64, len: usize) -> Option<Vec<u8>> {
3405        self.inner.read_kva(kva, len)
3406    }
3407    fn is_arena_addr(&self, addr: u64) -> bool {
3408        self.inner.is_arena_addr(addr)
3409    }
3410    fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
3411        self.inner.read_arena(addr, len)
3412    }
3413    fn nr_cpu_ids(&self) -> u32 {
3414        self.inner.nr_cpu_ids()
3415    }
3416    // cast_lookup intentionally NOT delegated: returns the trait
3417    // default `None`. See struct doc for why suppression is correct
3418    // when the chase has crossed BTFs.
3419    //
3420    // resolve_arena_type intentionally NOT delegated either:
3421    // [`ArenaResolveHit::target_type_id`] is keyed against the entry
3422    // BTF, so a cross-BTF recursion that consulted it would map a
3423    // sibling-BTF chase onto an entry-BTF id and silently wrong-render
3424    // (or silently skip with a misleading reason). False negative is
3425    // the safe direction — the sibling chase can still surface its
3426    // pointee via [`MemReader::cross_btf_resolve_fwd`] and the
3427    // ordinary [`Type::Ptr`] arm.
3428    //
3429    // resolve_arena_type_meta_fallback intentionally NOT delegated
3430    // either: same entry-BTF id-space rationale as
3431    // resolve_arena_type. The sdt_alloc meta payload's
3432    // `target_type_id` is resolved against the entry BTF; feeding it
3433    // into a sibling-BTF chase would produce the same wrong-render /
3434    // wrong-skip failure mode. Inherits trait default `None`.
3435    fn cross_btf_resolve_fwd(&self, name: &str, kind: FwdKind) -> Option<CrossBtfRef<'_>> {
3436        self.inner.cross_btf_resolve_fwd(name, kind)
3437    }
3438    fn is_already_rendered(&self, addr: u64) -> bool {
3439        // Delegate: the rendered-slot set keys on raw arena
3440        // addresses (low-32 windowed `slot_start`), not BTF type
3441        // ids, so the entry-BTF / sibling-BTF distinction does not
3442        // apply. A cross-BTF recursion that lands back on a
3443        // previously-rendered slot must skip the deref so the
3444        // payload doesn't appear twice in the dump — once under
3445        // the typed-allocator surface, once via the cross-BTF
3446        // chase that returned to the same slot.
3447        self.inner.is_already_rendered(addr)
3448    }
3449}
3450
3451/// Pre-read state assembled by [`resolve_chase_target`].
3452///
3453/// Both chase arms — [`chase_arena_pointer`] and
3454/// [`render_cast_pointer`]'s kernel arm — share the entire
3455/// "resolve target type, fire sdt_alloc bridge, fall back to
3456/// cross-BTF Fwd resolve, settle final size" sequence. That
3457/// sequence ends right before the per-arm read (`read_arena` vs
3458/// `read_kva`) — at which point the renderer holds enough
3459/// state to size the read, slice the header, and recurse. This
3460/// struct is exactly that "ready to read" snapshot.
3461///
3462/// `cross_btf_hit` is preserved by value (the type derives
3463/// [`Copy`], so threading it through the resolver does not
3464/// surface borrow lifetime headaches) so the per-arm post-read
3465/// step can build its own [`CrossBtfMemReader`] wrap on the
3466/// recursion path.
3467struct ResolvedTarget<'a> {
3468    /// Type id of the chase target within [`Self::current_btf`]'s
3469    /// id space. Threaded into [`render_value_inner`] for the
3470    /// recursion.
3471    effective_type_id: u32,
3472    /// BTF the recursion runs against — the entry BTF when
3473    /// cross-BTF stayed dormant, the resolved sibling BTF when
3474    /// the cross-BTF Fwd index returned a hit.
3475    current_btf: &'a Btf,
3476    /// Storage size of the resolved payload. The per-arm read
3477    /// budget is `header_skip + btf_size` clamped at the arm's
3478    /// cap (4 KiB for arena, 4 KiB AND page-remaining for
3479    /// kernel).
3480    btf_size: usize,
3481    /// Bytes the chase must skip past the chased address before
3482    /// the payload struct begins. `0` for a payload-start chase;
3483    /// the slot's header size for a slot-start chase the
3484    /// sdt_alloc bridge resolved.
3485    header_skip: usize,
3486    /// `true` when the resolved payload type id came from the
3487    /// sdt_alloc bridge ([`MemReader::resolve_arena_type`])
3488    /// rather than the cast analyzer's declared
3489    /// `target_type_id`. Surfaces through
3490    /// [`RenderedValue::Ptr::cast_annotation`] so operators see
3491    /// the layout came from the bridge.
3492    sdt_alloc_resolved: bool,
3493    /// Set when the cross-BTF Fwd index returned a hit (the
3494    /// chase target's body lives in a sibling BTF). The
3495    /// post-read recursion wraps `mem` in a
3496    /// [`CrossBtfMemReader`] in this case so id-keyed lookups
3497    /// (cast / arena type) cannot fire against the sibling
3498    /// BTF's id space — see [`CrossBtfMemReader`]'s doc for the
3499    /// collision rationale.
3500    cross_btf_hit: Option<CrossBtfRef<'a>>,
3501}
3502
3503/// Outcome of [`resolve_chase_target`].
3504enum ChaseResolve<'a> {
3505    /// Pre-read sequence completed; chase is ready to read and
3506    /// recurse. The caller plugs [`Self::Ready`]'s state into its
3507    /// per-arm reader and post-read gates.
3508    Ready(ResolvedTarget<'a>),
3509    /// Pre-read sequence skipped the chase. The reason and
3510    /// `sdt_alloc_resolved` flag flow into the caller's
3511    /// per-arm `Ptr` builder so the no-deref render still
3512    /// surfaces the bridge state when it fired before the skip.
3513    Skip {
3514        reason: String,
3515        sdt_alloc_resolved: bool,
3516    },
3517}
3518
3519/// Shared pre-read resolver for arena and kernel chase arms.
3520///
3521/// Encapsulates the steps both [`chase_arena_pointer`] and
3522/// [`render_cast_pointer`]'s kernel arm execute identically:
3523///
3524/// 1. Peel modifiers + resolve [`Type::Fwd`] to a complete
3525///    same-name sibling within `btf` via
3526///    [`peel_modifiers_resolving_fwd`].
3527/// 2. Try the sdt_alloc bridge ([`try_sdt_alloc_bridge`]) when
3528///    the post-peel terminal is still [`Type::Fwd`]. A bridge
3529///    fire returns the bridge's resolved `target_ty` /
3530///    `effective_type_id` plus the slot's `header_skip` and the
3531///    payload's `btf_size`; this resolver rebinds the local
3532///    target_ty / effective_type_id from the returned struct.
3533/// 3. When the bridge stays dormant, try the cross-BTF Fwd
3534///    index ([`try_cross_btf_fwd_resolve`]). A hit switches the
3535///    rendering BTF to the resolved sibling and adopts its
3536///    type id.
3537/// 4. Resolve the final `btf_size` against `current_btf` (or
3538///    reuse the bridge's `btf_size` when the bridge fired).
3539/// 5. Reject `btf_size == 0` payloads (incomplete types whose
3540///    BTF size resolves but is zero).
3541///
3542/// `kind_label` ("arena chase" / "kernel cast") flows into every
3543/// skip reason so the caller-visible messages still distinguish
3544/// the two arms; the renderer's test module asserts the prefix on
3545/// each path.
3546///
3547/// On the success path, the [`ChaseResolve::Ready`] payload carries
3548/// every value the per-arm read+recurse needs:
3549/// [`ResolvedTarget::effective_type_id`],
3550/// [`ResolvedTarget::current_btf`], [`ResolvedTarget::btf_size`],
3551/// [`ResolvedTarget::header_skip`],
3552/// [`ResolvedTarget::sdt_alloc_resolved`], and
3553/// [`ResolvedTarget::cross_btf_hit`]. The bridge state is captured
3554/// `Some(...)` exactly when the bridge fired; the `cross_btf_hit`
3555/// is captured exactly when the cross-BTF index returned a hit
3556/// (mutually exclusive with bridge fire).
3557fn resolve_chase_target<'a>(
3558    btf: &'a Btf,
3559    mem: &'a dyn MemReader,
3560    val: u64,
3561    target_type_id: u32,
3562    kind_label: &'static str,
3563) -> ChaseResolve<'a> {
3564    // Step 1: peel modifiers AND resolve a Fwd terminal to a
3565    // complete Struct/Union sibling of the same name when one
3566    // is in the BTF. Without the Fwd shortcut, `type_size`
3567    // returns `None` for a `Type::Fwd` terminal and the chase
3568    // would skip even when the renderer has full layout info
3569    // one BTF id away. `effective_type_id` is what the render
3570    // recursion uses to resolve members — the resolved
3571    // Struct/Union id when a sibling was found, otherwise the
3572    // post-peel original id.
3573    let Some((mut target_ty, mut effective_type_id)) =
3574        peel_modifiers_resolving_fwd(btf, target_type_id)
3575    else {
3576        return ChaseResolve::Skip {
3577            reason: format!("{kind_label} target type id {target_type_id} unresolvable"),
3578            sdt_alloc_resolved: false,
3579        };
3580    };
3581    // Step 2: when the post-peel terminal is still `Type::Fwd`,
3582    // ask the sdt_alloc bridge whether the chased address falls
3583    // inside an allocator slot whose payload type id the
3584    // pre-pass resolved. The bridge is pure-return: a fire
3585    // produces a fresh target_ty / effective_type_id which we
3586    // rebind here, plus the slot's `header_skip` and the
3587    // resolved payload's `btf_size`.
3588    let bridge = try_sdt_alloc_bridge(mem, val, &target_ty);
3589    let (sdt_alloc_resolved, header_skip) = match &bridge {
3590        Some(hit) => {
3591            if let Some((resolved_ty, resolved_id)) =
3592                peel_modifiers_resolving_fwd(btf, hit.target_type_id)
3593            {
3594                target_ty = resolved_ty;
3595                effective_type_id = resolved_id;
3596            }
3597            (true, hit.header_skip)
3598        }
3599        None => (false, 0usize),
3600    };
3601    // Step 3: when the bridge stayed dormant, try the cross-BTF
3602    // Fwd index. A `Type::Fwd` terminal whose complete body
3603    // lives in a sibling BTF resolves through the renderer's
3604    // cross-BTF index — the typical multi-`.bpf.objs` shape
3605    // (one object declares `struct cgx_target;` forward,
3606    // another defines the body). When the index returns a hit
3607    // we switch the rendering BTF to the resolved sibling and
3608    // adopt its type id. The bridge-fired path skips this
3609    // probe — its resolved id is in the entry BTF and the
3610    // recursion doesn't need to switch.
3611    let cross_btf_hit = if matches!(target_ty, Type::Fwd(_)) {
3612        try_cross_btf_fwd_resolve(mem, btf, &target_ty)
3613    } else {
3614        None
3615    };
3616    let current_btf: &Btf = match cross_btf_hit {
3617        Some(hit) => {
3618            target_ty = match hit.btf.resolve_type_by_id(hit.type_id) {
3619                Ok(ty) => ty,
3620                Err(_) => {
3621                    tracing::debug!(
3622                        kind_label,
3623                        hit_type_id = hit.type_id,
3624                        "cross-BTF Fwd resolve returned a type_id that does not resolve \
3625                         in the sibling BTF",
3626                    );
3627                    return ChaseResolve::Skip {
3628                        reason: format!(
3629                            "{kind_label}: cross-BTF Fwd type id {} unresolved in sibling BTF",
3630                            hit.type_id
3631                        ),
3632                        sdt_alloc_resolved,
3633                    };
3634                }
3635            };
3636            effective_type_id = hit.type_id;
3637            hit.btf
3638        }
3639        None => btf,
3640    };
3641    // Step 4: resolve the final `btf_size`. The bridge fire
3642    // already paid this resolve; reuse its value when present.
3643    let btf_size = {
3644        let Some(sz) = type_size(current_btf, &target_ty) else {
3645            return ChaseResolve::Skip {
3646                reason: unsizable_chase_reason(current_btf, kind_label, target_type_id, &target_ty),
3647                sdt_alloc_resolved,
3648            };
3649        };
3650        sz
3651    };
3652    // Step 5: reject zero-size payloads (incomplete types whose
3653    // BTF size resolves but is zero). The `incomplete type`
3654    // substring is what the kernel-arm
3655    // `cast_chase_kernel_target_btf_size_zero` test asserts.
3656    if btf_size == 0 {
3657        return ChaseResolve::Skip {
3658            reason: format!(
3659                "{kind_label} target type id {target_type_id} BTF size is 0 (incomplete type)"
3660            ),
3661            sdt_alloc_resolved,
3662        };
3663    }
3664    ChaseResolve::Ready(ResolvedTarget {
3665        effective_type_id,
3666        current_btf,
3667        btf_size,
3668        header_skip,
3669        sdt_alloc_resolved,
3670        cross_btf_hit,
3671    })
3672}
3673
3674/// Run [`render_value_inner`] against the resolved target and
3675/// wrap the result for the chase arm.
3676///
3677/// The post-read recursion is also identical between
3678/// [`chase_arena_pointer`] and [`render_cast_pointer`]: both
3679/// insert the chased value into `visited` before recursing,
3680/// optionally wrap `mem` in a [`CrossBtfMemReader`] when the
3681/// chase crossed a BTF boundary, recurse against
3682/// `effective_type_id` in `current_btf`, remove the value from
3683/// `visited` after, and box-wrap the rendered value in a
3684/// [`RenderedValue::Truncated`] when the read was clipped.
3685///
3686/// `truncated_at_cap` is the per-arm "the read was clipped"
3687/// flag — arena's path uses `total_needed > POINTER_CHASE_CAP`
3688/// (the snapshot is single-page-bound), kernel's path uses
3689/// `total_needed > read_size` (read_size is bound by both the
3690/// global cap AND the page-remaining length).
3691fn recurse_into_target(
3692    resolved: &ResolvedTarget<'_>,
3693    target_bytes: &[u8],
3694    val: u64,
3695    depth: u32,
3696    mem: &dyn MemReader,
3697    visited: &mut HashSet<u64>,
3698    truncated_at_cap: bool,
3699) -> Box<RenderedValue> {
3700    visited.insert(val);
3701    let cross_btf_wrap = resolved
3702        .cross_btf_hit
3703        .as_ref()
3704        .map(|_| CrossBtfMemReader { inner: mem });
3705    let recurse_mem: &dyn MemReader = match &cross_btf_wrap {
3706        Some(w) => w,
3707        None => mem,
3708    };
3709    let inner = render_value_inner(
3710        resolved.current_btf,
3711        resolved.effective_type_id,
3712        target_bytes,
3713        depth + 1,
3714        Some(recurse_mem),
3715        visited,
3716    );
3717    visited.remove(&val);
3718    if truncated_at_cap {
3719        // Partial render: only the first capped bytes of a
3720        // larger struct were read. Wrap so the consumer can
3721        // tell the rendered tree is incomplete even though it
3722        // looks structurally sound.
3723        Box::new(RenderedValue::Truncated {
3724            needed: resolved.btf_size,
3725            had: target_bytes.len(),
3726            partial: Box::new(inner),
3727        })
3728    } else {
3729        Box::new(inner)
3730    }
3731}
3732
3733/// Chase an arena pointer and render the target struct.
3734///
3735/// See [`ArenaChaseOutcome`] for the return shape. The caller plugs
3736/// `deref` and `reason` into [`RenderedValue::Ptr`]; when
3737/// `sdt_alloc_resolved` is `true` the caller adds an
3738/// `sdt_alloc`-flavoured `cast_annotation` so the recovered chase is
3739/// distinguishable from a native BTF chase.
3740///
3741/// `target_type_id == 0` selects the deferred-resolve arena path:
3742/// the [`MemReader::resolve_arena_type`] bridge fires first; on a
3743/// miss the analyzer-supplied `alloc_size` (captured at the
3744/// `scx_static_alloc_internal` call site) drives a size-based BTF
3745/// match via [`super::sdt_alloc::discover_payload_btf_id`]. Both
3746/// paths bypass the cross-BTF probe — their resolved ids are in
3747/// the entry BTF — so they synthesise a `ResolvedTarget` with
3748/// `cross_btf_hit = None`. `alloc_size` is `None` for any caller
3749/// that arrived via [`Type::Ptr`] (BTF-typed pointer chase) or
3750/// for cast findings whose source allocator did not capture a
3751/// size (kfunc / `scx_alloc_internal`).
3752///
3753/// Preconditions the caller must satisfy:
3754///   * The [`chase_gate`] outcome was [`ChaseGate::Proceed`] for
3755///     `val` at this `depth` (i.e. `val != 0`, not in `visited`,
3756///     `depth < MAX_RENDER_DEPTH`).
3757///   * `mem.is_arena_addr(val)` returned `true`. The cast path
3758///     wraps a separate "arena cast value outside arena window"
3759///     reason around an out-of-window value before invoking the
3760///     helper; the [`Type::Ptr`] arm dispatches on
3761///     `is_arena_addr` so it only enters this helper when the
3762///     check passed.
3763///
3764/// `visited` bookkeeping is internal: the helper inserts `val`
3765/// before recursing and removes it after, matching the path-based
3766/// cycle convention used in both call sites.
3767fn chase_arena_pointer(
3768    btf: &Btf,
3769    target_type_id: u32,
3770    alloc_size: Option<u64>,
3771    val: u64,
3772    mem: &dyn MemReader,
3773    depth: u32,
3774    visited: &mut HashSet<u64>,
3775) -> ArenaChaseOutcome {
3776    // Dedup: when the chased address points at a slot the dump
3777    // pre-pass has already rendered into `report.sdt_allocations`,
3778    // skip the per-map chase so the slot's typed payload already
3779    // appears in the failure dump under the sdt_alloc surface — not
3780    // also embedded inside whichever TASK_STORAGE / HASH map's value
3781    // pointed back at it. The dedup set is restricted to slots from
3782    // typed allocators (resolved `target_type_id != 0`); untyped
3783    // pre-pass renders (hex fallback) must NOT suppress per-map
3784    // chases because the cast analyzer's shape inference may resolve
3785    // a target type the heuristic missed. The default trait impl
3786    // returns `false`, so readers without a rendered-slot index
3787    // proceed with the chase as before.
3788    if mem.is_already_rendered(val) {
3789        return ArenaChaseOutcome {
3790            deref: None,
3791            reason: Some("already rendered in sdt_allocations".to_string()),
3792            sdt_alloc_resolved: false,
3793        };
3794    }
3795    // Special case: `target_type_id == 0` means the cast analyzer's
3796    // STX-flow path tagged the slot as Arena WITHOUT a resolved
3797    // BTF type id (the deferred-resolve arena cast path —
3798    // allocator-return seeds produce findings whose target shape
3799    // is determined entirely by the
3800    // [`MemReader::resolve_arena_type`] bridge at chase time).
3801    // Consult the bridge first; on a miss, fall back to the
3802    // analyzer-supplied `alloc_size` (captured at the
3803    // `scx_static_alloc_internal` call site) and resolve the
3804    // payload type by size match via
3805    // [`super::sdt_alloc::discover_payload_btf_id`]. The bump
3806    // allocator emits no per-slot header that the bridge could
3807    // index, so the size-match fallback is the only resolution
3808    // path for `scx_static_alloc_internal` allocations. Both
3809    // paths bypass the cross-BTF probe — their resolved ids are
3810    // in the entry BTF — by synthesising a `ResolvedTarget` with
3811    // `cross_btf_hit = None`, which feeds directly into the
3812    // post-read recursion.
3813    let resolved = if target_type_id == 0 {
3814        let bridge_hit = mem.resolve_arena_type(val);
3815        let (effective_target_id, header_skip, resolution_source) = match bridge_hit {
3816            Some(hit) => (hit.target_type_id, hit.header_skip, "bridge"),
3817            None => {
3818                // Bridge miss: try the analyzer-supplied
3819                // `alloc_size` fallback. `Some(n)` means a
3820                // `scx_static_alloc_internal` call captured the
3821                // sizeof argument; consult
3822                // [`super::sdt_alloc::discover_payload_btf_id`]
3823                // to size-match against BTF. The bump allocator
3824                // has no header so `header_skip == 0`. `None`
3825                // means no captured size — surface the bridge
3826                // miss as the chase reason.
3827                let size = match alloc_size {
3828                    Some(s) => s,
3829                    None => {
3830                        // No per-slot alloc_size. Use the pre-resolved
3831                        // (size, struct_name) pairs from embedded BTFs
3832                        // to find a type via cross-BTF Fwd resolution.
3833                        // Each entry was resolved at analysis time by
3834                        // running discover_payload_btf_id against the
3835                        // full embedded BTF (which carries struct bodies
3836                        // the kernel's split BTF may have dropped to Fwd).
3837                        // Try each: read `size` bytes from the arena,
3838                        // resolve the name via cross_btf_resolve_fwd,
3839                        // and use the FIRST that succeeds. The read-size
3840                        // gate ensures we only render bytes that belong
3841                        // to THIS allocation (no overread into adjacent).
3842                        for (candidate_size, struct_name) in mem.alloc_size_types() {
3843                            if mem.read_arena(val, *candidate_size as usize).is_none() {
3844                                continue;
3845                            }
3846                            if let Some(cross_ref) =
3847                                mem.cross_btf_resolve_fwd(struct_name, FwdKind::Struct)
3848                                && let Ok(ty) = cross_ref.btf.resolve_type_by_id(cross_ref.type_id)
3849                                && let Some(btf_size) = type_size(cross_ref.btf, &ty)
3850                                && btf_size > 0
3851                                && btf_size <= *candidate_size as usize
3852                            {
3853                                let read_size = btf_size.min(POINTER_CHASE_CAP);
3854                                let truncated = btf_size > POINTER_CHASE_CAP;
3855                                if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3856                                    let payload = recurse_into_target(
3857                                        &ResolvedTarget {
3858                                            effective_type_id: cross_ref.type_id,
3859                                            current_btf: cross_ref.btf,
3860                                            btf_size,
3861                                            header_skip: 0,
3862                                            sdt_alloc_resolved: false,
3863                                            cross_btf_hit: Some(CrossBtfRef {
3864                                                btf: cross_ref.btf,
3865                                                type_id: cross_ref.type_id,
3866                                            }),
3867                                        },
3868                                        &raw_bytes,
3869                                        val,
3870                                        depth,
3871                                        mem,
3872                                        visited,
3873                                        truncated,
3874                                    );
3875                                    return ArenaChaseOutcome {
3876                                        deref: Some(payload),
3877                                        reason: None,
3878                                        sdt_alloc_resolved: false,
3879                                    };
3880                                }
3881                            }
3882                            // Also try direct resolution in the entry BTF
3883                            let choice = super::sdt_alloc::discover_payload_btf_id(
3884                                btf,
3885                                *candidate_size as usize,
3886                                "",
3887                            );
3888                            if choice.target_type_id != 0
3889                                && let Some((resolved_ty, resolved_id)) =
3890                                    peel_modifiers_resolving_fwd(btf, choice.target_type_id)
3891                                && let Some(btf_size) = type_size(btf, &resolved_ty)
3892                                && btf_size > 0
3893                            {
3894                                let read_size = btf_size.min(POINTER_CHASE_CAP);
3895                                let truncated = btf_size > POINTER_CHASE_CAP;
3896                                if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3897                                    let payload = recurse_into_target(
3898                                        &ResolvedTarget {
3899                                            effective_type_id: resolved_id,
3900                                            current_btf: btf,
3901                                            btf_size,
3902                                            header_skip: 0,
3903                                            sdt_alloc_resolved: true,
3904                                            cross_btf_hit: None,
3905                                        },
3906                                        &raw_bytes,
3907                                        val,
3908                                        depth,
3909                                        mem,
3910                                        visited,
3911                                        truncated,
3912                                    );
3913                                    return ArenaChaseOutcome {
3914                                        deref: Some(payload),
3915                                        reason: None,
3916                                        sdt_alloc_resolved: true,
3917                                    };
3918                                }
3919                            }
3920                        }
3921                        tracing::debug!(
3922                            val = format_args!("{:#x}", val),
3923                            "arena chase: STX-flow tagged slot as Arena (target_type_id=0, \
3924                             deferred resolve), but resolve_arena_type had no entry and \
3925                             no alloc_size was supplied; allocator pre-pass may not have \
3926                             populated the index for this allocator",
3927                        );
3928                        return ArenaChaseOutcome {
3929                            deref: None,
3930                            reason: Some(format!(
3931                                "arena chase: STX-flow path tagged slot as Arena with \
3932                                 deferred resolve; bridge had no entry for 0x{val:x}"
3933                            )),
3934                            sdt_alloc_resolved: false,
3935                        };
3936                    }
3937                };
3938                let choice = super::sdt_alloc::discover_payload_btf_id(btf, size as usize, "");
3939                if choice.target_type_id != 0 {
3940                    (choice.target_type_id, 0usize, "alloc_size")
3941                } else {
3942                    // Entry BTF size match failed (the target struct
3943                    // is likely Fwd in split BTF). Try cross-BTF Fwd
3944                    // resolution using alloc_size_types entries that
3945                    // match the captured size.
3946                    for (candidate_size, struct_name) in mem.alloc_size_types() {
3947                        if *candidate_size != size {
3948                            continue;
3949                        }
3950                        if mem.read_arena(val, *candidate_size as usize).is_none() {
3951                            continue;
3952                        }
3953                        if let Some(cross_ref) =
3954                            mem.cross_btf_resolve_fwd(struct_name, FwdKind::Struct)
3955                            && let Ok(ty) = cross_ref.btf.resolve_type_by_id(cross_ref.type_id)
3956                            && let Some(btf_size) = type_size(cross_ref.btf, &ty)
3957                            && btf_size > 0
3958                            && btf_size <= *candidate_size as usize
3959                        {
3960                            let read_size = btf_size.min(POINTER_CHASE_CAP);
3961                            let truncated = btf_size > POINTER_CHASE_CAP;
3962                            if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3963                                let payload = recurse_into_target(
3964                                    &ResolvedTarget {
3965                                        effective_type_id: cross_ref.type_id,
3966                                        current_btf: cross_ref.btf,
3967                                        btf_size,
3968                                        header_skip: 0,
3969                                        sdt_alloc_resolved: false,
3970                                        cross_btf_hit: Some(CrossBtfRef {
3971                                            btf: cross_ref.btf,
3972                                            type_id: cross_ref.type_id,
3973                                        }),
3974                                    },
3975                                    &raw_bytes,
3976                                    val,
3977                                    depth,
3978                                    mem,
3979                                    visited,
3980                                    truncated,
3981                                );
3982                                return ArenaChaseOutcome {
3983                                    deref: Some(payload),
3984                                    reason: None,
3985                                    sdt_alloc_resolved: false,
3986                                };
3987                            }
3988                        }
3989                    }
3990                    tracing::debug!(
3991                        val = format_args!("{:#x}", val),
3992                        alloc_size = size,
3993                        reason = %choice.reason,
3994                        "arena chase: STX-flow tagged slot as Arena (target_type_id=0, \
3995                         deferred resolve), bridge returned no entry, and alloc_size \
3996                         fallback could not resolve a unique BTF type",
3997                    );
3998                    return ArenaChaseOutcome {
3999                        deref: None,
4000                        reason: Some(format!(
4001                            "arena chase: STX-flow path tagged slot as Arena with \
4002                             deferred resolve; alloc_size={size} fallback unresolved \
4003                             ({reason})",
4004                            reason = choice.reason
4005                        )),
4006                        sdt_alloc_resolved: false,
4007                    };
4008                }
4009            }
4010        };
4011        let Some((resolved_ty, resolved_id)) =
4012            peel_modifiers_resolving_fwd(btf, effective_target_id)
4013        else {
4014            tracing::debug!(
4015                source = resolution_source,
4016                effective_target_id,
4017                "arena chase: resolution source returned target_type_id but the type \
4018                 does not resolve in the program BTF",
4019            );
4020            return ArenaChaseOutcome {
4021                deref: None,
4022                reason: Some(format!(
4023                    "arena chase: type id {effective_target_id} unresolved in BTF"
4024                )),
4025                sdt_alloc_resolved: true,
4026            };
4027        };
4028        let Some(btf_size) = type_size(btf, &resolved_ty) else {
4029            return ArenaChaseOutcome {
4030                deref: None,
4031                reason: Some(unsizable_chase_reason(
4032                    btf,
4033                    "arena chase",
4034                    effective_target_id,
4035                    &resolved_ty,
4036                )),
4037                sdt_alloc_resolved: true,
4038            };
4039        };
4040        if btf_size == 0 {
4041            return ArenaChaseOutcome {
4042                deref: None,
4043                reason: Some(format!(
4044                    "arena chase target type id {effective_target_id} \
4045                     BTF size is 0 (incomplete type)"
4046                )),
4047                sdt_alloc_resolved: true,
4048            };
4049        }
4050        ResolvedTarget {
4051            effective_type_id: resolved_id,
4052            current_btf: btf,
4053            btf_size,
4054            header_skip,
4055            sdt_alloc_resolved: true,
4056            cross_btf_hit: None,
4057        }
4058    } else {
4059        match resolve_chase_target(btf, mem, val, target_type_id, "arena chase") {
4060            ChaseResolve::Ready(r) => r,
4061            ChaseResolve::Skip {
4062                reason,
4063                sdt_alloc_resolved,
4064            } => {
4065                return ArenaChaseOutcome {
4066                    deref: None,
4067                    reason: Some(reason),
4068                    sdt_alloc_resolved,
4069                };
4070            }
4071        }
4072    };
4073    // The single-page (4 KiB) cap ([`POINTER_CHASE_CAP`]) matches
4074    // the arena page granularity exposed by
4075    // [`MemReader::read_arena`]: a pointee larger than 4 KiB
4076    // renders only its first page. Cross-page chase would require
4077    // splitting the read into per-page chunks AND stitching them —
4078    // a future enhancement once a scheduler ships an
4079    // arena-allocated payload larger than 4 KiB. Today the
4080    // truncation surfaces explicitly via the
4081    // [`RenderedValue::Truncated`] wrapper below when btf_size
4082    // exceeds the cap, so the operator sees the rendered subtree
4083    // is partial.
4084    //
4085    // When the sdt_alloc bridge fired with `header_skip > 0`, the
4086    // total bytes the chase needs from `val` are
4087    // `header_skip + btf_size`. The cap still applies to the
4088    // requested span — slot-start chases of payloads close to the
4089    // cap may surface as `Truncated` because the header eats into
4090    // the page-bounded read budget.
4091    let total_needed = resolved.header_skip.saturating_add(resolved.btf_size);
4092    let read_size = total_needed.min(POINTER_CHASE_CAP);
4093    let truncated_at_cap = total_needed > POINTER_CHASE_CAP;
4094    let Some(raw_bytes) = mem.read_arena(val, read_size) else {
4095        // [`MemReader::read_arena`] returns `None` when the full
4096        // requested length cannot be satisfied — most commonly
4097        // because the read crosses a page boundary in the captured
4098        // arena snapshot. Annotate so the consumer sees why the
4099        // deref didn't land.
4100        return ArenaChaseOutcome {
4101            deref: None,
4102            reason: Some(format!(
4103                "arena read failed (cross-page boundary or unmapped \
4104                 page); needed {read_size} bytes from 0x{val:x}"
4105            )),
4106            sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4107        };
4108    };
4109    // Slot-start bridge fire: skip the header before rendering the
4110    // payload struct. `read_arena` is contractually
4111    // single-page-bound so the slice never under-runs unless the
4112    // page-tail cropped the read; in that pathological case
4113    // surface a clear skip reason rather than rendering a
4114    // partial-header-stripped buffer.
4115    let Some(target_bytes) = apply_header_skip(&raw_bytes, resolved.header_skip) else {
4116        return ArenaChaseOutcome {
4117            deref: None,
4118            reason: Some(format!(
4119                "arena read at 0x{val:x} returned {} bytes; \
4120                 sdt_alloc bridge needs at least {} for header skip",
4121                raw_bytes.len(),
4122                resolved.header_skip
4123            )),
4124            sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4125        };
4126    };
4127    let payload = recurse_into_target(
4128        &resolved,
4129        target_bytes,
4130        val,
4131        depth,
4132        mem,
4133        visited,
4134        truncated_at_cap,
4135    );
4136    ArenaChaseOutcome {
4137        deref: Some(payload),
4138        reason: None,
4139        sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4140    }
4141}
4142
4143/// Try the cross-BTF Fwd resolution bridge for a `BTF_KIND_FWD`
4144/// chase target. Returns `Some(CrossBtfRef { btf, type_id })` when
4145/// the [`MemReader::cross_btf_resolve_fwd`] override returns a
4146/// hit — the renderer's cast-analysis pre-pass populated a name-
4147/// keyed index over every embedded `.bpf.objs` BTF and the named
4148/// struct/union has a complete body in a sibling object.
4149///
4150/// `entry_btf` is the BTF the chase entered with — used to
4151/// translate the Fwd's name offset to a string the implementation
4152/// can key its index against. `target_ty` must be the post-peel
4153/// terminal of [`peel_modifiers_resolving_fwd`]; the helper
4154/// itself gates on `matches!(target_ty, Type::Fwd(_))` so calling
4155/// on a non-Fwd terminal is a no-op and returns `None`. Anonymous
4156/// Fwds (empty resolved name) likewise return `None` — the index
4157/// keys on non-empty names.
4158///
4159/// The aggregate-kind match ([`btf_rs::Fwd::is_struct`]) is
4160/// preserved end-to-end: a `Fwd` declared as `struct foo` only
4161/// resolves to a `struct foo` body in another BTF, never to a
4162/// `union foo`. Mirrors the same gate
4163/// [`peel_modifiers_resolving_fwd`] applies for in-BTF resolution.
4164fn try_cross_btf_fwd_resolve<'a>(
4165    mem: &'a dyn MemReader,
4166    entry_btf: &Btf,
4167    target_ty: &Type,
4168) -> Option<CrossBtfRef<'a>> {
4169    let Type::Fwd(fwd) = target_ty else {
4170        return None;
4171    };
4172    let kind = FwdKind::from_is_struct(fwd.is_struct());
4173    let name = entry_btf.resolve_name(fwd).ok()?;
4174    if name.is_empty() {
4175        return None;
4176    }
4177    mem.cross_btf_resolve_fwd(&name, kind)
4178}
4179
4180/// Build a [`RenderedValue::Ptr`] for a cast-recovered pointer with
4181/// uniform field assembly.
4182///
4183/// Every site in [`render_cast_pointer`] that emits a `Ptr` shares
4184/// the same four-field shape (`value`, optional `deref`, optional
4185/// `deref_skipped_reason`, optional `cast_annotation`). The helper
4186/// resolves the canonical annotation through [`cast_annotation_for`]
4187/// — a 4-cell match over
4188/// [`super::cast_analysis::AddrSpace`] × `sdt_alloc_resolved`
4189/// returning a `&'static str` — so every annotation the renderer
4190/// emits is a borrow into the binary's read-only string pool, not a
4191/// per-chase heap allocation. Adding a new address-space variant
4192/// surfaces as a non-exhaustive match at compile time, keeping the
4193/// analyzer enum and the operator-visible tag in lockstep.
4194/// `addr_space` here is the address space the renderer ACTUALLY
4195/// chased through (runtime decision), not the analyzer's hint, so
4196/// the annotation reflects the path the chase took.
4197///
4198/// `sdt_alloc_resolved` extends the annotation to
4199/// `cast→{addr_space} (sdt_alloc)` when the chase recovered the
4200/// target's BTF type id via [`MemReader::resolve_arena_type`]
4201/// instead of the cast analyzer's declared `target_type_id`.
4202/// Operators see at a glance that the rendered subtree's layout
4203/// came from the sdt_alloc bridge rather than the analyzer's own
4204/// flow-tracked type recovery.
4205fn cast_ptr(
4206    value: u64,
4207    deref: Option<Box<RenderedValue>>,
4208    reason: Option<String>,
4209    addr_space: super::cast_analysis::AddrSpace,
4210    sdt_alloc_resolved: bool,
4211) -> RenderedValue {
4212    RenderedValue::Ptr {
4213        value,
4214        deref,
4215        deref_skipped_reason: reason,
4216        cast_annotation: Some(Cow::Borrowed(cast_annotation_for(
4217            addr_space,
4218            sdt_alloc_resolved,
4219        ))),
4220    }
4221}
4222
4223/// Resolve the canonical cast annotation tag to a `&'static str`.
4224///
4225/// The `(addr_space, sdt_alloc_resolved)` pair maps to one of four
4226/// fixed strings — exhaustively enumerated below so
4227/// [`super::cast_analysis::AddrSpace`]'s closed variant set drives
4228/// an exhaustive match. A new variant produces a compile error
4229/// here, forcing the operator-visible tag to stay in lockstep with
4230/// the analyzer enum.
4231///
4232/// The pre-existing [`super::cast_analysis::AddrSpace`]
4233/// [`std::fmt::Display`] impl is kept for other call sites
4234/// (free-form formatting, error messages); the renderer side
4235/// bypasses `Display` because the closed set lets us hand back
4236/// static strings instead of allocating a new `String` per
4237/// chase.
4238fn cast_annotation_for(
4239    addr_space: super::cast_analysis::AddrSpace,
4240    sdt_alloc_resolved: bool,
4241) -> &'static str {
4242    use super::cast_analysis::AddrSpace;
4243    match (addr_space, sdt_alloc_resolved) {
4244        (AddrSpace::Arena, false) => "cast→arena",
4245        (AddrSpace::Arena, true) => "cast→arena (sdt_alloc)",
4246        (AddrSpace::Kernel, false) => "cast→kernel",
4247        (AddrSpace::Kernel, true) => "cast→kernel (sdt_alloc)",
4248    }
4249}
4250
4251/// Render a cast-recovered typed pointer.
4252///
4253/// Builds [`RenderedValue::Ptr`] mirroring the [`Type::Ptr`] arm's
4254/// shape so consumers (Display, JSON serializer, `is_flat_scalar`
4255/// classifier) handle cast-recovered pointers and BTF-typed pointers
4256/// uniformly. Pre-chase gating (null, cycle, depth cap) goes through
4257/// the shared [`chase_gate`] helper, so a linked-list /
4258/// parent-pointer cycle in cast-recovered pointers surfaces the
4259/// same `[cycle]` glyph as the [`Type::Ptr`] arm and a null cast
4260/// value renders identically.
4261///
4262/// Address-space dispatch is RUNTIME-driven: [`MemReader::is_arena_addr`]
4263/// is consulted on the actual pointer value to decide whether to
4264/// chase via [`MemReader::read_arena`] (in-window) or
4265/// [`MemReader::read_kva`] (out-of-window). The
4266/// [`super::cast_analysis::CastHit::addr_space`] tag from the
4267/// analyzer is treated as a hint only — runtime evidence from the
4268/// pointer value is authoritative because the analyzer's
4269/// flow-insensitive register tracking can mis-classify across
4270/// branch joins, while `is_arena_addr` is a structural property of
4271/// the live address space. When both readers might succeed the
4272/// arena reader wins (its frozen snapshot is more reliable than a
4273/// live walk through `read_kva`).
4274///
4275/// `cast_annotation` on the resulting [`RenderedValue::Ptr`]
4276/// records which path actually executed (`"cast→arena"` or
4277/// `"cast→kernel"`) so operators can distinguish cast-recovered
4278/// pointers from BTF-typed pointers without inspecting the
4279/// rendered subtree. When [`try_sdt_alloc_bridge`] fired during
4280/// the chase (the analyzer's `target_type_id` resolved to a
4281/// `BTF_KIND_FWD` whose real id was recovered via
4282/// [`MemReader::resolve_arena_type`]), [`cast_ptr`] extends the
4283/// annotation with a trailing ` (sdt_alloc)` — `"cast→arena
4284/// (sdt_alloc)"` / `"cast→kernel (sdt_alloc)"` — so operators
4285/// see the layout came from the bridge rather than the cast
4286/// analyzer's declared id. The [`Type::Ptr`] arm normally leaves
4287/// the field `None`, with one exception: when its arena branch
4288/// also fires the sdt_alloc bridge it sets `cast_annotation` to
4289/// the unprefixed `"sdt_alloc"` (no `cast→` prefix because the
4290/// chased pointer is structurally BTF-typed, not analyzer-
4291/// recovered).
4292///
4293/// On read failure (cross-page boundary in the arena snapshot,
4294/// unmapped page, etc.) the render emits `Ptr` with
4295/// `deref_skipped_reason` populated and `deref: None` — the chase
4296/// was attempted, distinguishing it from the no-chase paths above.
4297fn render_cast_pointer(
4298    btf: &Btf,
4299    hit: CastHit,
4300    value: u64,
4301    depth: u32,
4302    mem: &dyn MemReader,
4303    visited: &mut HashSet<u64>,
4304) -> RenderedValue {
4305    // [`chase_gate`] applies the null/cycle/depth-cap policy
4306    // shared with the [`Type::Ptr`] arm: null and depth-cap take
4307    // the "no chase attempted" path (`deref` + reason both
4308    // `None`); a cycle records the `cycle → 0x{value:x}` reason.
4309    // Only [`ChaseGate::Proceed`] enters the per-arm read+recurse
4310    // logic below. The cast_annotation on the no-chase path
4311    // reflects the analyzer's hint so operators still see the
4312    // pointer was a cast finding rather than a BTF-typed pointer.
4313    if let ChaseGate::Skip { reason } = chase_gate(value, depth, visited) {
4314        return cast_ptr(value, None, reason, hit.addr_space, false);
4315    }
4316    // Runtime address-space detection: if the value falls in the
4317    // arena window, chase through `read_arena` (frozen-snapshot
4318    // backed, no slab-liveness concern). Otherwise chase through
4319    // `read_kva` with the existing plausibility gate. The
4320    // analyzer's hint is preserved in `cast_annotation` so a
4321    // hint→runtime mismatch is visible; the renderer doesn't try
4322    // to reconcile them — the runtime decision wins.
4323    if mem.is_arena_addr(value) {
4324        // Thread the analyzer-captured `alloc_size` into the chase
4325        // helper so the deferred-resolve path
4326        // (`target_type_id == 0`) can fall back to size-based BTF
4327        // matching when the bridge has no entry — the
4328        // `scx_static_alloc_internal` resolution path.
4329        let outcome = chase_arena_pointer(
4330            btf,
4331            hit.target_type_id,
4332            hit.alloc_size,
4333            value,
4334            mem,
4335            depth,
4336            visited,
4337        );
4338        return cast_ptr(
4339            value,
4340            outcome.deref,
4341            outcome.reason,
4342            super::cast_analysis::AddrSpace::Arena,
4343            outcome.sdt_alloc_resolved,
4344        );
4345    }
4346    // Kernel-arm chase: out-of-arena value, read through the
4347    // page-table walker. Resolve target type id to a Type so
4348    // `type_size` can size the read. Failure here is rare in
4349    // practice — the cast analyzer only emits ids it itself
4350    // resolved through the same BTF — but the fallthrough emits a
4351    // labelled skip rather than panicking. Use the Fwd-resolving
4352    // peel so a [`Type::Fwd`] target with a complete sibling in
4353    // the BTF lands on the sibling rather than skipping. The
4354    // arena arm above shares the same shortcut via
4355    // [`chase_arena_pointer`].
4356    //
4357    // Special case: `target_type_id == 0` is the cast analyzer's
4358    // STX-flow Arena sentinel (the deferred-resolve arena cast
4359    // path emits this when the target is unresolved at analysis
4360    // time). The hint was Arena but the runtime value fell
4361    // outside the arena window, so we landed here. Without a BTF
4362    // id to resolve, the kernel arm cannot size the read; surface
4363    // a clear skip reason so the operator sees the analyzer/runtime
4364    // mismatch.
4365    if hit.target_type_id == 0 {
4366        return cast_ptr(
4367            value,
4368            None,
4369            Some(format!(
4370                "kernel cast target unresolved (analyzer hinted Arena \
4371                 with deferred resolve, but runtime value 0x{value:x} \
4372                 fell outside the arena window)"
4373            )),
4374            super::cast_analysis::AddrSpace::Kernel,
4375            false,
4376        );
4377    }
4378    // Pre-read sequence shared with [`chase_arena_pointer`]: peel +
4379    // Fwd-resolve target, try sdt_alloc bridge, fall back to
4380    // cross-BTF Fwd index, settle final btf_size, reject zero-size
4381    // payloads. Returns a [`ResolvedTarget`] ready for the per-arm
4382    // `read_kva`, or a skip reason that flows into the `cast_ptr`
4383    // builder so the no-deref render still surfaces the bridge
4384    // state when it fired before the skip.
4385    let resolved = match resolve_chase_target(btf, mem, value, hit.target_type_id, "kernel cast") {
4386        ChaseResolve::Ready(r) => r,
4387        ChaseResolve::Skip {
4388            reason,
4389            sdt_alloc_resolved,
4390        } => {
4391            return cast_ptr(
4392                value,
4393                None,
4394                Some(reason),
4395                super::cast_analysis::AddrSpace::Kernel,
4396                sdt_alloc_resolved,
4397            );
4398        }
4399    };
4400    // Kernel reads honour [`POINTER_CHASE_CAP`] and also cap at
4401    // the remaining bytes in the current 4 KiB page so `read_kva`
4402    // cannot accidentally cross a page boundary into an unrelated
4403    // allocation. The kernel direct-map / vmalloc walker returns
4404    // whatever the page tables resolve, but a struct that
4405    // straddles a page boundary may have its tail in a freed or
4406    // unrelated page — bounding the read at the page edge keeps
4407    // the dump from leaking adjacent slab content.
4408    //
4409    // When the sdt_alloc bridge fired with `header_skip > 0`,
4410    // the total bytes the chase needs from `value` are
4411    // `header_skip + btf_size` — the header bytes are skipped
4412    // before the payload starts. The page-bounded cap still
4413    // applies to the requested span.
4414    const PAGE_SIZE: u64 = 4096;
4415    // PAGE_SIZE - (value % PAGE_SIZE) bytes remain in the current
4416    // 4 KiB page from `value` onward. usize fits the result
4417    // because PAGE_SIZE is 4096 (well below usize::MAX) and the
4418    // modulo result is in [0, PAGE_SIZE).
4419    let page_remaining = (PAGE_SIZE - (value % PAGE_SIZE)) as usize;
4420    let total_needed = resolved.header_skip.saturating_add(resolved.btf_size);
4421    let read_size = total_needed.min(POINTER_CHASE_CAP).min(page_remaining);
4422    let truncated_at_cap = total_needed > read_size;
4423    let Some(raw_bytes) = mem.read_kva(value, read_size) else {
4424        // The runtime check rejected the arena window AND the
4425        // kernel read failed — surface both pieces of evidence so
4426        // the operator can correlate the analyzer hint with the
4427        // actual chase outcome. If the analyzer hinted Arena but
4428        // the value was outside the arena window, this is a
4429        // structural mismatch: the analyzer may have flagged a
4430        // non-pointer field.
4431        let suffix = if matches!(hit.addr_space, super::cast_analysis::AddrSpace::Arena) {
4432            " (cast analysis may have flagged a non-pointer field)"
4433        } else {
4434            ""
4435        };
4436        return cast_ptr(
4437            value,
4438            None,
4439            Some(format!(
4440                "kernel read_kva failed at 0x{value:x} \
4441                 (unmapped page or no PTE); needed {read_size} bytes{suffix}"
4442            )),
4443            super::cast_analysis::AddrSpace::Kernel,
4444            resolved.sdt_alloc_resolved,
4445        );
4446    };
4447    // Slot-start bridge fire on the kernel arm: skip the header
4448    // before the plausibility gate and the recursion. Refuse to
4449    // proceed when the read returned fewer bytes than the header
4450    // skip needs — surfaces the page-tail truncation as a clear
4451    // skip reason rather than rendering a corrupted slice.
4452    let Some(target_bytes) = apply_header_skip(&raw_bytes, resolved.header_skip) else {
4453        return cast_ptr(
4454            value,
4455            None,
4456            Some(format!(
4457                "kernel read_kva at 0x{value:x} returned {} bytes; \
4458                 sdt_alloc bridge needs at least {} for header skip",
4459                raw_bytes.len(),
4460                resolved.header_skip
4461            )),
4462            super::cast_analysis::AddrSpace::Kernel,
4463            resolved.sdt_alloc_resolved,
4464        );
4465    };
4466    // Plausibility gate: a freed slab object's first qword is
4467    // often a freelist next pointer, which on x86_64 / aarch64
4468    // typically lands in the kernel direct-map range
4469    // (0xffff800000000000+, top byte 0xff). Reject reads where
4470    // the first 8 bytes look like that pattern as a probable
4471    // stale-pointer signature. Same heuristic as the cpumask
4472    // kptr chase in the `Type::Ptr` arm. Arena reads are exempt
4473    // (the arena helper above does not apply this gate) — arena
4474    // pages are caller-controlled allocations whose first bytes
4475    // are not used for slab freelist metadata. The gate runs
4476    // against the post-skip payload bytes when the bridge fired.
4477    if target_bytes.len() >= 8 {
4478        let first_qword = u64::from_le_bytes(target_bytes[..8].try_into().unwrap());
4479        if first_qword >> 56 == 0xff {
4480            return cast_ptr(
4481                value,
4482                None,
4483                Some(format!(
4484                    "kernel cast plausibility gate rejected: first qword \
4485                     top byte is 0xff at 0x{value:x} (likely freed slab \
4486                     object freelist pointer)"
4487                )),
4488                super::cast_analysis::AddrSpace::Kernel,
4489                resolved.sdt_alloc_resolved,
4490            );
4491        }
4492    }
4493    let deref_payload = recurse_into_target(
4494        &resolved,
4495        target_bytes,
4496        value,
4497        depth,
4498        mem,
4499        visited,
4500        truncated_at_cap,
4501    );
4502    cast_ptr(
4503        value,
4504        Some(deref_payload),
4505        None,
4506        super::cast_analysis::AddrSpace::Kernel,
4507        resolved.sdt_alloc_resolved,
4508    )
4509}
4510
4511fn render_bitfield(
4512    btf: &Btf,
4513    member_type_id: u32,
4514    parent_bytes: &[u8],
4515    bit_off: usize,
4516    width: usize,
4517) -> RenderedValue {
4518    if width == 0 || width > 64 {
4519        return RenderedValue::Unsupported {
4520            reason: format!("bitfield width {width} out of range"),
4521        };
4522    }
4523    let byte_start = bit_off / 8;
4524    let bit_shift = bit_off % 8;
4525    let bits_needed = bit_shift + width;
4526    let bytes_needed = bits_needed.div_ceil(8);
4527    if byte_start + bytes_needed > parent_bytes.len() {
4528        let avail_start = byte_start.min(parent_bytes.len());
4529        let avail = &parent_bytes[avail_start..];
4530        return RenderedValue::Truncated {
4531            needed: bytes_needed,
4532            had: avail.len(),
4533            partial: Box::new(RenderedValue::Bytes {
4534                hex: hex_dump(avail),
4535            }),
4536        };
4537    }
4538    // Pull up to 16 bytes (max bitfield is 64 bits + 7 bit shift = 71
4539    // bits = 9 bytes); use a fixed buffer to avoid heap.
4540    let mut buf = [0u8; 16];
4541    buf[..bytes_needed].copy_from_slice(&parent_bytes[byte_start..byte_start + bytes_needed]);
4542    // Pack into a u128 little-endian, then mask + shift.
4543    let mut packed: u128 = 0;
4544    for (i, b) in buf[..bytes_needed].iter().enumerate() {
4545        packed |= (*b as u128) << (i * 8);
4546    }
4547    let raw = ((packed >> bit_shift) & ((1u128 << width) - 1)) as u64;
4548
4549    let Some(member_ty) = peel_modifiers(btf, member_type_id) else {
4550        return RenderedValue::Unsupported {
4551            reason: "bitfield type modifiers unresolvable".to_string(),
4552        };
4553    };
4554    // BTF bitfields can carry signed Int *or* signed Enum / Enum64
4555    // bases (e.g. `enum scx_exit_kind` declared with negative
4556    // members). Treat all three as signed for the sign-extension
4557    // step so a negative-valued bitfield rendered through any of
4558    // them comes back as a correctly-signed `Int` rather than a
4559    // raw-bits `Uint`.
4560    let signed = match &member_ty {
4561        Type::Int(i) => i.is_signed(),
4562        Type::Enum(e) => e.is_signed(),
4563        Type::Enum64(e) => e.is_signed(),
4564        _ => false,
4565    };
4566    if signed {
4567        let value = sign_extend(raw, width) as i64;
4568        RenderedValue::Int {
4569            bits: width as u32,
4570            value,
4571        }
4572    } else {
4573        RenderedValue::Uint {
4574            bits: width as u32,
4575            value: raw,
4576        }
4577    }
4578}
4579
4580/// Peel pass-through qualifier types (Volatile, Const, Restrict,
4581/// Typedef, TypeTag, DeclTag) and return the underlying [`Type`].
4582/// Returns `None` if the chain exceeds [`MAX_MODIFIER_DEPTH`] or fails
4583/// to resolve.
4584pub(crate) fn peel_modifiers(btf: &Btf, type_id: u32) -> Option<Type> {
4585    peel_modifiers_with_id(btf, type_id).map(|(ty, _)| ty)
4586}
4587
4588/// Peel modifiers like [`peel_modifiers_with_id`], then if the
4589/// terminal is a [`Type::Fwd`] resolve it to a complete
4590/// [`Type::Struct`] / [`Type::Union`] of the same name in the same
4591/// BTF when one exists.
4592///
4593/// `BTF_KIND_FWD` is a forward declaration (`struct foo;` with no
4594/// body) clang emits when a type is referenced only via pointer in
4595/// the compilation unit. Concatenated BPF objects routinely include
4596/// both a `Fwd` and a complete `Struct`/`Union` with the same name —
4597/// the `Fwd` from a header that only declares the type, the
4598/// complete shape from a `.bpf.c` that defines it. The chase
4599/// pipeline calls [`type_size`] right after peeling; `type_size`
4600/// returns `None` for `Type::Fwd`, which produces "unresolvable
4601/// size" skips even when the BTF carries a fully-typed sibling
4602/// the renderer could land against. This helper elides that gap by
4603/// preferring the complete sibling whenever one is present.
4604///
4605/// Returns the original peeled (Type, id) pair when:
4606/// - the terminal is not a [`Type::Fwd`] (no resolution needed),
4607/// - the [`Type::Fwd`] has no name (anonymous fwds cannot be
4608///   keyed for lookup),
4609/// - no sibling [`Type::Struct`]/[`Type::Union`] of the same name
4610///   AND matching aggregate kind (struct vs union, per
4611///   [`btf_rs::Fwd::is_struct`] / `is_union`) exists in the BTF.
4612///
4613/// The aggregate-kind match is crucial — a `Fwd` declared as
4614/// `struct foo` must NOT resolve to a `union foo` in the same BTF
4615/// (the wire format permits same-name struct + union declarations,
4616/// rare but legal). The renderer would render the wrong layout if
4617/// we collapsed the two.
4618///
4619/// Single-pass resolution: the helper calls
4620/// [`peel_modifiers_with_id`] once, inspects the terminal, and
4621/// either returns the original peeled pair or returns the first
4622/// matching sibling found in the by-name candidate list. There is
4623/// no re-entry into [`peel_modifiers_resolving_fwd`] from within
4624/// itself; the bounded modifier-peel cap inside
4625/// [`peel_modifiers_with_id`] is what protects against malformed
4626/// `Fwd -> Typedef -> Fwd` chains.
4627pub(crate) fn peel_modifiers_resolving_fwd(btf: &Btf, type_id: u32) -> Option<(Type, u32)> {
4628    let (ty, tid) = peel_modifiers_with_id(btf, type_id)?;
4629    let Type::Fwd(ref fwd) = ty else {
4630        return Some((ty, tid));
4631    };
4632    let kind = FwdKind::from_is_struct(fwd.is_struct());
4633    let Ok(name) = btf.resolve_name(fwd) else {
4634        return Some((ty, tid));
4635    };
4636    if name.is_empty() {
4637        return Some((ty, tid));
4638    }
4639    let Ok(candidate_ids) = btf.resolve_ids_by_name(&name) else {
4640        return Some((ty, tid));
4641    };
4642    for cid in candidate_ids {
4643        if cid == tid {
4644            // The Fwd itself shows up in the by-name list; skip it
4645            // so the loop searches only siblings.
4646            continue;
4647        }
4648        let Some((candidate_ty, candidate_id)) = peel_modifiers_with_id(btf, cid) else {
4649            continue;
4650        };
4651        match (&candidate_ty, kind) {
4652            (Type::Struct(_), FwdKind::Struct) => return Some((candidate_ty, candidate_id)),
4653            (Type::Union(_), FwdKind::Union) => return Some((candidate_ty, candidate_id)),
4654            _ => continue,
4655        }
4656    }
4657    Some((ty, tid))
4658}
4659
4660/// Peel pass-through qualifiers starting from a [`Type`] value
4661/// rather than a BTF type id. Single shared helper for callers that
4662/// already hold a resolved [`Type`] and would otherwise re-implement
4663/// the peel loop. Returns the original `start` type unchanged when
4664/// it is already non-modifier; returns `None` only on `btf_rs`
4665/// resolve failure or the [`MAX_MODIFIER_DEPTH`] cap.
4666pub(crate) fn peel_modifiers_from_type(btf: &Btf, start: Type) -> Option<Type> {
4667    let mut t = start;
4668    for _ in 0..MAX_MODIFIER_DEPTH {
4669        // Each modifier kind binds a different `btf_rs` type
4670        // (`Volatile`, `Const`, `Restrict`, `Typedef`, `TypeTag`,
4671        // `DeclTag`), so or-patterns that share an `inner` binding
4672        // would force the binding to a single type. Use separate
4673        // arms — `resolve_chained_type` is generic over `BtfType`
4674        // so each arm reduces to one line.
4675        let next = match &t {
4676            Type::Volatile(inner) => btf.resolve_chained_type(inner).ok()?,
4677            Type::Const(inner) => btf.resolve_chained_type(inner).ok()?,
4678            Type::Restrict(inner) => btf.resolve_chained_type(inner).ok()?,
4679            Type::Typedef(inner) => btf.resolve_chained_type(inner).ok()?,
4680            Type::TypeTag(inner) => btf.resolve_chained_type(inner).ok()?,
4681            Type::DeclTag(inner) => btf.resolve_chained_type(inner).ok()?,
4682            _ => return Some(t),
4683        };
4684        t = next;
4685    }
4686    None
4687}
4688
4689/// Same as [`peel_modifiers`] but also returns the BTF type id of the
4690/// peeled (terminal) type. The cast-intercept path keys
4691/// [`MemReader::cast_lookup`] on the *struct* type id of the parent
4692/// aggregate — not the modifier-wrapped surface id — so the
4693/// post-peel id is what the cast_analysis [`super::cast_analysis::CastMap`]
4694/// stores. Mirrors [`super::bpf_map::resolve_to_struct_id`]'s
4695/// modifier-peeling shape; the renderer uses this variant whenever
4696/// it needs both the resolved Type and its id.
4697pub(crate) fn peel_modifiers_with_id(btf: &Btf, mut type_id: u32) -> Option<(Type, u32)> {
4698    for _ in 0..MAX_MODIFIER_DEPTH {
4699        let ty = btf.resolve_type_by_id(type_id).ok()?;
4700        match &ty {
4701            Type::Volatile(t) => type_id = t.get_type_id()?,
4702            Type::Const(t) => type_id = t.get_type_id()?,
4703            Type::Restrict(t) => type_id = t.get_type_id()?,
4704            Type::Typedef(t) => type_id = t.get_type_id()?,
4705            Type::TypeTag(t) => type_id = t.get_type_id()?,
4706            // DeclTag doesn't change the underlying type, just adds
4707            // metadata; peel through it too.
4708            Type::DeclTag(t) => type_id = t.get_type_id()?,
4709            _ => return Some((ty, type_id)),
4710        }
4711    }
4712    None
4713}
4714
4715/// Compute the storage size in bytes of a (peeled) BTF type.
4716///
4717/// Returns `None` for types whose size is not resolvable from BTF
4718/// alone (Func, FuncProto, Datasec, Var, Void) or where the chain
4719/// requires further resolution that fails.
4720pub(crate) fn type_size(btf: &Btf, ty: &Type) -> Option<usize> {
4721    match ty {
4722        Type::Int(int) => Some(int.size()),
4723        Type::Float(f) => Some(f.size()),
4724        Type::Enum(e) => Some(e.size()),
4725        Type::Enum64(e) => Some(e.size()),
4726        Type::Struct(s) | Type::Union(s) => Some(s.size()),
4727        Type::Ptr(_) => Some(8),
4728        Type::Array(arr) => {
4729            let len = arr.len();
4730            let elem_peeled = peel_modifiers(btf, arr.get_type_id()?)?;
4731            let elem_size = type_size(btf, &elem_peeled)?;
4732            Some(len * elem_size)
4733        }
4734        Type::Volatile(t) | Type::Const(t) | Type::Restrict(t) => {
4735            let inner = btf.resolve_chained_type(t).ok()?;
4736            type_size(btf, &inner)
4737        }
4738        Type::Typedef(t) => {
4739            let inner = btf.resolve_chained_type(t).ok()?;
4740            type_size(btf, &inner)
4741        }
4742        Type::TypeTag(t) => {
4743            let inner = btf.resolve_chained_type(t).ok()?;
4744            type_size(btf, &inner)
4745        }
4746        // Function types, datasec, var, fwd, void have no value size.
4747        _ => None,
4748    }
4749}
4750
4751/// Read a little-endian unsigned integer up to 8 bytes wide.
4752fn read_uint_le(bytes: &[u8]) -> u64 {
4753    let mut buf = [0u8; 8];
4754    let n = bytes.len().min(8);
4755    buf[..n].copy_from_slice(&bytes[..n]);
4756    u64::from_le_bytes(buf)
4757}
4758
4759/// Sign-extend `raw` (unsigned, low `bits` populated) to a signed i64-
4760/// representable value held in u64 bit pattern.
4761fn sign_extend(raw: u64, bits: usize) -> u64 {
4762    if bits == 0 || bits >= 64 {
4763        return raw;
4764    }
4765    let shift = 64 - bits;
4766    ((raw << shift) as i64 >> shift) as u64
4767}
4768
4769#[cfg(test)]
4770mod tests;
4771
4772/// Host-unit coverage for renderer decode and chase branches that the
4773/// `tests/` submodule leaves unexercised: the `render_float` byte-decode
4774/// arms, the `chase_arena_pointer` deferred-resolve `alloc_size`
4775/// fallback (both the size-match success and the unresolved skip), the
4776/// `apply_header_skip` underrun guard, the raw `MAX_RENDER_DEPTH`
4777/// backstop, the flex-array (`BTF len=0`) arm, and the
4778/// `MemReader::alloc_size_types` / `resolve_arena_type_meta_fallback`
4779/// trait defaults. Each test asserts the exact rendered value so a
4780/// behavioural regression fails the assertion rather than slipping
4781/// through a vacuous `is_some()` check.
4782///
4783/// Local `MemReader` stubs are defined here rather than reusing the
4784/// `tests/` submodule's `CastStubReader`: the `tests/` types are private
4785/// to that module tree, and the underrun test needs a reader whose
4786/// `read_arena` returns a SHORTER slice than requested (a page-tail
4787/// crop) — behaviour the shared `CastStubReader` deliberately does not
4788/// have (it returns `None` on a short read).
4789#[cfg(test)]
4790mod blueprint_coverage_tests {
4791    use super::*;
4792    use crate::monitor::cast_analysis::{AddrSpace, CastHit, CastMap};
4793    use crate::test_support::btf_blob::{CastSynMember, CastSynType, cast_build_btf};
4794
4795    /// Append a NUL-terminated `name` to a BTF string section and
4796    /// return its byte offset. Mirrors the `push` closure the `tests/`
4797    /// submodule fixtures use.
4798    fn push_name(strings: &mut Vec<u8>, name: &str) -> u32 {
4799        let off = strings.len() as u32;
4800        strings.extend_from_slice(name.as_bytes());
4801        strings.push(0);
4802        off
4803    }
4804
4805    /// Build a BTF blob carrying one 4-byte `BTF_KIND_FLOAT` (id 1) and
4806    /// one 8-byte `BTF_KIND_FLOAT` (id 2). Returns `(blob, f32_id,
4807    /// f64_id)`.
4808    fn float_btf_f32_and_f64() -> (Vec<u8>, u32, u32) {
4809        let mut strings: Vec<u8> = vec![0];
4810        let n_f32 = push_name(&mut strings, "f32");
4811        let n_f64 = push_name(&mut strings, "f64");
4812        let types = vec![
4813            CastSynType::Float {
4814                name_off: n_f32,
4815                size: 4,
4816            },
4817            CastSynType::Float {
4818                name_off: n_f64,
4819                size: 8,
4820            },
4821        ];
4822        (cast_build_btf(&types, &strings), 1, 2)
4823    }
4824
4825    /// Build a BTF blob with a single `BTF_KIND_FLOAT` of `size` bytes
4826    /// (id 1). Returns `(blob, float_id)`.
4827    fn float_btf_with_size(size: u32) -> (Vec<u8>, u32) {
4828        let mut strings: Vec<u8> = vec![0];
4829        let n = push_name(&mut strings, "flt");
4830        let types = vec![CastSynType::Float { name_off: n, size }];
4831        (cast_build_btf(&types, &strings), 1)
4832    }
4833
4834    /// `render_float` decodes a 4-byte BTF float via `f32::from_le_bytes`
4835    /// and an 8-byte one via `f64::from_le_bytes`, reached through the
4836    /// `Type::Float` arm of `render_value_inner`. 1.5 (f32) and -2.25
4837    /// (f64) are exactly representable, so the `f64` equality is exact.
4838    #[test]
4839    fn render_float_f32_and_f64_decode_via_btf() {
4840        let (blob, f32_id, f64_id) = float_btf_f32_and_f64();
4841        let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4842        assert_eq!(
4843            render_value(&btf, f32_id, &1.5f32.to_le_bytes()),
4844            RenderedValue::Float {
4845                bits: 32,
4846                value: 1.5,
4847            },
4848        );
4849        assert_eq!(
4850            render_value(&btf, f64_id, &(-2.25f64).to_le_bytes()),
4851            RenderedValue::Float {
4852                bits: 64,
4853                value: -2.25,
4854            },
4855        );
4856    }
4857
4858    /// A BTF float whose declared `size` is neither 4 nor 8 (here 2, a
4859    /// `_Float16` / `bf16` width) routes `render_float` to the
4860    /// `Unsupported` arm. The input slice is exactly `size` bytes so the
4861    /// `bytes.len() < size` truncation guard does not fire first.
4862    #[test]
4863    fn render_float_unsupported_size_yields_unsupported() {
4864        let (blob, f16_id) = float_btf_with_size(2);
4865        let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4866        assert_eq!(
4867            render_value(&btf, f16_id, &[0u8; 2]),
4868            RenderedValue::Unsupported {
4869                reason: "unsupported float size 2".to_string(),
4870            },
4871        );
4872    }
4873
4874    /// Fewer bytes than the float's declared size trips the truncation
4875    /// guard in `render_float`, carrying the available bytes as a
4876    /// lowercase space-separated hex `Bytes` partial. Mirrors the
4877    /// dedicated Int / Enum / Enum64 truncation tests, which `render_float`
4878    /// previously lacked.
4879    #[test]
4880    fn render_float_truncated_carries_bytes_partial() {
4881        let (blob, f64_id) = float_btf_with_size(8);
4882        let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4883        assert_eq!(
4884            render_value(&btf, f64_id, &[0xaa, 0xbb]),
4885            RenderedValue::Truncated {
4886                needed: 8,
4887                had: 2,
4888                partial: Box::new(RenderedValue::Bytes {
4889                    hex: "aa bb".to_string(),
4890                }),
4891            },
4892        );
4893    }
4894
4895    /// Stub `MemReader` for the deferred-resolve arena chase tests. The
4896    /// cast intercept fires only for the keyed `(parent_type_id, off)`
4897    /// pairs in `cast_map`, so the inner payload struct's own `u64`
4898    /// member is NOT mis-chased as a pointer. `resolve_arena_type`
4899    /// returns `None` (bridge miss) so the chase falls through to the
4900    /// `alloc_size` path; `alloc_size_types` keeps the trait default
4901    /// (empty) so the cross-BTF fallback loop is empty.
4902    #[derive(Default)]
4903    struct AllocSizeStubReader {
4904        arena_window: Option<(u64, u64)>,
4905        arena_bytes_at: std::collections::HashMap<u64, Vec<u8>>,
4906        cast_map: CastMap,
4907    }
4908    impl MemReader for AllocSizeStubReader {
4909        fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
4910            None
4911        }
4912        fn is_arena_addr(&self, addr: u64) -> bool {
4913            match self.arena_window {
4914                Some((lo, hi)) => addr >= lo && addr < hi,
4915                None => false,
4916            }
4917        }
4918        fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
4919            let bytes = self.arena_bytes_at.get(&addr)?;
4920            if bytes.len() < len {
4921                return None;
4922            }
4923            Some(bytes[..len].to_vec())
4924        }
4925        fn cast_lookup(&self, parent_type_id: u32, member_byte_offset: u32) -> Option<CastHit> {
4926            self.cast_map
4927                .get(&(parent_type_id, member_byte_offset))
4928                .copied()
4929        }
4930        // resolve_arena_type intentionally left at the trait default
4931        // (None) so the deferred-resolve chase takes the alloc_size
4932        // fallback path rather than the bridge path.
4933    }
4934
4935    /// Build a BTF for the `alloc_size` fallback tests:
4936    ///   - id 1: u64 (plain unsigned, the cast field's wire type)
4937    ///   - id 2: struct task_ctx { u64 weight @ 0 } size 8 — the ONLY
4938    ///     size-8 struct, so `discover_payload_btf_id(btf, 8, "")`
4939    ///     resolves it unambiguously
4940    ///   - id 3: struct outer { u64 p @ 0 } size 16 — the cast-carrying
4941    ///     parent; size 16 keeps it OUT of the size-8 candidate set so
4942    ///     task_ctx is the unique size-8 match
4943    ///
4944    /// Returns `(blob, outer_id, task_ctx_id)`.
4945    fn alloc_size_btf() -> (Vec<u8>, u32, u32) {
4946        let mut strings: Vec<u8> = vec![0];
4947        let n_u64 = push_name(&mut strings, "u64");
4948        let n_task = push_name(&mut strings, "task_ctx");
4949        let n_weight = push_name(&mut strings, "weight");
4950        let n_outer = push_name(&mut strings, "outer");
4951        let n_p = push_name(&mut strings, "p");
4952        let types = vec![
4953            CastSynType::Int {
4954                name_off: n_u64,
4955                size: 8,
4956                encoding: 0,
4957                offset: 0,
4958                bits: 64,
4959            },
4960            CastSynType::Struct {
4961                name_off: n_task,
4962                size: 8,
4963                members: vec![CastSynMember {
4964                    name_off: n_weight,
4965                    type_id: 1,
4966                    byte_offset: 0,
4967                }],
4968            },
4969            CastSynType::Struct {
4970                name_off: n_outer,
4971                size: 16,
4972                members: vec![CastSynMember {
4973                    name_off: n_p,
4974                    type_id: 1,
4975                    byte_offset: 0,
4976                }],
4977            },
4978        ];
4979        (cast_build_btf(&types, &strings), 3, 2)
4980    }
4981
4982    /// Deferred-resolve arena cast (`target_type_id == 0`) where the
4983    /// `resolve_arena_type` bridge misses and `alloc_size = Some(8)`:
4984    /// `discover_payload_btf_id` size-matches the unique size-8 struct,
4985    /// the chase reads its bytes from the arena, and the rendered `Ptr`
4986    /// carries `cast→arena (sdt_alloc)`. Exercises the
4987    /// `chase_arena_pointer`'s `alloc_size` size-match success arm — dead in the `tests/`
4988    /// suite because every existing `CastHit` uses `alloc_size: None`.
4989    #[test]
4990    fn cast_chase_arena_alloc_size_fallback_resolves_via_discover_payload_btf_id() {
4991        let (blob, outer_id, task_ctx_id) = alloc_size_btf();
4992        let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
4993        // task_ctx_id is distinct from outer_id so the keyed cast_map
4994        // hit on (outer_id, 0) does not re-fire when the inner
4995        // task_ctx.weight member is rendered.
4996        assert_ne!(task_ctx_id, outer_id);
4997
4998        const ARENA_LO: u64 = 0x10_0000_0000;
4999        const ARENA_HI: u64 = 0x10_0001_0000;
5000        const TARGET_ADDR: u64 = 0x10_0000_1000;
5001        // outer { p: TARGET_ADDR } in 16 bytes (cast field @ 0).
5002        let mut outer_bytes = vec![0u8; 16];
5003        outer_bytes[0..8].copy_from_slice(&TARGET_ADDR.to_le_bytes());
5004        // task_ctx at TARGET_ADDR: weight = 0x42.
5005        let inner_bytes = 0x42u64.to_le_bytes().to_vec();
5006        let mut arena_bytes = std::collections::HashMap::new();
5007        arena_bytes.insert(TARGET_ADDR, inner_bytes);
5008        let mut cast_map = CastMap::new();
5009        cast_map.insert(
5010            (outer_id, 0),
5011            CastHit {
5012                target_type_id: 0,
5013                addr_space: AddrSpace::Arena,
5014                alloc_size: Some(8),
5015            },
5016        );
5017        let reader = AllocSizeStubReader {
5018            arena_window: Some((ARENA_LO, ARENA_HI)),
5019            arena_bytes_at: arena_bytes,
5020            cast_map,
5021        };
5022
5023        let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5024        let RenderedValue::Struct { ref members, .. } = v else {
5025            panic!("expected outer Struct render, got {v:?}");
5026        };
5027        assert_eq!(members.len(), 1);
5028        assert_eq!(members[0].name, "p");
5029        let RenderedValue::Ptr {
5030            value,
5031            ref deref,
5032            ref deref_skipped_reason,
5033            ref cast_annotation,
5034        } = members[0].value
5035        else {
5036            panic!("cast field must render as Ptr, got {:?}", members[0].value);
5037        };
5038        assert_eq!(value, TARGET_ADDR);
5039        assert!(
5040            deref_skipped_reason.is_none(),
5041            "alloc_size size-match resolve must not surface a skip reason; got {deref_skipped_reason:?}",
5042        );
5043        assert_eq!(
5044            cast_annotation.as_deref(),
5045            Some("cast→arena (sdt_alloc)"),
5046            "deferred-resolve size-match sets the sdt_alloc-flavoured cast annotation",
5047        );
5048        let inner = deref
5049            .as_deref()
5050            .expect("alloc_size size-match must produce a deref");
5051        let RenderedValue::Struct {
5052            type_name: ref inner_name,
5053            members: ref inner_members,
5054        } = *inner
5055        else {
5056            panic!("deref payload must be the resolved task_ctx struct, got {inner:?}");
5057        };
5058        assert_eq!(inner_name.as_deref(), Some("task_ctx"));
5059        assert_eq!(inner_members.len(), 1);
5060        assert_eq!(inner_members[0].name, "weight");
5061        assert_eq!(
5062            inner_members[0].value,
5063            RenderedValue::Uint {
5064                bits: 64,
5065                value: 0x42,
5066            },
5067        );
5068    }
5069
5070    /// Deferred-resolve arena cast where the bridge misses and
5071    /// `alloc_size = Some(99)` matches no struct: `discover_payload_btf_id`
5072    /// returns `target_type_id 0` with reason `"no candidate of size 99"`,
5073    /// and `alloc_size_types` is empty, so the chase surfaces the
5074    /// labelled unresolved skip with `deref: None` and the plain
5075    /// `cast→arena` annotation (sdt_alloc NOT resolved). Exercises
5076    /// `chase_arena_pointer`'s `alloc_size` fallback-unresolved skip arm.
5077    #[test]
5078    fn cast_chase_arena_alloc_size_fallback_unresolved_surfaces_skip_reason() {
5079        let (blob, outer_id, _task_ctx_id) = alloc_size_btf();
5080        let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
5081
5082        const ARENA_LO: u64 = 0x10_0000_0000;
5083        const ARENA_HI: u64 = 0x10_0001_0000;
5084        const TARGET_ADDR: u64 = 0x10_0000_1000;
5085        let mut outer_bytes = vec![0u8; 16];
5086        outer_bytes[0..8].copy_from_slice(&TARGET_ADDR.to_le_bytes());
5087        // No arena bytes needed: the size-99 discover_payload_btf_id
5088        // miss returns the unresolved skip BEFORE any arena read, and
5089        // alloc_size_types() is empty (trait default) so the cross-BTF
5090        // fallback loop never runs.
5091        let arena_bytes: std::collections::HashMap<u64, Vec<u8>> = std::collections::HashMap::new();
5092        let mut cast_map = CastMap::new();
5093        cast_map.insert(
5094            (outer_id, 0),
5095            CastHit {
5096                target_type_id: 0,
5097                addr_space: AddrSpace::Arena,
5098                alloc_size: Some(99),
5099            },
5100        );
5101        let reader = AllocSizeStubReader {
5102            arena_window: Some((ARENA_LO, ARENA_HI)),
5103            arena_bytes_at: arena_bytes,
5104            cast_map,
5105        };
5106
5107        let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5108        let RenderedValue::Struct { ref members, .. } = v else {
5109            panic!("expected outer Struct render, got {v:?}");
5110        };
5111        let RenderedValue::Ptr {
5112            ref deref,
5113            ref deref_skipped_reason,
5114            ref cast_annotation,
5115            ..
5116        } = members[0].value
5117        else {
5118            panic!("cast field must render as Ptr, got {:?}", members[0].value);
5119        };
5120        assert!(deref.is_none(), "size-99 miss must not produce a deref");
5121        assert_eq!(
5122            deref_skipped_reason.as_deref(),
5123            Some(
5124                "arena chase: STX-flow path tagged slot as Arena with \
5125                 deferred resolve; alloc_size=99 fallback unresolved \
5126                 (no candidate of size 99)"
5127            ),
5128        );
5129        assert_eq!(
5130            cast_annotation.as_deref(),
5131            Some("cast→arena"),
5132            "unresolved deferred-resolve keeps the plain (non-sdt_alloc) annotation",
5133        );
5134    }
5135
5136    /// Stub `MemReader` whose `read_arena` returns a slice SHORTER than
5137    /// the requested length — modelling a page-tail crop in the captured
5138    /// arena snapshot. The shared `CastStubReader` returns `None` on a
5139    /// short read, which routes to the read-failed branch; this reader
5140    /// returns the truncated slice so the chase reaches
5141    /// `apply_header_skip`'s underrun guard specifically.
5142    struct ShortReadArenaReader {
5143        arena_window: (u64, u64),
5144        /// Bytes returned for the chased address regardless of the
5145        /// requested length — capped at what is stored (the page-tail
5146        /// crop).
5147        bytes_at: std::collections::HashMap<u64, Vec<u8>>,
5148        arena_type_at: std::collections::HashMap<u64, ArenaResolveHit>,
5149    }
5150    impl MemReader for ShortReadArenaReader {
5151        fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
5152            None
5153        }
5154        fn is_arena_addr(&self, addr: u64) -> bool {
5155            addr >= self.arena_window.0 && addr < self.arena_window.1
5156        }
5157        fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
5158            // Page-tail crop: return min(stored, requested) bytes — a
5159            // successful-but-short read, distinct from the shared
5160            // reader's None-on-short-read behaviour.
5161            let bytes = self.bytes_at.get(&addr)?;
5162            let n = bytes.len().min(len);
5163            Some(bytes[..n].to_vec())
5164        }
5165        fn resolve_arena_type(&self, addr: u64) -> Option<ArenaResolveHit> {
5166            self.arena_type_at.get(&addr).copied()
5167        }
5168    }
5169
5170    /// `BTF_KIND_FWD`-pointee `Type::Ptr` chase where the
5171    /// `resolve_arena_type` bridge fires with `header_skip = 16` but the
5172    /// arena read returns only 8 bytes (page-tail crop): `apply_header_skip`
5173    /// cannot slice past the header, so the chase surfaces the underrun
5174    /// skip with `deref: None` and the `sdt_alloc` annotation (the bridge
5175    /// did resolve; only the read fell short). Exercises the
5176    /// `chase_arena_pointer`'s `apply_header_skip` underrun guard, which a contract-conforming
5177    /// `read_arena` never reaches.
5178    #[test]
5179    fn arena_chase_header_skip_exceeds_read_surfaces_underrun_skip() {
5180        // BTF: id1 u64; id2 Fwd struct sdt_data (no body); id3 Ptr->id2;
5181        // id4 struct outer { sdt_data *data @ 0 } size 8; id5 struct
5182        // task_ctx { u64 weight @ 0 } size 8 (the bridge-resolved
5183        // payload, btf_size = 8).
5184        let mut strings: Vec<u8> = vec![0];
5185        let n_u64 = push_name(&mut strings, "u64");
5186        let n_fwd = push_name(&mut strings, "sdt_data");
5187        let n_outer = push_name(&mut strings, "outer");
5188        let n_data = push_name(&mut strings, "data");
5189        let n_task = push_name(&mut strings, "task_ctx");
5190        let n_weight = push_name(&mut strings, "weight");
5191        let types = vec![
5192            CastSynType::Int {
5193                name_off: n_u64,
5194                size: 8,
5195                encoding: 0,
5196                offset: 0,
5197                bits: 64,
5198            },
5199            CastSynType::Fwd {
5200                name_off: n_fwd,
5201                is_union: false,
5202            },
5203            CastSynType::Ptr { type_id: 2 },
5204            CastSynType::Struct {
5205                name_off: n_outer,
5206                size: 8,
5207                members: vec![CastSynMember {
5208                    name_off: n_data,
5209                    type_id: 3,
5210                    byte_offset: 0,
5211                }],
5212            },
5213            CastSynType::Struct {
5214                name_off: n_task,
5215                size: 8,
5216                members: vec![CastSynMember {
5217                    name_off: n_weight,
5218                    type_id: 1,
5219                    byte_offset: 0,
5220                }],
5221            },
5222        ];
5223        let blob = cast_build_btf(&types, &strings);
5224        let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
5225        let (outer_id, task_ctx_id) = (4u32, 5u32);
5226
5227        const ARENA_LO: u64 = 0x10_0000_0000;
5228        const ARENA_HI: u64 = 0x10_0001_0000;
5229        const SLOT_ADDR: u64 = 0x10_0000_1000;
5230        let outer_bytes = SLOT_ADDR.to_le_bytes().to_vec();
5231        // Seed only 8 bytes at SLOT_ADDR. The chase needs
5232        // header_skip(16) + btf_size(8) = 24 bytes; read_arena returns
5233        // 8 (page-tail crop), which is < header_skip, so apply_header_skip
5234        // cannot land on the payload.
5235        let mut bytes_at = std::collections::HashMap::new();
5236        bytes_at.insert(SLOT_ADDR, vec![0u8; 8]);
5237        let mut arena_type_at = std::collections::HashMap::new();
5238        arena_type_at.insert(
5239            SLOT_ADDR,
5240            ArenaResolveHit {
5241                target_type_id: task_ctx_id,
5242                header_skip: 16,
5243            },
5244        );
5245        let reader = ShortReadArenaReader {
5246            arena_window: (ARENA_LO, ARENA_HI),
5247            bytes_at,
5248            arena_type_at,
5249        };
5250
5251        let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5252        let RenderedValue::Struct { ref members, .. } = v else {
5253            panic!("expected outer Struct render, got {v:?}");
5254        };
5255        assert_eq!(members[0].name, "data");
5256        let RenderedValue::Ptr {
5257            ref deref,
5258            ref deref_skipped_reason,
5259            ref cast_annotation,
5260            ..
5261        } = members[0].value
5262        else {
5263            panic!("data field must render as Ptr, got {:?}", members[0].value);
5264        };
5265        assert!(deref.is_none(), "underrun must not produce a deref");
5266        assert_eq!(
5267            deref_skipped_reason.as_deref(),
5268            Some(
5269                "arena read at 0x1000001000 returned 8 bytes; \
5270                 sdt_alloc bridge needs at least 16 for header skip"
5271            ),
5272        );
5273        assert_eq!(
5274            cast_annotation.as_deref(),
5275            Some("sdt_alloc"),
5276            "the bridge resolved (sdt_alloc) even though the read fell short",
5277        );
5278    }
5279
5280    /// Stub `MemReader` for the depth-cap chain: every seeded arena
5281    /// address stores the NEXT distinct arena address, so the renderer
5282    /// recurses through a non-cycling pointer chain. Distinct addresses
5283    /// keep the visited-set cycle check from firing before the raw
5284    /// `MAX_RENDER_DEPTH` backstop.
5285    struct ChainArenaReader {
5286        arena_window: (u64, u64),
5287        bytes_at: std::collections::HashMap<u64, Vec<u8>>,
5288    }
5289    impl MemReader for ChainArenaReader {
5290        fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
5291            None
5292        }
5293        fn is_arena_addr(&self, addr: u64) -> bool {
5294            addr >= self.arena_window.0 && addr < self.arena_window.1
5295        }
5296        fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
5297            let bytes = self.bytes_at.get(&addr)?;
5298            if bytes.len() < len {
5299                return None;
5300            }
5301            Some(bytes[..len].to_vec())
5302        }
5303    }
5304
5305    /// A non-cycling chain of >32 distinct addresses (each `node`
5306    /// pointing at a fresh address) exhausts the recursion depth before
5307    /// any address repeats, so the raw `MAX_RENDER_DEPTH` backstop fires
5308    /// — the deepest deref is `Unsupported { reason: "render depth 32
5309    /// exceeded" }` — rather than the visited-set cycle check. The
5310    /// existing `tests/` cycle cases all trip the visited check first, so
5311    /// this is the only test that reaches the depth cap.
5312    #[test]
5313    fn render_value_inner_depth_cap_yields_unsupported() {
5314        // id1: struct node { node *next @ 0 } size 8; id2: Ptr -> id1.
5315        let mut strings: Vec<u8> = vec![0];
5316        let n_node = push_name(&mut strings, "node");
5317        let n_next = push_name(&mut strings, "next");
5318        let types = vec![
5319            CastSynType::Struct {
5320                name_off: n_node,
5321                size: 8,
5322                members: vec![CastSynMember {
5323                    name_off: n_next,
5324                    type_id: 2,
5325                    byte_offset: 0,
5326                }],
5327            },
5328            CastSynType::Ptr { type_id: 1 },
5329        ];
5330        let blob = cast_build_btf(&types, &strings);
5331        let btf = Btf::from_bytes(&blob).expect("synthetic node BTF parses");
5332        let node_id = 1u32;
5333
5334        const ARENA_BASE: u64 = 0x10_0000_0000;
5335        const ARENA_HI: u64 = 0x10_0010_0000;
5336        // A_i = ARENA_BASE + (i+1) * 0x1000, all distinct, all in window.
5337        // 41 nodes is well past the ~16 levels needed to reach depth 32.
5338        let addr = |i: usize| ARENA_BASE + ((i as u64) + 1) * 0x1000;
5339        let mut bytes_at = std::collections::HashMap::new();
5340        for i in 0..41 {
5341            bytes_at.insert(addr(i), addr(i + 1).to_le_bytes().to_vec());
5342        }
5343        let reader = ChainArenaReader {
5344            arena_window: (ARENA_BASE, ARENA_HI),
5345            bytes_at,
5346        };
5347
5348        // Outer node whose `next` points at A_0.
5349        let outer = addr(0).to_le_bytes().to_vec();
5350        let v = render_value_with_mem(&btf, node_id, &outer, &reader);
5351
5352        // Descend the Struct -> Ptr -> deref chain to the terminal node.
5353        // A Struct yields its sole member's value; a Ptr surfaces its
5354        // skip reason (assert none is a cycle) then descends into its
5355        // deref; an Unsupported is the terminal.
5356        let mut current = &v;
5357        let mut terminal_reason: Option<String> = None;
5358        for _ in 0..200 {
5359            match current {
5360                RenderedValue::Struct { members, .. } => {
5361                    assert_eq!(members.len(), 1, "node has exactly one member");
5362                    current = &members[0].value;
5363                }
5364                RenderedValue::Ptr {
5365                    deref,
5366                    deref_skipped_reason,
5367                    ..
5368                } => {
5369                    if let Some(reason) = deref_skipped_reason {
5370                        assert!(
5371                            !reason.contains("cycle"),
5372                            "distinct-address chain must NOT trip the cycle check: {reason}",
5373                        );
5374                    }
5375                    match deref {
5376                        Some(inner) => current = inner.as_ref(),
5377                        None => {
5378                            // A null/no-deref Ptr terminates the walk
5379                            // without reaching the depth cap — that would
5380                            // be a setup bug (the chain ran dry early).
5381                            panic!(
5382                                "chain terminated at a Ptr with no deref and no depth-cap \
5383                                 Unsupported; skip reason {deref_skipped_reason:?}",
5384                            );
5385                        }
5386                    }
5387                }
5388                RenderedValue::Unsupported { reason } => {
5389                    terminal_reason = Some(reason.clone());
5390                    break;
5391                }
5392                other => panic!("unexpected node in depth-cap chain: {other:?}"),
5393            }
5394        }
5395        assert_eq!(
5396            terminal_reason.as_deref(),
5397            Some("render depth 32 exceeded"),
5398            "the deepest node must be the MAX_RENDER_DEPTH backstop",
5399        );
5400    }
5401
5402    /// A `BTF_KIND_ARRAY` with `nelems == 0` (a `T[]` / `T[0]` flex
5403    /// member) over a non-zero-size element, rendered against a
5404    /// non-empty byte slice, surfaces `Unsupported` with the flex-array
5405    /// reason carrying the available byte count. `CastSynType` has no
5406    /// Array variant, so the array + a u32 element are hand-emitted on
5407    /// the wire (the `tests/` suite uses the same pattern for nelems=3).
5408    #[test]
5409    fn render_array_flex_len_zero_yields_unsupported() {
5410        const BTF_KIND_ARRAY: u32 = 3;
5411        let mut strings: Vec<u8> = vec![0];
5412        let n_u32 = push_name(&mut strings, "u32");
5413
5414        let mut type_section = Vec::new();
5415        // id1: BTF_KIND_INT u32 (size 4).
5416        type_section.extend_from_slice(&n_u32.to_le_bytes());
5417        type_section.extend_from_slice(&((1u32 << 24) & 0x1f00_0000).to_le_bytes());
5418        type_section.extend_from_slice(&4u32.to_le_bytes());
5419        type_section.extend_from_slice(&32u32.to_le_bytes());
5420        // id2: BTF_KIND_ARRAY of id1, nelems = 0 (flex). btf_array
5421        // { type(4), index_type(4), nelems(4) }; the type record's
5422        // size_type word is unused for arrays.
5423        type_section.extend_from_slice(&0u32.to_le_bytes()); // name_off
5424        type_section.extend_from_slice(&((BTF_KIND_ARRAY << 24) & 0x1f00_0000).to_le_bytes());
5425        type_section.extend_from_slice(&0u32.to_le_bytes()); // size_type (unused)
5426        type_section.extend_from_slice(&1u32.to_le_bytes()); // elem type id
5427        type_section.extend_from_slice(&1u32.to_le_bytes()); // index type id
5428        type_section.extend_from_slice(&0u32.to_le_bytes()); // nelems = 0
5429
5430        let type_len = type_section.len() as u32;
5431        let str_len = strings.len() as u32;
5432        let mut blob = Vec::new();
5433        blob.extend_from_slice(&0xEB9Fu16.to_le_bytes());
5434        blob.push(1);
5435        blob.push(0);
5436        blob.extend_from_slice(&24u32.to_le_bytes());
5437        blob.extend_from_slice(&0u32.to_le_bytes());
5438        blob.extend_from_slice(&type_len.to_le_bytes());
5439        blob.extend_from_slice(&type_len.to_le_bytes());
5440        blob.extend_from_slice(&str_len.to_le_bytes());
5441        blob.extend_from_slice(&type_section);
5442        blob.extend_from_slice(&strings);
5443
5444        let btf = Btf::from_bytes(&blob).expect("synthetic flex-array BTF parses");
5445        let arr_id = 2u32;
5446        match render_value(&btf, arr_id, &[0u8; 8]) {
5447            RenderedValue::Unsupported { reason } => {
5448                assert!(
5449                    reason.starts_with("flex array (BTF len=0)")
5450                        && reason.contains("8 bytes available at site"),
5451                    "flex-array reason names the kind and the available bytes: {reason}",
5452                );
5453            }
5454            other => panic!("expected Unsupported flex-array render, got {other:?}"),
5455        }
5456    }
5457
5458    /// `MemReader::alloc_size_types` and
5459    /// `MemReader::resolve_arena_type_meta_fallback` trait defaults: a
5460    /// reader overriding only `read_kva` returns an empty slice and
5461    /// `None` respectively. Pins the defaults that keep every existing
5462    /// reader correct, mirroring the existing
5463    /// `mem_reader_default_resolve_arena_type_is_none` /
5464    /// `mem_reader_default_nr_cpu_ids_is_u32_max` pins.
5465    #[test]
5466    fn mem_reader_default_alloc_size_types_and_meta_fallback_are_empty_and_none() {
5467        struct DefaultReader;
5468        impl MemReader for DefaultReader {
5469            fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
5470                None
5471            }
5472        }
5473        let r = DefaultReader;
5474        assert!(
5475            r.alloc_size_types().is_empty(),
5476            "default alloc_size_types must be empty",
5477        );
5478        assert!(
5479            r.resolve_arena_type_meta_fallback(0x10_0000_1000).is_none(),
5480            "default resolve_arena_type_meta_fallback must be None for an arena-window address",
5481        );
5482        assert!(
5483            r.resolve_arena_type_meta_fallback(0).is_none(),
5484            "default resolve_arena_type_meta_fallback must be None for null too",
5485        );
5486    }
5487}