every objective line shows, the detail follows the row, and 1.98 lints

- a quest objective that carries both a creature and an item is two lines in the quest log, as the reference's four passes enumerate it: Graverobbers shows its Embalming Ichor (2249)
- the quest log's detail follows the live selection inside a frame, so a quest addon's log walk reads the right quest; Questie no longer files a chain quest under its shortest sibling (2247)
- a UI tint change is not a material change: a run's colour rides the batch's instance tag, so the PlayerFrame glow no longer re-prepares a material every frame (2245)
- the workspace lints clean on rustc 1.98: constant-size chunks become as_chunks, one slice fill, two unused globs
- WOW_HOVER_PROBE aims the pick in a headless run, and the inspect card names the tooltip's ladder stage (2248, 2250)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 17:01:27 +07:00
co-authored by Claude Fable 5.1
parent b91ad0a11c
commit f2377ec846
67 changed files with 1245 additions and 464 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ impl CombinedAlphaMap {
/// encoding width and the 63→64 edge fix (vanilla: `false`, `true`).
pub fn new(chunk: &McnkChunk, has_big_alpha: bool, fix_alpha: bool) -> Self {
let mut map = vec![0u8; W * W * 4];
for px in map.chunks_exact_mut(4) {
for px in map.as_chunks_mut::<4>().0 {
px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt)
}
let mut s = Self {
+6 -4
View File
@@ -342,14 +342,16 @@ fn split_strings(blob: &[u8]) -> Vec<String> {
/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID).
fn read_u32_list(data: &[u8], name: &'static str) -> Result<Vec<u32>> {
data.chunks_exact(4)
data.as_chunks::<4>()
.0
.iter()
.map(|c| c.u32_at(0).ok_or(Error::Truncated(name)))
.collect()
}
fn read_mddf(data: &[u8]) -> Result<Vec<DoodadPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(36) {
for r in data.as_chunks::<36>().0 {
out.push(DoodadPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MDDF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MDDF"))?,
@@ -372,7 +374,7 @@ fn read_mddf(data: &[u8]) -> Result<Vec<DoodadPlacement>> {
fn read_modf(data: &[u8]) -> Result<Vec<WmoPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(64) {
for r in data.as_chunks::<64>().0 {
out.push(WmoPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MODF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MODF"))?,
@@ -503,7 +505,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
.filter(|&(_, n)| n > 0)
{
let mut layers = Vec::new();
for l in slice(p, n).chunks_exact(16) {
for l in slice(p, n).as_chunks::<16>().0 {
layers.push(MclyLayer {
texture_id: l.u32_at(0).ok_or(Error::Truncated("MCLY"))?,
flags: MclyFlags {
@@ -1812,7 +1812,6 @@ fn seat_a_session(script: &mut UiScript) {
script.set_quest_log(QuestLogState {
entries: vec![header, in_progress, done],
num_quests: 2,
detail: None,
});
}
@@ -9,7 +9,6 @@ use benilla_formats::{
char_proc_type, CharProc, SpellVisualCatalog, VisualKit, VisualStages, KIT_CHAR_PROCS,
};
use benilla_protocol::messages::ObjectFields;
use bevy::prelude::*;
use super::*;
use crate::creature_anim::SpellVisuals;
@@ -261,7 +261,7 @@ fn collect_quads(
.flat_map(|r| quads.verts[r.start as usize..r.end as usize].iter())
.map(|v| v.pos)
.collect();
for (index, quad) in pos.chunks_exact(4).enumerate() {
for (index, quad) in pos.as_chunks::<4>().0.iter().enumerate() {
let mut corners = [Vec2::ZERO; 4];
let (mut dmin, mut dmax, mut center) = (f32::MAX, f32::MIN, Vec3::ZERO);
let mut behind = false;
+3 -1
View File
@@ -1088,7 +1088,9 @@ fn drive_capture(
if watch.stable >= stable_frames() || capped {
if let Some(px) = watch.prev.as_deref() {
if px
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.all(|p| p[0] == 0 && p[1] == 0 && p[2] == 0)
{
error!(
+46 -2
View File
@@ -155,8 +155,13 @@ pub(super) struct InspectStores<'w, 's> {
/// on the category gate or on something else.
plate_mode: Res<'w, crate::vplates::VPlateMode>,
/// The ask-once GO template cache — the readable head a TEXT object's line reports
/// (decision 1105).
/// (decision 1105), and the highlight column + name the tooltip ladder reports (2246).
go_templates: Res<'w, crate::go_templates::GameObjectTemplates>,
/// **The published GameObject mouseover** — the one the tooltip actually reads
/// ([`crate::target::HoveredObject`]). The card's own pick is a dev pick and does not go
/// through the publish, so without this the card can show an object the game is not hovering
/// at all and give no sign of the difference (2246).
hovered_go: Res<'w, crate::target::HoveredObject>,
/// The GameObject **animation** readout (decision 1151) — what the §243 arm is playing right
/// now, for the card's `anim` line. Its own query rather than a `collision` member because it
/// needs the model components: they sit on the same entity as [`crate::go_anim::GoAnim`], but
@@ -257,6 +262,7 @@ pub(super) fn inspect_ui(
let (reputations, plates, plate_mode) =
(&*stores.reputations, &stores.plates.0, &*stores.plate_mode);
let go_templates = &*stores.go_templates;
let hovered_go = &*stores.hovered_go;
// The picked submesh's shading payload — off the hit entity itself (see the field's doc).
let tag_line = mouseover
.entity
@@ -419,6 +425,44 @@ pub(super) fn inspect_ui(
} else {
"interact ✗"
};
// **The TOOLTIP's own ladder** (decision 2246), which `interact` above is not and is
// routinely mistaken for. "No tooltip on this" has three possible stages and the card
// could name none of them, so every report of it cost a session of code reading:
//
// · `hover ✗` — the **mouseover-eligibility** slot `+0x54`
// ([`crate::target::cursor_mode::mouseover_eligible`]) said no, so the reference
// publishes the NULL mouseover and there is no tooltip *by design*. For a
// GENERIC(5) signpost this is the template's `data[1]` highlight column, which is
// why the `tmpl` field sits beside it.
// · `hover ✓` but `shown ✗` — eligible, and the publish still did not take it: the
// pick lost to the occlusion verdict, to a nearer unit, or to the pointer being
// over UI. The fault is in the pick, not the gate.
// · `shown ✓` and still no plate on screen — the fault is downstream in
// [`crate::ui_tooltip`], and `tmpl` says whether the name it needs has arrived.
//
// `tmpl —` is the one that looks like a bug and is not: the template query is
// ask-once and answers a frame or two later, and until it does the tooltip has no
// name to draw.
let tmpl = go_guid.and_then(|g| go_templates.get(g));
let hover_gate = crate::target::cursor_mode::mouseover_eligible(
s.0.gameobject_type_id(),
flags,
s.0.gameobject_dynamic_flags(),
tmpl.map(|t| t.highlight_column),
reaction,
overrides,
);
let published = go_guid.is_some_and(|g| hovered_go.guid == Some(g));
let tooltip_line = format!(
" · hover {} · shown {} · tmpl {}",
if hover_gate { "✓" } else { "✗" },
if published { "✓" } else { "✗" },
match tmpl {
None => "—".to_string(),
Some(t) if t.name.is_empty() => "(unnamed)".to_string(),
Some(t) => format!("{:?}", t.name),
}
);
// TEXT (type 9) only: the **readable head** (decision 1105). A book that opens no
// window is either "no page in the template" or a fault downstream, and only this
// line tells the two apart — the symptom is identical from the chair, and the first
@@ -454,7 +498,7 @@ pub(super) fn inspect_ui(
.map(|deg| format!(" · tilt {deg:.0}°"))
.unwrap_or_default();
format!(
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text} · {interact}{page_text}{tilt}",
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text} · {interact}{tooltip_line}{page_text}{tilt}",
s.0.gameobject_type_id()
)
});
+3 -1
View File
@@ -629,7 +629,9 @@ fn model_local_collider(hull: &CollisionMesh) -> Option<Collider> {
let verts: Vec<Vec3> = hull.positions.iter().map(|p| wow_to_bevy(*p)).collect();
let tris: Vec<[u32; 3]> = hull
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| [c[0], c[1], c[2]])
.collect();
Some(Collider::trimesh(verts, tris))
+3 -1
View File
@@ -2525,7 +2525,9 @@ mod inside_a_hull {
let verts: Vec<Vec3> = hull.positions.iter().map(|p| wow_to_bevy(*p)).collect();
let tris: Vec<[u32; 3]> = hull
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| [c[0], c[1], c[2]])
.collect();
let mut app = world();
+2 -2
View File
@@ -328,8 +328,8 @@ pub(crate) fn encode_target_readback(shot: &Image) -> Option<Image> {
}
let src = shot.data.as_ref()?;
let mut out = Vec::with_capacity(src.len() / 2);
for texel in src.chunks_exact(8) {
for (c, half_pair) in texel.chunks_exact(2).enumerate() {
for texel in src.as_chunks::<8>().0 {
for (c, half_pair) in texel.as_chunks::<2>().0.iter().enumerate() {
let v = half::f16::from_le_bytes([half_pair[0], half_pair[1]]).to_f32();
// The sRGB transfer function for colour (channel 3 is plain coverage, never encoded) —
// the same curve the swapchain's `…Srgb` write applies to the live frame.
+67 -6
View File
@@ -23,7 +23,69 @@
// and would also move bilinear filtering into gamma (as the reference filters), but it forces the
// booth to emit gamma — a contained follow-up, not a look change.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
#import bevy_sprite::mesh2d_functions as mesh_functions
// The mesh's own vertex layout, at bevy's fixed Mesh2d locations (`Mesh2dPipeline::specialize`
// binds POSITION at 0, UV_0 at 2, COLOR at 4 and defines `VERTEX_UVS` / `VERTEX_COLORS` for the
// attributes the mesh actually carries — the minimap's composite tile quad has no colours).
struct Vertex {
@builtin(instance_index) instance_index: u32,
@location(0) position: vec3<f32>,
#ifdef VERTEX_UVS
@location(2) uv: vec2<f32>,
#endif
#ifdef VERTEX_COLORS
@location(4) color: vec4<f32>,
#endif
}
struct VertexOutput {
// Clip position out of the vertex stage, the FRAGMENT COORDINATE (physical px) into the
// fragment — the screen mask below compares against it.
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
#ifdef VERTEX_COLORS
@location(1) color: vec4<f32>,
#endif
// The instance's whole-run colour, read off bevy's per-instance mesh TAG (`MeshTag`, a u32 the
// mesh uniform carries for every Mesh2d entity, re-uploaded with its transform each frame).
// A run with one colour draws WHITE vertices and carries that colour here — so a colour
// pulse is one component write on the batch entity: not an `Assets<Mesh>` write (which arms
// bevy's asset-changed probes over every `Mesh3d` row in the scene, 1982) and not a material
// write (which re-creates the material's bind group and uniform buffers on the render thread,
// 2–3 of them a frame at a parked pin costing ~9 ms on Intel's DX12 driver, 2236).
//
// Packed as the reference packs a vertex colour: one BYTE per channel — `CImVector`, the
// client's `SetVertexColor` quantising `×255 + 0.5` and folding the frame's alpha in bytes
// (wow-re `system/ui/scratch/texture-color-composition.md`) — so this carries exactly the
// precision the real client draws with. Stored COMPLEMENTED (`!packed`): an entity with no
// `MeshTag` reads 0 from bevy, and the complement makes that opaque white, i.e. untinted,
// rather than transparent black — every Mesh2d that draws with this material and never
// asked for a tint (the minimap's interior tiles) is right by default.
@location(2) @interpolate(flat) tint: vec4<f32>,
}
@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
var out: VertexOutput;
let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index);
let world_position = mesh_functions::mesh2d_position_local_to_world(
world_from_local,
vec4<f32>(vertex.position, 1.0),
);
out.position = mesh_functions::mesh2d_position_world_to_clip(world_position);
#ifdef VERTEX_UVS
out.uv = vertex.uv;
#else
out.uv = vec2<f32>(0.0);
#endif
#ifdef VERTEX_COLORS
out.color = vertex.color;
#endif
// `unpack4x8unorm` reads byte 0 into `.x`: r, g, b, a from the low byte up.
out.tint = unpack4x8unorm(~mesh_functions::get_tag(vertex.instance_index));
return out;
}
@group(2) @binding(0) var<uniform> additive: u32;
@group(2) @binding(1) var quad_texture: texture_2d<f32>;
@@ -110,9 +172,6 @@
// standalone clamped texture of it would. Producers leave it off for UVs that run past `[0,1]` —
// that is the backdrop's tiling idiom, not a cell.
@group(2) @binding(11) var<uniform> uv_clamp: vec4<f32>;
// A whole-run colour multiplier (ONE for pooled materials): a solo quad's colour rides here
// instead of in its vertices, so a per-frame colour pulse is a material write, not a mesh write.
@group(2) @binding(12) var<uniform> tint: vec4<f32>;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
@@ -150,10 +209,12 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let hi = max(uv_clamp.xy, uv_clamp.zw);
let uv = select(in.uv, clamp(in.uv, lo, hi), uv_clamp.xy <= uv_clamp.zw);
let t = textureSample(quad_texture, quad_sampler, uv);
// The quad's colour: its vertex colour (white for a one-colour run) times the run's tint off
// the instance tag (see `VertexOutput::tint`) — a mesh without colours is the tint alone.
#ifdef VERTEX_COLORS
let c = in.color * tint;
let c = in.color * in.tint;
#else
let c = vec4<f32>(1.0);
let c = in.tint;
#endif
// Back to the client's byte space, then tint there: `UiQuad.color` is already a client-space
// sRGB value (FrameXML's `<Color>`, `|cff…`, quality colors), so this multiply IS the FFP's
+2 -2
View File
@@ -1224,8 +1224,8 @@ mod tests {
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(bytes.len() > 44, "{name} has no audio");
let (mut peak, mut over) = (0.0f32, 0u64);
for c in bytes[44..].chunks_exact(4) {
let v = f32::from_le_bytes(c.try_into().unwrap()).abs();
for c in bytes[44..].as_chunks::<4>().0 {
let v = f32::from_le_bytes(*c).abs();
peak = peak.max(v);
over += u64::from(v > 1.0);
}
+7 -2
View File
@@ -351,7 +351,7 @@ impl<F: FnMut(Cycle<'_>) + Send + 'static> Callback<F> {
fn spread<T: SizedSample + FromSample<f32>>(stereo: &[f32], out: &mut [T], channels: usize) {
match channels {
1 => {
for (frame, slot) in stereo.chunks_exact(2).zip(out.iter_mut()) {
for (frame, slot) in stereo.as_chunks::<2>().0.iter().zip(out.iter_mut()) {
*slot = T::from_sample((frame[0] + frame[1]) * 0.5);
}
}
@@ -361,7 +361,12 @@ fn spread<T: SizedSample + FromSample<f32>>(stereo: &[f32], out: &mut [T], chann
}
}
n => {
for (frame, slot) in stereo.chunks_exact(2).zip(out.chunks_exact_mut(n)) {
for (frame, slot) in stereo
.as_chunks::<2>()
.0
.iter()
.zip(out.chunks_exact_mut(n))
{
slot[0] = T::from_sample(frame[0]);
slot[1] = T::from_sample(frame[1]);
for quiet in &mut slot[2..] {
+5 -7
View File
@@ -330,13 +330,11 @@ mod tests {
race: Some(1),
..Default::default()
};
for slot in &mut s.table {
*slot = Slot {
normal,
annoyed,
annoyed_variations: variations,
};
}
s.table.fill(Slot {
normal,
annoyed,
annoyed_variations: variations,
});
s
}
+72 -1
View File
@@ -559,6 +559,11 @@ pub(super) struct GoPickSet<'w, 's> {
/// taken as its own `SystemParam` because [`update_hovered_object`] is at Bevy's 16-param
/// function-system ceiling.
cards: Query<'w, 's, &'static BillboardCard>,
/// The headless probe's frame counter and its say-once report ([`super::hover_probe`], 2250).
/// `Local`s: in a player run the probe is unarmed, so these are one `u64` and one `None` that
/// nothing ever reads.
frame: Local<'s, u64>,
report: Local<'s, super::hover_probe::ProbeReport>,
}
/// The number of `ChildOf` hops [`net_entity_of`] will climb before giving up — a malformed-data
@@ -634,6 +639,7 @@ pub(super) fn update_hovered_object(
// 16-param ceiling): the pick-set cache, its stream edges, and the sticky-hover memory.
mut cache: GoPickSet,
) {
*cache.frame = cache.frame.wrapping_add(1);
let added_parts = &cache.added;
let removed_parts = &mut cache.removed;
let pickable = &mut *cache.cache;
@@ -678,7 +684,13 @@ pub(super) fn update_hovered_object(
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
let Some(cursor) = window.cursor_position() else {
// The aim. A person's pointer always wins; the probe (2250) answers only for a window that has
// no cursor at all, which is every automated run — see [`super::hover_probe`].
let probing = super::hover_probe::armed();
let Some(cursor) = window
.cursor_position()
.or_else(|| super::hover_probe::point(window, *cache.frame))
else {
return;
};
if pickable.is_empty() {
@@ -690,6 +702,38 @@ pub(super) fn update_hovered_object(
};
let self_store = self_q.single().ok();
// The probe's census (2250): once a second, where the pickable GameObjects actually ARE on
// screen. Without it a headless "no hit" is unreadable — it cannot tell a pick fault from a
// camera that is simply not looking at the thing, which is the single question a person with a
// mouse never has to ask.
if probing && (*cache.frame).is_multiple_of(60) {
let rows: Vec<String> = pickable
.iter()
.filter_map(|&e| {
let (_, gt, vis, ..) = parts.get(e).ok()?;
let world = gt.translation();
let screen = camera.world_to_viewport(cam_tf, world).ok();
Some(format!(
"{:?}{} → {}",
resolve_net(e),
if vis.get() { "" } else { " (unseen)" },
screen.map_or_else(
|| "off-camera".to_string(),
|p| format!("{:.0},{:.0}", p.x, p.y)
),
))
})
.take(8)
.collect();
info!(
"hover probe census: window {:.0}x{:.0}, {} pickable — {}",
window.width(),
window.height(),
pickable.len(),
rows.join(" · ")
);
}
// Pass 1 — the exact resident geometry, pure nearest-wins (priority-independent).
let mut best: Option<(f32, Entity)> =
benilla_world::interact::cast_pick_ray(ray, pickable, &parts, false)
@@ -750,6 +794,12 @@ pub(super) fn update_hovered_object(
let picked = best.map(|(t, e)| (t, resolve_net(e)));
*last_pick = picked.map(|(_, net)| net);
let Some((distance, net_entity)) = picked else {
if probing {
cache.report.say(format!(
"aim {cursor:?} — no GameObject hit (pickable {})",
pickable.len()
));
}
return;
};
let Ok(guid) = guids.get(net_entity) else {
@@ -789,8 +839,29 @@ pub(super) fn update_hovered_object(
),
},
) {
if probing {
cache.report.say(format!(
"guid {:#x} type {} at {distance:.1} yd — hover ✗ (tmpl {:?}, highlight {:?}): \
the eligibility gate refused it, so there is no tooltip BY DESIGN",
guid.0,
store.0.gameobject_type_id(),
tmpl.map(|t| t.name.as_str()),
tmpl.map(|t| t.highlight_column),
));
}
return;
}
if probing {
cache.report.say(format!(
"guid {:#x} type {} at {distance:.1} yd — hover ✓, published (tmpl {:?}, highlight \
{:?}, occlusion {:.1})",
guid.0,
store.0.gameobject_type_id(),
tmpl.map(|t| t.name.as_str()),
tmpl.map(|t| t.highlight_column),
occlusion.distance,
));
}
}
hovered.target = Some(net_entity);
hovered.guid = Some(guid.0);
@@ -0,0 +1,145 @@
//! **The headless hover probe** (decision 2250) — aim the mouseover pick from a screen point when
//! the window has no OS cursor of its own, and say what the pick found.
//!
//! ## Why this exists
//!
//! An automated run **cannot hover anything**. The rig's probe window (640×360, corner-parked,
//! always-on-top) never receives the OS cursor: `Window::cursor_position()` stays `None` through a
//! `CGWarpMouseCursorPosition` *and* through HID-level `mouseMoved` events posted into its content
//! rect, so [`super::hover::update_hovered_object`] returns before the pick on every frame. The
//! consequence is not subtle — every report of the shape *"this object shows no tooltip"* has had to
//! be settled by the director hovering it and reading the card back to us, because the pick was
//! reachable only by a person with a mouse (2248 recorded that gap; this closes it).
//!
//! ## What it does
//!
//! `WOW_HOVER_PROBE` names where to aim, in the window's own cursor space (logical px, y-down):
//!
//! | value | aim |
//! |---|---|
//! | `centre` / `center` | the window's middle |
//! | `<x>,<y>` | that point |
//! | `sweep` | a 7×5 grid across the middle half of the window, one point per frame, cycling |
//!
//! `sweep` is the one that makes a rig run useful without a camera: standing a body in front of a
//! known object and asking *"what is anywhere near the middle of the screen"* answers the question
//! a fixed point can only answer if the aim was already right. It is a probe, not a camera search —
//! the grid is fixed, bounded and the same every run.
//!
//! **Armed only when the window reports no cursor**, so an attended run is never overridden: a
//! person's pointer always wins, and leaving the variable set costs a player nothing.
use bevy::prelude::*;
use bevy::window::Window;
/// The aim, parsed once per process.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Aim {
Centre,
At(i32, i32),
Sweep,
}
fn aim() -> Option<Aim> {
static AIM: std::sync::OnceLock<Option<Aim>> = std::sync::OnceLock::new();
*AIM.get_or_init(|| {
let raw = std::env::var("WOW_HOVER_PROBE").ok()?;
let v = raw.trim();
match v {
"centre" | "center" => Some(Aim::Centre),
"sweep" => Some(Aim::Sweep),
_ => match v.split_once(',') {
Some((x, y)) => match (x.trim().parse(), y.trim().parse()) {
(Ok(x), Ok(y)) => Some(Aim::At(x, y)),
_ => {
warn!("hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep` or `x,y`");
None
}
},
None => {
warn!("hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep` or `x,y`");
None
}
},
}
})
}
/// Is the probe armed at all? (Cheap enough to ask per frame — one `OnceLock` read.)
pub(super) fn armed() -> bool {
aim().is_some()
}
/// The point to pick from, given the window and a frame counter — `None` when the probe is not
/// armed. The caller uses it **only** where the real cursor is absent.
pub(super) fn point(window: &Window, frame: u64) -> Option<Vec2> {
let (w, h) = (window.width(), window.height());
match aim()? {
Aim::Centre => Some(Vec2::new(w / 2.0, h / 2.0)),
Aim::At(x, y) => Some(Vec2::new(x as f32, y as f32)),
// The grid: 7 columns × 5 rows over the middle half, so the edges of the frame (chrome,
// sky, the player's own back) are never the answer. One cell per frame, cycling — at frame
// rate the whole grid is covered ~2× a second.
Aim::Sweep => {
const COLS: u64 = 7;
const ROWS: u64 = 5;
let cell = frame % (COLS * ROWS);
let (cx, cy) = (cell % COLS, cell / COLS);
let fx = 0.25 + 0.5 * (cx as f32 / (COLS - 1) as f32);
let fy = 0.25 + 0.5 * (cy as f32 / (ROWS - 1) as f32);
Some(Vec2::new(w * fx, h * fy))
}
}
}
/// One line per *change*, so a stationary probe does not flood the log: what the pick found at the
/// aim point, and every term of the GameObject tooltip ladder the card shows a person (2248) —
/// eligibility, the published mouseover, and the ask-once template the plate needs.
#[derive(Default)]
pub(super) struct ProbeReport {
last: Option<String>,
}
impl ProbeReport {
pub(super) fn say(&mut self, line: String) {
if self.last.as_deref() == Some(line.as_str()) {
return;
}
info!("hover probe: {line}");
self.last = Some(line);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The grid stays inside the middle half on both axes — the property that keeps a sweep off the
/// frame's edges, where the answer is always sky or the player's own back.
#[test]
fn the_sweep_grid_stays_in_the_middle_half() {
let w = 800.0_f32;
let h = 600.0_f32;
for cell in 0..35_u64 {
let (cx, cy) = (cell % 7, cell / 7);
let fx = 0.25 + 0.5 * (cx as f32 / 6.0);
let fy = 0.25 + 0.5 * (cy as f32 / 4.0);
let (x, y) = (w * fx, h * fy);
assert!((w * 0.25..=w * 0.75).contains(&x), "col {cx} at {x}");
assert!((h * 0.25..=h * 0.75).contains(&y), "row {cy} at {y}");
}
}
/// A repeated line is said once — the property that makes the probe readable in a log rather
/// than 60 identical lines a second.
#[test]
fn the_report_only_speaks_on_change() {
let mut r = ProbeReport::default();
r.say("a".into());
assert_eq!(r.last.as_deref(), Some("a"));
r.say("a".into());
assert_eq!(r.last.as_deref(), Some("a"));
r.say("b".into());
assert_eq!(r.last.as_deref(), Some("b"));
}
}
+12
View File
@@ -70,6 +70,18 @@ pub(crate) mod cursor_mode;
mod flash;
mod highlight;
pub(crate) mod hover;
/// The headless hover probe (2250) — see its header.
mod hover_probe;
/// The probe's aim for a window with no OS cursor, for the tooltip's cursor-seated arm (2250).
pub(crate) fn hover_probe_point(window: &bevy::window::Window) -> Option<bevy::math::Vec2> {
hover_probe::point(window, 0)
}
/// Is the headless hover probe armed? (Gates its own log lines outside this module.)
pub(crate) fn hover_probe_armed() -> bool {
hover_probe::armed()
}
pub(crate) mod lock;
mod relations;
mod reticle;
+23 -7
View File
@@ -18,9 +18,9 @@
//! All four drain in `super::feed_actions` into the one sink [`show_messages`], which puts each
//! resolved line on the surface its message record names — read from the catalog
//! ([`benilla_ui::messages`], decision 1770) rather than carried to the call site by hand.
//! Every string comes from the VM's own loaded `GlobalStrings.lua`, never hardcoded, so an
//! absent key shows nothing (the reference's data-suppression face) and localization rides
//! for free.
//! Every string comes from the VM's own loaded `GlobalStrings.lua`, never hardcoded, so
//! localization rides for free. An absent key shows **nothing**, which is a deliberate
//! divergence and not the reference's behaviour — see [`ui_error_text`].
use benilla_ui::messages::MessageRecord;
use benilla_ui::script::{ScriptValue, UiScript};
@@ -278,8 +278,23 @@ impl UiErrorTexts {
/// Resolve one [`UiError`] to its displayed text — `GetText(key)` + the `%s`/`%d` argText
/// substitution ("Requires %s" + "Herbalism" → "Requires Herbalism", cursor-system.md §8.8).
/// `None` (absent or empty key) = show nothing: GlobalStrings data-suppression, faithfully
/// (the ref's own `[record+0x00]` null/empty guard at `0x4967bd`/`0x4967c5`).
/// `None` (the key resolves to nothing, or the filled text is empty) = show nothing — a
/// **NAMED DIVERGENCE**, corrected from a false citation this doc carried until 2246.
///
/// The claim was that this is the reference's own guard at `0x4967bd`/`0x4967c5`. It is not.
/// Those two test the **catalog row's key field** — `0x4967b6 mov ecx,[edx*4 + 0xb4b498]`, the
/// row's `+0x00`, null then first-byte-empty — i.e. whether the *record names a key at all*. In
/// benilla that field is a `&'static str` literal in a generated table, so the guard they model
/// can never fire, and the guard actually implemented here is one the reference does not have:
/// between the resolve (`0x4967d7 call 0x703bf0`) and the dispatch (`0x496842`) there is **no
/// test of the resolved string**. A key the player's `GlobalStrings.lua` does not define resolves
/// to the empty string and the reference emits an **empty** line — `0x49a870`'s own entry test
/// (`0x49a881 test esi,esi`) is on the buffer *pointer*, not its contents.
///
/// Kept because it only bites on a chain missing a stock key, where silence beats a blank line in
/// the log — but kept **named**, because the shape of the old comment (bytes cited for behaviour
/// those bytes do not justify) is the same shape as the `removed_spell` error 2246 corrects.
/// [`keyed_line`] and [`keyed_line_s`] carry the same divergence.
pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
// Through the one shared filler (2045). This used `str::replace`, which fills EVERY `%s`
// with the same argument — latent only because no message on this queue carried two yet.
@@ -359,8 +374,9 @@ impl Shown {
}
/// Resolve a message key against the VM's own `GlobalStrings.lua` into a [`Shown`] — or `None`
/// when the key has no string there, which is the reference's own data-suppression face
/// (`0x4967bd`/`0x4967c5`) and the reason every raise site carries a *key* rather than text.
/// when the key has no string there. Carrying a *key* rather than text at every raise site is the
/// reference's shape; suppressing on an **absent** one is [`ui_error_text`]'s named divergence,
/// not the `0x4967bd`/`0x4967c5` guard this line used to cite.
///
/// Lives here rather than in each window because every keyed raise site needs exactly this and
/// four of them had grown their own copy (decision 1821).
+215 -124
View File
@@ -69,7 +69,7 @@ use bevy::camera::visibility::RenderLayers;
use bevy::camera::CameraOutputMode;
use bevy::image::Image;
use bevy::math::Rect;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::mesh::{Indices, Mesh, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Extent3d,
@@ -380,7 +380,10 @@ struct UiWhiteTexture(Handle<Image>);
/// The quad material: a texture + the blend-mode flag, drawn through one PREMULTIPLIED-alpha
/// pipeline (`ui_quad.wgsl`) so straight-alpha and WoW-ADD quads share the pipeline and differ
/// only per-material. Vertex colors carry the per-quad tint.
/// only per-material. Vertex colors carry the per-quad tint, and a run with ONE colour carries it
/// on the batch entity's [`MeshTag`] instead (see [`tint_tag`]) — so **nothing about a colour is
/// in here**, and a material is its identity key alone: the same texture and flags are the same
/// asset for as long as the image lives, and a steady frame re-prepares none.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub(crate) struct UiQuadMaterial {
#[uniform(0)]
@@ -418,14 +421,34 @@ pub(crate) struct UiQuadMaterial {
/// **per axis**, with `min > max` on an axis disabling that axis. See [`UiQuad::uv_clamp`].
#[uniform(11)]
uv_clamp: Vec4,
/// A whole-run colour multiplier, `ONE` for a pooled material. A run of ONE quad with one
/// colour carries that colour here and draws white vertices (decision 1979): the stock
/// PlayerFrame status glow pulses its colour every frame, and as a vertex colour that was
/// one `Assets<Mesh>` write a frame — which arms bevy's asset-changed probes over every
/// `Mesh3d` row in the scene (~44 k at the Stormwind pin, six material families' worth).
/// A material write arms only the 2D quad family's probe (~110 rows).
#[uniform(12)]
tint: Vec4,
}
/// **A run's one colour, packed for the batch entity's [`MeshTag`]** — the per-instance `u32`
/// bevy's Mesh2d uniform carries and re-uploads with the transform every frame, which
/// `ui_quad.wgsl`'s vertex stage unpacks into the run's tint. A run whose quads all share one
/// colour stores WHITE vertices and puts the colour here, so a colour pulse (the stock
/// PlayerFrame status glow, every frame at rest) is one component write on one entity: not an
/// `Assets<Mesh>` write, which arms bevy's asset-changed probes over every `Mesh3d` row in the
/// scene (1982), and not a material write, which re-creates the material's bind group and
/// uniform buffers on the render thread — 2–3 of them a frame at a parked pin priced at ~9 ms
/// on Intel's DX12 driver (2236).
///
/// One BYTE per channel, `×255 + 0.5`: exactly how the reference packs a vertex colour
/// (`CImVector`; `SetVertexColor` at `0x79abd0`, the frame-alpha fold at `0x77fac0` in bytes —
/// wow-re `system/ui/scratch/texture-color-composition.md`), so this is the precision the real
/// client draws with, not a step down from it. Stored COMPLEMENTED: an entity with no `MeshTag`
/// reads 0 in bevy's extract, and the complement makes 0 unpack to opaque white — untinted —
/// so a Mesh2d that draws with this material and never asked for a tint (the minimap's
/// interior tiles) is right without knowing this exists.
fn tint_tag(color: [f32; 4]) -> u32 {
let byte = |v: f32| {
// Round half up on a clamped value — `0x40a2b0`'s `×255.0 + 0.5` then truncate; the
// clamp is the binding's own `[0, 1]` marshal. The cast cannot overflow after it.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let b = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
b
};
!(byte(color[0]) | byte(color[1]) << 8 | byte(color[2]) << 16 | byte(color[3]) << 24)
}
impl UiQuadMaterial {
@@ -452,13 +475,18 @@ impl UiQuadMaterial {
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
mask: None,
// A tile samples its whole image; there is no cell to stay inside of.
tint: Vec4::ONE,
uv_clamp: UV_CLAMP_OFF,
}
}
}
impl Material2d for UiQuadMaterial {
/// Our own vertex stage, in the same file: bevy's carries no per-instance data past the
/// transform, and the run's tint rides the instance tag (see [`tint_tag`]).
fn vertex_shader() -> ShaderRef {
"embedded://benilla_app/shaders/ui_quad.wgsl".into()
}
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/shaders/ui_quad.wgsl".into()
}
@@ -828,7 +856,10 @@ impl Run {
/// for exactly each `Added`/`Modified` event, and nothing else re-prepares one (2236: two or three
/// such events a frame at a parked pin cost 9.5 traced ms on Intel's DX12 driver, which prices one
/// re-prepared material in milliseconds there). So this count is the render side's material work
/// for the frame, read where it is caused.
/// for the frame, read where it is caused — and since a colour left the material for the
/// instance tag ([`tint_tag`]) it reads ZERO on a parked frame, pulsing glow or not: a line here
/// after the interface has settled is a material being built or rebuilt for something other than
/// a new texture, and that is a regression to find, not a cost to price.
fn count_material_events(
mut events: MessageReader<AssetEvent<UiQuadMaterial>>,
materials: Res<Assets<UiQuadMaterial>>,
@@ -845,7 +876,7 @@ fn count_material_events(
AssetEvent::Added { .. } => added += 1,
AssetEvent::Modified { id } => {
modified += 1;
// The first hundred name themselves: the texture and the tint the write left.
// The first hundred name themselves: the texture the rewritten material binds.
if *named < 100 {
*named += 1;
if let Some(m) = materials.get(*id) {
@@ -854,10 +885,7 @@ fn count_material_events(
.as_ref()
.and_then(|t| server.get_path(t.id()))
.map(|p| p.to_string());
info!(
"[ui-mat] modified {id:?} texture={path:?} tint={:?}",
m.tint
);
info!("[ui-mat] modified {id:?} texture={path:?}");
}
}
}
@@ -877,9 +905,6 @@ fn count_material_events(
/// material asset forever, so `prepare_assets` re-prepares nothing on a steady frame). Before
/// this, every rebuild despawned every batch and allocated fresh meshes + materials — ~9 ms/frame
/// of render-side churn in a live city (0365).
/// A solo run's material with the key and tint it was built for.
type SoloMaterial = (Handle<UiQuadMaterial>, MatKey, [f32; 4]);
#[derive(Default)]
struct BatchPools {
entities: Vec<Entity>,
@@ -897,14 +922,11 @@ struct BatchPools {
/// Goldshire pin).
offsets: Vec<Vec2>,
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
/// Per slot: the solo-run material (key + tint it was built with), `None` while pooled.
solo: Vec<Option<SoloMaterial>>,
/// Solo materials a slot let go of, by key — taken before a fresh one is built, so a run
/// shifting slots (a tooltip opening above it) re-points at a material that exists
/// instead of adding one and dropping one per shifted slot per frame (review 2026-09-04).
solo_free: std::collections::HashMap<MatKey, Vec<SoloMaterial>>,
/// Per slot: the material the batch entity currently carries.
bound: Vec<Option<AssetId<UiQuadMaterial>>>,
/// Per slot: the [`MeshTag`] the batch entity currently carries — the run's one colour
/// ([`tint_tag`]), rewritten only when it moves.
tags: Vec<Option<u32>>,
}
/// One pooled batch slot's full identity: the mesh bytes plus everything the entity was last
@@ -1103,15 +1125,6 @@ fn rebuild_ui_mesh(
.collect();
if !retired.is_empty() {
pools.materials.retain(|key, _| !retired.contains(&key.0));
for slot in &mut pools.solo {
if slot
.as_ref()
.is_some_and(|(_, k, _)| retired.contains(&k.0))
{
*slot = None;
}
}
pools.solo_free.retain(|k, _| !retired.contains(&k.0));
}
let (hidden, mesh_cost, cost_wanted) =
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
@@ -1331,7 +1344,6 @@ fn rebuild_ui_mesh(
// on their next miss.
if pools.materials.len() > 256 {
pools.materials.clear();
pools.solo_free.clear();
}
let mut used = 0usize;
let mut n_rewrites = 0usize;
@@ -1383,12 +1395,17 @@ fn rebuild_ui_mesh(
// GPU mesh allocator's free+realloc each frame (0.86 ms/frame at the Stormwind pin).
// Full content compare, not a hash — a stale batch drawn over a collision would be a
// rendering bug no one could reproduce.
// A solo run (one quad, one colour) hands its colour to the material's `tint` and
// stores white vertices — so a colour pulse is a material write, never a mesh write.
let solo_tint = (run.positions.len() == 4 && run.colors.windows(2).all(|w| w[0] == w[1]))
.then(|| run.colors[0]);
let colors = match solo_tint {
Some(_) => vec![[1.0; 4]; 4],
// A run whose quads all share ONE colour stores white vertices and carries the colour on
// the batch entity's tag ([`tint_tag`]) — so a colour pulse, or a whole string fading,
// is a component write: never a mesh write, never a material write.
let one_colour = run
.colors
.first()
.filter(|&&c| run.colors.iter().all(|&o| o == c))
.copied();
let tag = tint_tag(one_colour.unwrap_or([1.0; 4]));
let colors = match one_colour {
Some(_) => vec![[1.0; 4]; run.colors.len()],
None => run.colors,
};
let stored = StoredRun {
@@ -1399,92 +1416,33 @@ fn rebuild_ui_mesh(
z_bits: z.to_bits(),
key,
};
while pools.solo.len() <= used {
pools.solo.push(None);
while pools.bound.len() <= used {
pools.bound.push(None);
pools.tags.push(None);
}
let material_handle = match solo_tint {
Some(tint) => {
let fresh = |materials: &mut Assets<UiQuadMaterial>| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
tint: Vec4::from_array(tint),
})
};
let BatchPools {
solo, solo_free, ..
} = &mut *pools;
match &mut solo[used] {
Some((handle, k, t)) if *k == key => {
if *t != tint {
if let Some(m) = materials.get_mut(&*handle) {
m.tint = Vec4::from_array(tint);
}
*t = tint;
}
handle.clone()
}
slot => {
if let Some(old) = slot.take() {
solo_free.entry(old.1).or_default().push(old);
}
let reused =
solo_free
.get_mut(&key)
.and_then(Vec::pop)
.map(|(handle, k, t)| {
if t != tint {
if let Some(m) = materials.get_mut(&handle) {
m.tint = Vec4::from_array(tint);
}
}
(handle, k, tint)
});
let (handle, k, t) =
reused.unwrap_or_else(|| (fresh(materials), key, tint));
*slot = Some((handle.clone(), k, t));
handle
}
}
}
None => {
if let Some(old) = pools.solo[used].take() {
pools.solo_free.entry(old.1).or_default().push(old);
}
pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
tint: Vec4::ONE,
})
})
.clone()
}
};
let material_handle = pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
})
})
.clone();
// The pan gate (1463): a run that matches its slot's base up to one constant XY delta
// moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming.
// `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the
// `Transform` write is skipped unless a previous pan is being undone.
// `Transform` write is skipped unless a previous pan is being undone. The colour rides
// the same way: a moved tag is one component write on the slot's entity.
let pan = pools
.stored
.get(used)
@@ -1498,6 +1456,12 @@ fn rebuild_ui_mesh(
.insert(MeshMaterial2d(material_handle.clone()));
}
}
if pools.tags[used] != Some(tag) {
pools.tags[used] = Some(tag);
if let Some(&entity) = pools.entities.get(used) {
commands.entity(entity).insert(MeshTag(tag));
}
}
if pools.offsets[used] != d {
pools.offsets[used] = d;
if let Some(&entity) = pools.entities.get(used) {
@@ -1581,13 +1545,15 @@ fn rebuild_ui_mesh(
}
};
pools.bound[used] = Some(material_handle.id());
pools.tags[used] = Some(tag);
// Reuse the batch entity at this slot — same Mesh2d handle (the pooled asset), a material
// handle that only changes when the run's identity does, a fresh z.
// handle that only changes when the run's identity does, the run's colour tag, a fresh z.
match pools.entities.get(used) {
Some(&entity) => {
commands.entity(entity).insert((
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
MeshTag(tag),
Transform::from_xyz(0.0, 0.0, z),
));
}
@@ -1597,6 +1563,7 @@ fn rebuild_ui_mesh(
UiQuadBatch,
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
MeshTag(tag),
Transform::from_xyz(0.0, 0.0, z),
ui_render_layers(),
))
@@ -1959,6 +1926,130 @@ mod tests {
);
}
/// **The tag is the reference's byte colour, complemented.** `SetVertexColor` quantises
/// `×255 + 0.5` into one byte per channel (wow-re `texture-color-composition.md`), and the
/// complement is what makes bevy's untagged 0 read as opaque white (the minimap's interior
/// tiles never set one).
#[test]
fn tint_tag_is_the_reference_byte_colour_complemented() {
assert_eq!(tint_tag([1.0; 4]), 0, "white is the untagged default");
assert_eq!(
tint_tag([0.0; 4]),
u32::MAX,
"transparent black is a real colour, not a missing tag"
);
// Gold at half alpha: 255, 224 (224.9 truncated), 64 (64.25), 128 (128.0) — the bytes the
// reference packs, r in the low byte the way `unpack4x8unorm` reads them back.
assert_eq!(
tint_tag([1.0, 0.88, 0.25, 0.5]),
!(255 | 224 << 8 | 64 << 16 | 128 << 24)
);
assert_eq!(
tint_tag([2.0, -1.0, 0.5, 1.0]),
!(255 | 128 << 16 | 255 << 24),
"clamped to [0, 1] first, as the Lua binding clamps"
);
}
/// **A colour pulse is one component write** — no `Assets<Mesh>` write and no material
/// write (decision 2236's gate: the `WOW_UI_COST=1` material-event count must be ZERO on a
/// parked frame with a pulsing element, not merely cheaper). The stock PlayerFrame status
/// glow pulses its alpha every frame at rest; on Intel's DX12 driver each material it
/// re-prepared cost milliseconds, and as a vertex colour it had cost an asset-changed sweep
/// over every mesh in the scene. So the colour goes on the batch entity's `MeshTag`, the
/// mesh stays white and untouched, and the material — its identity alone — is never
/// rewritten.
#[test]
fn a_colour_pulse_writes_the_tag_and_neither_asset() {
let mut app = rebuild_app();
// The rebuild's own meter is off unless asked (a player's frame pays one bool for it);
// this test reads its `rewrites`, so it asks.
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
assert_eq!(batches(&mut app), 1);
// Read through a cursor, not the "current update" buffer: `TimePlugin` only lets the
// message buffers swap after a FIXED step (`signal_message_update_system`), and this
// harness ticks faster than one, so frame 1's events are still "current" on frame 2.
let mut cursor = bevy::ecs::message::MessageCursor::<AssetEvent<UiQuadMaterial>>::default();
let mut material_events = |app: &App| {
let (mut added, mut modified) = (0, 0);
for e in cursor.read(
app.world()
.resource::<Messages<AssetEvent<UiQuadMaterial>>>(),
) {
match e {
AssetEvent::Added { .. } => added += 1,
AssetEvent::Modified { .. } => modified += 1,
_ => {}
}
}
(added, modified)
};
assert_eq!(
material_events(&app),
(1, 0),
"the first frame builds the run's one material (and this harness hears it)"
);
let (batch, tag0) = app
.world_mut()
.query_filtered::<(Entity, &MeshTag), With<UiQuadBatch>>()
.single(app.world())
.expect("one batch");
assert_eq!(
**tag0,
tint_tag([1.0; 4]),
"a white quad carries the white tag"
);
// The pulse: the same quad, another colour.
let gold = [1.0, 0.88, 0.25, 0.5];
{
let mut q = app.world_mut().resource_mut::<UiQuads>();
q.quads[0].color = gold;
q.dirty = true;
}
app.update();
let cost = app.world().resource::<UiMeshCost>();
assert!(cost.rebuilt, "the dirty flag ran the rebuild");
assert_eq!(
cost.rewrites, 0,
"no pooled mesh was rewritten for a colour"
);
assert_eq!(
material_events(&app),
(0, 0),
"no material was built or rebuilt for a colour — the count 2236 gates on"
);
assert_eq!(
app.world().resource::<Assets<UiQuadMaterial>>().len(),
1,
"still the one material: colour is not part of its identity"
);
let tag = app
.world()
.get::<MeshTag>(batch)
.expect("the batch keeps its tag");
assert_eq!(**tag, tint_tag(gold), "the colour went to the instance tag");
let mesh_handle = app.world().get::<Mesh2d>(batch).expect("the batch's mesh");
let mesh = app
.world()
.resource::<Assets<Mesh>>()
.get(&mesh_handle.0)
.expect("the pooled mesh");
let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) =
mesh.attribute(Mesh::ATTRIBUTE_COLOR)
else {
panic!("the run's vertex colours are Float32x4");
};
assert!(
colors.iter().all(|c| *c == [1.0; 4]),
"the vertices stay white: the colour is the tag's, not the mesh's"
);
}
// The mirror-preservation guarantee, and the reason [`UvRect`] exists instead of
// `bevy::math::Rect`: PlayerFrameTexture's `<TexCoords left="1.0" right="0.09375">`
// (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners`
-2
View File
@@ -2,8 +2,6 @@
//! fixtures (a packed word, a slot view, a `PetSpells` state) and splitting them per module would
//! fork those three ways.
use bevy::prelude::*;
use benilla_protocol::messages::{
PetActionEntry, PetSpells, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE,
PET_ACT_REACTION, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW,
+287 -140
View File
@@ -158,17 +158,12 @@ impl Plugin for UiQuestLogPlugin {
}
}
/// One leaderboard line for a template objective slot — `None` when the slot carries no content (an
/// unused quad; `SMSG_QUEST_QUERY_RESPONSE`'s objective array is a fixed 4 regardless of how many
/// the quest actually uses — the wire pin). Pure: the caller has already resolved the creature/item
/// name (ask-once — `None` while the answer is in flight) and the live progress (`counter` — the
/// descriptor slot's 6-bit counter, meaningful only for a creature/GO objective; `bag_count` — the
/// live backpack+bags count, meaningful only for an item objective — [`crate::ui_items::count_of`]).
///
/// The custom `text` field, when non-empty, REPLACES the auto-generated name in the line but keeps
/// the "cur/req" suffix — CONFIRMED since (wow-re `ui/scratch/quest-leaderboard-law.md` §5: a
/// creature objective carrying `ObjectiveText[i]` formats with `QUEST_OBJECTS_FOUND`, not the
/// "slain" key, and that text IS the name — while `type` still answers `"monster"`).
/// The name a still-in-flight cache miss wears. The reference's miss reads `" "` (a single space,
/// `0x82ee00`, wow-re `ui/scratch/quest-leaderboard-law.md` §5); ours reads this, a longer-standing
/// divergence that says "still in flight" rather than looking like a nameless objective.
const NAME_PLACEHOLDER: &str = "...";
/// Format one leaderboard line from its `%s`/`%d`/`%d` key — the shared tail of both passes below.
///
/// **The three format keys are the leaderboard's own, and are NOT the progress toast's** (decision
/// 2045's central trap: `QUEST_MONSTERS_KILLED` and `ERR_QUEST_ADD_KILL_SII` are the same enUS
@@ -182,83 +177,102 @@ impl Plugin for UiQuestLogPlugin {
/// A key the install does not carry renders the line with **empty text rather than no line**: the
/// reference's `FrameScript_GetText` hands back its pre-seeded empty string and the leaderboard
/// entry still exists, which is what `GetNumQuestLeaderBoards` counts.
fn leaderboard_text(
// The VM's own `GlobalStrings.lua` (decision 2045).
get: &dyn Fn(&str) -> Option<String>,
key: &str,
name: &str,
cur: u32,
req: u32,
) -> String {
get(key).map_or_else(String::new, |f| {
fill(
&f,
&[Arg::S(name), Arg::D(i64::from(cur)), Arg::D(i64::from(req))],
)
})
}
/// One creature/GameObject leaderboard line — the reference's **pass 2**, whose predicate is
/// `ReqCreatureOrGOId[i] != 0` alone (`0x4e00c0`/`0x4e02d7`). Pure: the caller has already resolved
/// the creature name (ask-once — `None` while the answer is in flight) and `counter`, the
/// descriptor slot's 6-bit counter for this array index.
///
/// **Known-wrong (decision 1156, not yet corrected):** `finished` must be `cur >= req` and NOTHING
/// else. The whole-quest COMPLETE bit is read exactly once in the reference binding, by the `event`
/// line, where it is that line's only predicate (`0x4e02a2`); or-ing it in here marks every
/// objective finished the moment the quest completes. The fix must land WITH [`count_of`]'s scope
/// widening (1156) — alone, it would render a banked-item quest's lines unfinished under a
/// COMPLETE quest.
fn objective_line(
/// The custom `text` field, when non-empty, REPLACES the auto-generated name in the line but keeps
/// the "cur/req" suffix (wow-re §5: a creature objective carrying `ObjectiveText[i]` formats with
/// `QUEST_OBJECTS_FOUND`, not the "slain" key, and that text IS the name — while `type` still
/// answers `"monster"`, read from a fresh sign test of the id).
fn creature_line(
obj: &QuestObjective,
counter: u8,
creature_name: Option<&str>,
item_name: Option<&str>,
bag_count: u32,
// The VM's own `GlobalStrings.lua` (decision 2045).
get: &dyn Fn(&str) -> Option<String>,
) -> Option<QuestLogObjectiveView> {
// The reference's cache miss reads `" "` (`0x82ee00`); ours reads this, a longer-standing
// divergence that says "still in flight" rather than looking like a nameless objective.
const PLACEHOLDER: &str = "...";
let line = |key: &str, name: &str, cur: u32, req: u32| {
get(key).map_or_else(String::new, |f| {
fill(
&f,
&[Arg::S(name), Arg::D(i64::from(cur)), Arg::D(i64::from(req))],
)
})
if obj.creature_or_go == 0 {
return None;
}
let req = obj.required_count;
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
let cur = u32::from(counter).min(req);
// §5's fork, in its own order: a non-empty override IS the name and takes
// `QUEST_OBJECTS_FOUND` on either kind; a GameObject takes that key on both its legs (the
// binding fetches it once, above its cache test, at `0x4e031e`); only a plain creature reaches
// `QUEST_MONSTERS_KILLED`. No gameobject-name cache yet — a later CMSG_GAMEOBJECT_QUERY slice
// — so a GO with no override wears the placeholder.
let (key, name) = if !obj.text.is_empty() {
("QUEST_OBJECTS_FOUND", obj.text.as_str())
} else if is_go {
("QUEST_OBJECTS_FOUND", NAME_PLACEHOLDER)
} else {
(
"QUEST_MONSTERS_KILLED",
creature_name.unwrap_or(NAME_PLACEHOLDER),
)
};
if obj.creature_or_go != 0 && obj.required_count > 0 {
let req = obj.required_count;
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
let cur = u32::from(counter).min(req);
let kind = if is_go { "object" } else { "monster" };
// §5's fork, in its own order: a non-empty override IS the name and takes
// `QUEST_OBJECTS_FOUND` on either kind; a GameObject takes that key on both its legs
// (the binding fetches it once, above its cache test, at `0x4e031e`); only a plain
// creature reaches `QUEST_MONSTERS_KILLED`. No gameobject-name cache yet — a later
// CMSG_GAMEOBJECT_QUERY slice — so a GO with no override wears the placeholder.
let (key, name) = if !obj.text.is_empty() {
("QUEST_OBJECTS_FOUND", obj.text.as_str())
} else if is_go {
("QUEST_OBJECTS_FOUND", PLACEHOLDER)
} else {
(
"QUEST_MONSTERS_KILLED",
creature_name.unwrap_or(PLACEHOLDER),
)
};
return Some(QuestLogObjectiveView {
text: line(key, name, cur, req),
kind: kind.into(),
finished: cur >= req,
Some(QuestLogObjectiveView {
text: leaderboard_text(get, key, name, cur, req),
kind: if is_go { "object" } else { "monster" }.into(),
finished: cur >= req,
cur,
req,
})
}
/// One item ("collect") leaderboard line — the reference's **pass 3**, whose predicate is
/// `ReqItemId[i] > 0 && ReqItemId[i] != SrcItemId` (`0x4e04f4`–`0x4e04fd`): the `+0x34` test drops
/// the quest's own provided/starter item from the board. `bag_count` is the live inventory walk
/// ([`crate::ui_items::count_of`]) — item progress is NOT one of the slot's 6-bit counters, the
/// client counts bags itself.
///
/// **The name comes from the item cache alone.** The per-objective override buffer
/// (`template+0x14dc + i*0x100`) is read only by the creature and GameObject branches (§5), and
/// on a quad that carries both kinds that buffer belongs to the *creature* objective — reading it
/// here would label the item with the creature's text.
fn item_line(
obj: &QuestObjective,
src_item_id: u32,
item_name: Option<&str>,
bag_count: u32,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<QuestLogObjectiveView> {
if obj.item_id == 0 || obj.item_id == src_item_id {
return None;
}
let req = obj.item_count;
let cur = bag_count.min(req);
Some(QuestLogObjectiveView {
text: leaderboard_text(
get,
"QUEST_ITEMS_NEEDED",
item_name.unwrap_or(NAME_PLACEHOLDER),
cur,
req,
});
}
if obj.item_id != 0 && obj.item_count > 0 {
let req = obj.item_count;
let cur = bag_count.min(req);
// Pre-existing divergence, preserved rather than quietly changed here: §5's item row
// resolves its name from the **item cache alone** — the per-objective override buffer
// (`template+0x14dc + i*0x100`) is read only by the creature and GameObject branches — so
// the reference would show the item's name where this shows `ObjectiveText[i]`. The FORMAT
// key is `QUEST_ITEMS_NEEDED` either way, which is what this conversion is about.
let name = if obj.text.is_empty() {
item_name.unwrap_or(PLACEHOLDER)
} else {
obj.text.as_str()
};
return Some(QuestLogObjectiveView {
text: line("QUEST_ITEMS_NEEDED", name, cur, req),
kind: "item".into(),
finished: cur >= req,
cur,
req,
});
}
None
),
kind: "item".into(),
finished: cur >= req,
cur,
req,
})
}
/// Split a `SMSG_QUEST_QUERY_RESPONSE` `money` value into (required, reward) — negative demands
@@ -310,9 +324,18 @@ fn resolve_template_item(
/// Build one occupied slot's objective ("leaderboard") lines from its template + this frame's
/// descriptor slot counters/bag counts — every entry gets these (`GetNumQuestLeaderBoards`/
/// `GetQuestLogLeaderBoard` answer ANY index, not just the selection: the watch tracker HUD reads
/// non-selected quests' lines, ref `QuestLogFrame.lua:613-663`). Pulled out of [`build_detail`]
/// (which used to build this only for the selection) so the per-frame loop below can call it once
/// per occupied slot.
/// non-selected quests' lines, ref `QuestLogFrame.lua:613-663`).
///
/// **The passes are independent walks of the whole array, not one walk of merged quads** — the
/// reference enumerates `event`, then creature/GO over `ReqCreatureOrGOId[0..3]`, then item over
/// `ReqItemId[0..3]`, then reputation (wow-re `ui/scratch/quest-leaderboard-law.md` §4, VERIFIED;
/// decision 1156 §5 named the ORDER half of this and its worse half went unnoticed). Our merged
/// walk emitted at most ONE line per index, so an objective quad carrying BOTH kinds lost its item
/// line outright: quest 358 "Graverobbers" (creature 1941 ×8 and item 2834 ×8, both at index 0)
/// showed 2 lines where the reference shows 3, and `GetNumQuestLeaderBoards` under-counted with it.
///
/// Still not emitted, and still gaps rather than errors (1156 §4): the `event` line and the
/// reputation line.
#[allow(clippy::too_many_arguments)]
fn build_objectives(
template: &QuestTemplate,
@@ -321,10 +344,13 @@ fn build_objectives(
items: &mut Items,
names: &mut NameCache,
commands: &NetCommands,
// The VM's own `GlobalStrings.lua`, for [`objective_line`]'s three format keys.
// The VM's own `GlobalStrings.lua`, for the leaderboard's three format keys.
get: &dyn Fn(&str) -> Option<String>,
) -> Vec<QuestLogObjectiveView> {
let mut objectives = Vec::with_capacity(template.objectives.len());
// Pass 2 — creature/GameObject. The counter index is this array index (1156: the reference
// increments it per array ELEMENT, including elements that produce no line, which is what makes
// `log_slot.counters[i]` the right read over the fixed 4).
for (i, obj) in template.objectives.iter().enumerate() {
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
let creature_name = (obj.creature_or_go != 0 && !is_go)
@@ -334,22 +360,23 @@ fn build_objectives(
.map(str::to_string)
})
.flatten();
let item_name = (obj.item_id != 0)
.then(|| {
items
.template(obj.item_id, 0, commands)
.map(|t| t.name.clone())
})
.flatten();
let bag_count = if obj.item_id != 0 {
count_of(store, items, obj.item_id, InventoryScope::QUEST_ITEMS)
} else {
0
};
if let Some(line) = objective_line(
if let Some(line) = creature_line(obj, log_slot.counters[i], creature_name.as_deref(), get)
{
objectives.push(line);
}
}
// Pass 3 — item.
for obj in template.objectives.iter() {
if obj.item_id == 0 {
continue;
}
let item_name = items
.template(obj.item_id, 0, commands)
.map(|t| t.name.clone());
let bag_count = count_of(store, items, obj.item_id, InventoryScope::QUEST_ITEMS);
if let Some(line) = item_line(
obj,
log_slot.counters[i],
creature_name.as_deref(),
template.src_item_id,
item_name.as_deref(),
bag_count,
get,
@@ -360,8 +387,8 @@ fn build_objectives(
objectives
}
/// Build the selected quest's detail pane from its template — description/money/rewards/choices
/// only (the objective lines live on every [`QuestLogEntryView::objectives`] now, selection or
/// Build a row's detail pane from its template — description/money/rewards/choices
/// only (the objective lines live on every [`QuestLogEntryView::objectives`], selection or
/// not — see [`build_objectives`]), so this no longer needs the descriptor slot at all.
fn build_detail(
template: &QuestTemplate,
@@ -657,6 +684,14 @@ fn feed_quest_log(
};
let groups = ordered;
// Above the row loop because every row's detail substitutes through it now (the chat macros
// in a quest's description/objectives paragraph), not just the selection's.
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let macros = crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
};
let mut entries: Vec<QuestLogEntryView> = Vec::new();
let mut entry_slots: Vec<Option<u8>> = Vec::new();
let mut header_keys: Vec<Option<String>> = Vec::new();
@@ -673,6 +708,7 @@ fn feed_quest_log(
timer: 0, // a header row is not a quest and never carries a timer
pushable: false, // nor is it shareable — there is no quest under the row
objectives: Vec::new(),
detail: None, // a header names a zone/sort; there is no quest to describe
});
entry_slots.push(None);
header_keys.push(Some(name.clone()));
@@ -681,7 +717,7 @@ fn feed_quest_log(
if collapsed {
continue; // folded: the quest stays in the log, just not in the visible list
}
let (title, level, tag, pushable, objectives) =
let (title, level, tag, pushable, objectives, detail) =
match quest_log.template(r.quest_id, &commands) {
Some(t) => (
t.title.clone(),
@@ -710,6 +746,17 @@ fn feed_quest_log(
&commands,
&get,
),
// Every row, not just the selection (decision 2247) — the detail bindings
// resolve the live selection against these at call time, the way the
// reference peeks its quest cache inside the call.
Some(build_detail(
t,
&mut items,
icons.as_deref(),
&commands,
&macros,
spells.as_deref(),
)),
),
// Unreachable: the in-flight gate above emptied `rows` unless EVERY template is
// cached, which is the reference's own all-or-nothing rebuild. This arm used to
@@ -738,6 +785,7 @@ fn feed_quest_log(
// (decision 1150).
timer: r.log_slot.timer,
objectives,
detail,
});
entry_slots.push(Some(r.slot));
header_keys.push(None);
@@ -753,34 +801,9 @@ fn feed_quest_log(
quest_log.entry_slots = entry_slots;
quest_log.header_keys = header_keys;
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let macros = crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
};
let sel = script.quest_log_selection() as usize;
let detail = sel
.checked_sub(1)
.and_then(|i| entries.get(i))
.filter(|e| !e.is_header)
.map(|e| e.quest_id)
.and_then(|quest_id| {
quest_log.template(quest_id, &commands).map(|t| {
build_detail(
t,
&mut items,
icons.as_deref(),
&commands,
&macros,
spells.as_deref(),
)
})
});
let fresh = QuestLogState {
entries,
num_quests: rows.len() as u32,
detail,
};
if fresh == *last {
return;
@@ -1129,6 +1152,7 @@ mod tests {
fn header(title: &str) -> QuestLogEntryView {
QuestLogEntryView {
detail: None,
quest_id: 0,
title: title.into(),
level: 0,
@@ -1144,6 +1168,7 @@ mod tests {
fn quest(id: u32) -> QuestLogEntryView {
QuestLogEntryView {
detail: None,
quest_id: id,
title: format!("Quest {id}"),
level: 1,
@@ -1208,7 +1233,7 @@ mod tests {
}
}
// ── objective_line ──────────────────────────────────────────────────────────────────────────
// ── creature_line / item_line ───────────────────────────────────────────────────────────────
/// A stand-in string table whose three templates are **deliberately not** the shipped wording.
///
@@ -1231,7 +1256,7 @@ mod tests {
#[test]
fn creature_objective_with_resolved_name() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 3, Some("Kobold Vermin"), &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED Kobold Vermin 3 of 10>");
assert_eq!(line.kind, "monster");
assert!(!line.finished);
@@ -1240,7 +1265,7 @@ mod tests {
#[test]
fn creature_objective_in_flight_shows_a_placeholder() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 0, None, None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 0, None, &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED ... 0 of 10>");
}
@@ -1252,7 +1277,7 @@ mod tests {
fn go_objective_falls_back_without_a_name_cache() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 1, 0, 0, "");
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 1, None, &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND ... 1 of 1>");
assert_eq!(line.kind, "object");
assert!(line.finished); // cur (1) >= req (1)
@@ -1262,7 +1287,7 @@ mod tests {
fn go_objective_prefers_its_custom_text_over_the_fallback() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 4, 0, 0, "Destroy the barricade");
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 1, None, &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND Destroy the barricade 1 of 4>");
assert_eq!(line.kind, "object");
}
@@ -1271,7 +1296,7 @@ mod tests {
fn item_objective_counts_bags_and_clamps_to_required() {
let o = obj(0, 0, 2000, 5, "");
// 8 in the bags, but required is only 5 — the line clamps, doesn't overshoot.
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8, &probe_strings).unwrap();
let line = item_line(&o, 0, Some("Kobold Ear"), 8, &probe_strings).unwrap();
assert_eq!(line.text, "<NEEDED Kobold Ear 5 of 5>");
assert_eq!(line.kind, "item");
assert!(line.finished);
@@ -1280,7 +1305,7 @@ mod tests {
#[test]
fn item_objective_in_flight_shows_a_placeholder() {
let o = obj(0, 0, 2000, 5, "");
let line = objective_line(&o, 0, None, None, 2, &probe_strings).unwrap();
let line = item_line(&o, 0, None, 2, &probe_strings).unwrap();
assert_eq!(line.text, "<NEEDED ... 2 of 5>");
}
@@ -1292,7 +1317,7 @@ mod tests {
#[test]
fn custom_text_overrides_the_creature_auto_line() {
let o = obj(100, 10, 0, 0, "Slay the vermin");
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 4, Some("Kobold Vermin"), &probe_strings).unwrap();
assert_eq!(line.text, "<FOUND Slay the vermin 4 of 10>");
assert_eq!(line.kind, "monster");
}
@@ -1324,7 +1349,7 @@ mod tests {
assert!(get(key).is_some(), "{key} missing");
}
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &get).unwrap();
let line = creature_line(&o, 3, Some("Kobold Vermin"), &get).unwrap();
assert_eq!(line.text, "Kobold Vermin slain: 3/10");
}
@@ -1338,7 +1363,7 @@ mod tests {
let o = obj(100, 10, 0, 0, "");
// The counter (1) is well short of required (10). Whatever the quest's state byte says,
// THIS line is not finished.
let line = objective_line(&o, 1, Some("Kobold"), None, 0, &probe_strings).unwrap();
let line = creature_line(&o, 1, Some("Kobold"), &probe_strings).unwrap();
assert_eq!(line.text, "<KILLED Kobold 1 of 10>");
assert!(!line.finished);
}
@@ -1346,7 +1371,8 @@ mod tests {
#[test]
fn empty_objective_slot_emits_no_line() {
let o = obj(0, 0, 0, 0, "");
assert!(objective_line(&o, 0, None, None, 0, &probe_strings).is_none());
assert!(creature_line(&o, 0, None, &probe_strings).is_none());
assert!(item_line(&o, 0, None, 0, &probe_strings).is_none());
}
// ── build_objectives: every occupied slot gets its own lines, not just the selection ────────────
@@ -1414,7 +1440,7 @@ mod tests {
// The two unused quads emit nothing; the two real ones resolve in order — creature/item
// names are unresolved (ask-once mid-flight, no answer seeded) so they fall to the same
// placeholder path `objective_line`'s own tests already cover directly.
// placeholder path the line builders' own tests already cover directly.
assert_eq!(objectives.len(), 2);
assert_eq!(objectives[0].text, "<KILLED ... 3 of 10>");
assert_eq!(objectives[0].kind, "monster");
@@ -1423,6 +1449,128 @@ mod tests {
assert_eq!(objectives[1].kind, "item");
}
/// **The bug this split exists for** (director, 2026-09-15; wow-re
/// `ui/scratch/quest-leaderboard-law.md` §4): the reference walks `ReqCreatureOrGOId[0..3]` and
/// `ReqItemId[0..3]` as two INDEPENDENT passes, so one objective quad carrying both kinds
/// produces two lines. Merging them per quad returned on the creature branch and dropped the
/// item outright.
///
/// The fixture is quest 358 "Graverobbers"' own shape — `ReqCreatureOrGOId1` 1941 ×8 and
/// `ReqItemId1` 2834 ×8 both at index 0, plus a second creature at index 1 — which showed 2
/// lines where the reference shows 3.
#[test]
fn a_quad_carrying_both_a_creature_and_an_item_emits_both_lines() {
let template = quest_template([
obj(1941, 8, 2834, 8, ""), // BOTH kinds, same index
obj(1675, 5, 0, 0, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
]);
let log_slot = QuestLogSlot {
quest_id: 358,
counters: [8, 1, 0, 0],
state: 0,
timer: 0,
};
let store = ObjectFields::default();
let mut items = Items::default();
let mut names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let objectives = build_objectives(
&template,
&log_slot,
&store,
&mut items,
&mut names,
&commands,
&probe_strings,
);
// Three lines, in the reference's pass order: both creatures, THEN the item — not the
// quad order, which would interleave them.
assert_eq!(objectives.len(), 3, "{objectives:#?}");
assert_eq!(objectives[0].text, "<KILLED ... 8 of 8>");
assert_eq!(objectives[1].text, "<KILLED ... 1 of 5>");
assert_eq!(objectives[2].text, "<NEEDED ... 0 of 8>");
assert_eq!(objectives[2].kind, "item");
}
/// The override buffer belongs to the CREATURE branch (§5) — on a shared quad it must not leak
/// into the item line, which takes its name from the item cache alone. The split made this
/// reachable for the first time, so it is asserted rather than assumed.
#[test]
fn a_shared_quads_objective_text_names_the_creature_not_the_item() {
let template = quest_template([
obj(1941, 8, 2834, 8, "Graverobbers routed"),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
]);
let log_slot = QuestLogSlot {
quest_id: 358,
counters: [8, 0, 0, 0],
state: 0,
timer: 0,
};
let store = ObjectFields::default();
let mut items = Items::default();
let mut names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let objectives = build_objectives(
&template,
&log_slot,
&store,
&mut items,
&mut names,
&commands,
&probe_strings,
);
assert_eq!(objectives.len(), 2);
// The override IS the creature's name, and flips its key to QUEST_OBJECTS_FOUND (§5).
assert_eq!(objectives[0].text, "<FOUND Graverobbers routed 8 of 8>");
// The item line wears the in-flight placeholder, never the creature's text.
assert_eq!(objectives[1].text, "<NEEDED ... 0 of 8>");
}
/// `ReqItemId[i] != SrcItemId` (`0x4e00ce`/`0x4e04fa`): the quest's own provided/starter item
/// is excluded from the board even when it also sits in the required array.
#[test]
fn the_quests_own_source_item_is_not_a_leaderboard_line() {
let mut template = quest_template([
obj(0, 0, 2834, 8, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
obj(0, 0, 0, 0, ""),
]);
template.src_item_id = 2834;
let log_slot = QuestLogSlot {
quest_id: 1,
counters: [0; 4],
state: 0,
timer: 0,
};
let store = ObjectFields::default();
let mut items = Items::default();
let mut names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let objectives = build_objectives(
&template,
&log_slot,
&store,
&mut items,
&mut names,
&commands,
&probe_strings,
);
assert!(objectives.is_empty(), "{objectives:#?}");
}
/// The 0109-era behaviour this inverts: a COMPLETE slot state used to mark every line
/// finished. Decision 1158 — the reference's per-line verdict never reads that bit
/// (`0x4e0110` §7); only the whole-quest turn-in predicate (`0x4df580`) combines the two.
@@ -1488,7 +1636,7 @@ mod tests {
// ── quests_with_progressed_objectives: the QUEST_WATCH_UPDATE per-quest trigger ─────────────────
/// A one-objective entry built the way [`objective_line`] builds one: the `cur`/`req` carried
/// A one-objective entry built the way [`creature_line`] builds one: the `cur`/`req` carried
/// on the view are READ BACK OUT of the line's own trailing `%d/%d`, so a fixture can never
/// claim a count its text disagrees with (the production invariant — the text is formatted
/// FROM the numbers).
@@ -1515,7 +1663,6 @@ mod tests {
QuestLogState {
num_quests: entries.iter().filter(|e| !e.is_header).count() as u32,
entries,
detail: None,
}
}
@@ -71,7 +71,6 @@ fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.len() as u32,
entries,
detail: None,
}
}
@@ -24,8 +24,34 @@ fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Re
.unwrap_or_else(|| panic!("no bare-frame quad sized {w}x{h}"))
}
thread_local! {
/// The reward/description pane the quest-log FrameXML tests paint. It hangs on every row
/// (decision 2247 moved the detail from the snapshot onto the entry), so a test may select
/// any index and still read it — which is what the state-level field used to give them.
static DETAIL_FIXTURE: QuestLogDetail = QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: Some("Interface\\Icons\\INV_Hammer_15".into()),
count: 1,
quality: 1,
usable: true,
// The reward row's ctrl/shift payload (`GetQuestLogItemLink`, decisions 1059/1060)
// — `ui_quest_log.rs` builds it through `ui_items::item_link` once the template
// lands, so the fixture carries that exact shape.
link: Some(HAMMER_LINK.into()),
}],
reward_spell: None,
};
}
/// 8 flat quest entries (exercises the 6-row faux-scroll), the first carrying one objective line
/// (the auto-picked first selection reads it), + a resolved detail for that selection (no choices,
/// (the auto-picked first selection reads it), each carrying the same resolved detail (no choices,
/// 1 fixed reward + money) — the fixture every test below shares. Each entry gets a distinct
/// `quest_id` (the watch set's stable key — `benilla-ui`'s `quest_log.rs` module doc) so the
/// watch/tracker tests below can tell entries apart across a scroll/reselect.
@@ -47,32 +73,13 @@ fn eight_entries() -> QuestLogState {
} else {
vec![]
},
detail: Some(DETAIL_FIXTURE.with(Clone::clone)),
..Default::default()
})
.collect();
QuestLogState {
num_quests: 8,
entries,
detail: Some(QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: Some("Interface\\Icons\\INV_Hammer_15".into()),
count: 1,
quality: 1,
usable: true,
// The reward row's ctrl/shift payload (`GetQuestLogItemLink`, decisions 1059/1060)
// — `ui_quest_log.rs` builds it through `ui_items::item_link` once the template
// lands, so the fixture carries that exact shape.
link: Some(HAMMER_LINK.into()),
}],
reward_spell: None,
}),
}
}
@@ -914,7 +921,9 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let mut state = eight_entries();
state.detail = Some(QuestLogDetail {
// The detail hangs on the rows now (2247), so override every row's — the window paints
// whichever one the auto-selection lands on.
let detail = QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
@@ -949,7 +958,10 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
..Default::default()
}],
reward_spell: None,
});
};
for e in &mut state.entries {
e.detail = Some(detail.clone());
}
s.set_quest_log(state);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1059,25 +1071,25 @@ fn overflowing_entry() -> QuestLogState {
level: 5,
complete: 0,
objectives,
detail: Some(QuestLogDetail {
description: "A very long description. ".repeat(20),
objectives_text: "Report back once every objective below is complete.".into(),
required_money: 0,
reward_money: 0,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 0,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
reward_spell: None,
}),
..Default::default()
}],
detail: Some(QuestLogDetail {
description: "A very long description. ".repeat(20),
objectives_text: "Report back once every objective below is complete.".into(),
required_money: 0,
reward_money: 0,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 0,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
reward_spell: None,
}),
}
}
+11
View File
@@ -838,7 +838,11 @@ fn drive_mouseover_tooltip(
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The headless probe's aim stands in for a cursor the window does not have
// (2250), so an automated run can carry a GENERIC plate — which is
// cursor-seated — all the way to the screen. A person's pointer always wins.
w.cursor_position()
.or_else(|| crate::target::hover_probe_point(w))
.map(|c| (c.x / s, (w.height() - c.y) / s))
})
})
@@ -853,6 +857,13 @@ fn drive_mouseover_tooltip(
if cursor_seated && cursor_ui.is_none() {
return; // cursor off-window: nothing to seat the pointer-anchored plate against
}
if crate::target::hover_probe_armed() {
info!(
"hover probe/tooltip: guid {guid:#x} cursor_seated {cursor_seated} cursor_ui \
{cursor_ui:?} template {:?}",
go_inputs.templates.get(guid).map(|t| t.name.clone()),
);
}
let Some(template) = go_inputs.templates.get(guid).cloned() else {
// Template in flight: ask once and retry next frame (`last` stays, so the show
// fires the moment the name lands).
+1 -1
View File
@@ -459,7 +459,7 @@ fn an_armed_trail_commits_a_strip_to_the_effect_stream() {
assert!((hi - 95.0 / 255.0).abs() < 1e-6, "the opaque end: {hi}");
assert!(hi - lo > 0.1, "a real ramp, not one flat alpha: {alphas:?}");
// Each quad is `[b0, b1, t1, t0]` — the two ends of one segment, so its corners pair up.
for q in verts.chunks_exact(4) {
for q in verts.as_chunks::<4>().0 {
assert_eq!(
q[0].color[3], q[3].color[3],
"the older pair shares an alpha"
@@ -89,7 +89,7 @@ fn main() -> anyhow::Result<()> {
.iter()
.map(|p| transform.transform_point(wow_to_bevy(*p)))
.collect();
for t in idx.chunks_exact(3) {
for t in idx.as_chunks::<3>().0 {
let (Some(&a), Some(&b), Some(&c)) = (
verts.get(t[0] as usize),
verts.get(t[1] as usize),
+1 -1
View File
@@ -336,7 +336,7 @@ fn normalize_path(path: &str) -> String {
/// Flat per-chunk normals for the rare chunk lacking authored MCNR (most carry it).
fn computed_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
let mut acc = vec![Vec3::ZERO; positions.len()];
for tri in indices.chunks_exact(3) {
for tri in indices.as_chunks::<3>().0 {
let [a, b, c] = [tri[0] as usize, tri[1] as usize, tri[2] as usize];
let (va, vb, vc) = (
Vec3::from(positions[a]),
+8 -3
View File
@@ -442,7 +442,12 @@ mod tests {
layer.chain.len(),
mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT)
);
assert!(layer.chain.chunks_exact(4).all(|px| px == [10, 20, 30, 40]));
assert!(layer
.chain
.as_chunks::<4>()
.0
.iter()
.all(|px| *px == [10, 20, 30, 40]));
}
#[test]
@@ -582,7 +587,7 @@ mod tests {
.remove(0);
// RGB565 is what a solid colour costs in any block form; assert within one quantisation
// step per channel rather than pretending the round trip is exact.
for px in top.chunks_exact(4) {
for px in top.as_chunks::<4>().0 {
for (got, want) in px[..3].iter().zip(&rgba[..3]) {
assert!(
got.abs_diff(*want) <= 8,
@@ -594,7 +599,7 @@ mod tests {
// and samples opaque, which is the documented limitation of the placeholder.
let alpha_ok = match form {
BlpTexels::Bc1 => true,
_ => top.chunks_exact(4).all(|px| px[3] == rgba[3]),
_ => top.as_chunks::<4>().0.iter().all(|px| px[3] == rgba[3]),
};
assert!(alpha_ok, "{form:?} must carry the fallback's alpha");
}
+3 -3
View File
@@ -262,7 +262,7 @@ pub fn footprint_tri_grids(footprints: &[Option<FootprintTris>]) -> Vec<Option<C
.iter()
.map(|fp| {
let fp = fp.as_ref()?;
let tris: Vec<&[u16]> = fp.indices.chunks_exact(3).collect();
let tris: Vec<&[u16; 3]> = fp.indices.as_chunks::<3>().0.iter().collect();
ColumnGrid::build(tris.len(), |i| {
let mut lo = [f32::MAX; 2];
let mut hi = [f32::MIN; 2];
@@ -589,7 +589,7 @@ impl AssetLoader for WmoModelLoader {
let mut gidx: Vec<u32> = Vec::new();
accumulate_wmo_group_collision(&gbytes, &mut gpos, &mut gidx);
if let Some(tris) = group_collision_tris.get_mut(gi as usize) {
for t in gidx.chunks_exact(3) {
for t in gidx.as_chunks::<3>().0 {
if let (Some(&a), Some(&b), Some(&c)) = (
gpos.get(t[0] as usize),
gpos.get(t[1] as usize),
@@ -608,7 +608,7 @@ impl AssetLoader for WmoModelLoader {
let (mut dpos, mut didx): (Vec<[f32; 3]>, Vec<u32>) = (Vec::new(), Vec::new());
accumulate_wmo_group_camera_only_collision(&gbytes, &mut dpos, &mut didx);
if let Some(tris) = group_camera_only_tris.get_mut(gi as usize) {
for t in didx.chunks_exact(3) {
for t in didx.as_chunks::<3>().0 {
if let (Some(&a), Some(&b), Some(&c)) = (
dpos.get(t[0] as usize),
dpos.get(t[1] as usize),
@@ -131,7 +131,7 @@ fn flatten_frame_dc(data: &mut [u8], spans: &[Vec<(usize, usize)>], levels: usiz
let mut texels = 0usize;
for frame in spans {
let (start, len) = frame[level];
for px in data[start..start + len].chunks_exact(4) {
for px in data[start..start + len].as_chunks::<4>().0 {
for c in 0..4 {
sums[c] += i64::from(px[c]);
}
@@ -149,7 +149,9 @@ fn flatten_frame_dc(data: &mut [u8], spans: &[Vec<(usize, usize)>], levels: usiz
}
for c in 0..4 {
let have: i64 = data[start..start + len]
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|px| i64::from(px[c]))
.sum();
// The exact integer total this channel must move by to sit on the loop mean.
@@ -604,7 +604,7 @@ impl WorldAssets {
}
let loaded =
decode_sprite(&self.chain, self.loose_root.as_deref(), path).map(|(w, h, mut rgba)| {
for px in rgba.chunks_exact_mut(4) {
for px in rgba.as_chunks_mut::<4>().0 {
px[0] = 0xFF;
px[1] = 0xFF;
px[2] = 0xFF;
+1 -1
View File
@@ -14,7 +14,7 @@ fn main() {
return;
}
let mut ppm = format!("P6\n{} {}\n255\n", mip.width, mip.height).into_bytes();
for px in mip.rgba.chunks_exact(4) {
for px in mip.rgba.as_chunks::<4>().0 {
let a = px[3] as u32;
for (c, mag) in px[..3].iter().zip([255u32, 0, 255]) {
ppm.push(((*c as u32 * a + mag * (255 - a)) / 255) as u8);
@@ -106,7 +106,12 @@ fn main() -> anyhow::Result<()> {
// What an AVERAGED chain would carry, for comparison — mip0's alpha box-filtered down.
// This is the control for a binary chain: it says how much of the flat coverage is the
// art's own and how much is the authored mip pyramid throwing the gradient away.
let mip0_alpha: Vec<u8> = mips.mips[0].chunks_exact(4).map(|p| p[3]).collect();
let mip0_alpha: Vec<u8> = mips.mips[0]
.as_chunks::<4>()
.0
.iter()
.map(|p| p[3])
.collect();
let mut averaged: Vec<Vec<u8>> = vec![mip0_alpha];
for i in 1..5usize {
let (pw, ph) = mips.mip_size(i as u32 - 1);
@@ -133,7 +138,7 @@ fn main() -> anyhow::Result<()> {
let mut binary_below_zero = false;
for (i, mip) in mips.mips.iter().enumerate().take(5) {
let (w, h) = mips.mip_size(i as u32);
let alpha: Vec<u8> = mip.chunks_exact(4).map(|p| p[3]).collect();
let alpha: Vec<u8> = mip.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
if alpha.len() != (w * h) as usize {
println!(
" mip{i}: SIZE MISMATCH — {} texels for {w}x{h}",
@@ -118,7 +118,7 @@ fn main() -> anyhow::Result<()> {
let (w, h) = chainx.mip_size(i as u32);
let n = mip.len() / 4;
let (mut zero, mut low, mut mid, mut high, mut full) = (0u32, 0u32, 0u32, 0u32, 0u32);
for px in mip.chunks_exact(4) {
for px in mip.as_chunks::<4>().0 {
match px[3] {
0 => zero += 1,
1..=63 => low += 1,
@@ -124,7 +124,7 @@ fn shell(subs: &[RenderSubmesh], pal: &[Mat4], probe: Vec3) -> (Vec<[Vec3; 3]>,
for &p in &pos {
near = near.min(p.distance(probe));
}
for t in s.indices.chunks_exact(3) {
for t in s.indices.as_chunks::<3>().0 {
tris.push([pos[t[0] as usize], pos[t[1] as usize], pos[t[2] as usize]]);
}
}
@@ -127,7 +127,7 @@ fn occluders(subs: &[RenderSubmesh], all: bool) -> Vec<([f32; 3], [f32; 3], [f32
if !all && (s.no_depth_write || s.no_depth_test) {
continue;
}
for t in s.indices.chunks_exact(3) {
for t in s.indices.as_chunks::<3>().0 {
tris.push((
s.positions[t[0] as usize],
s.positions[t[1] as usize],
+3 -1
View File
@@ -89,7 +89,9 @@ fn main() -> anyhow::Result<()> {
let mobn = nodes.len() / 0x10;
let mobr: Vec<u16> = mogp_subchunk(&gbytes, b"RBOM")
.map(|c| {
c.chunks_exact(2)
c.as_chunks::<2>()
.0
.iter()
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.collect()
})
@@ -38,7 +38,7 @@ struct Gather {
fn gather(positions: &[[f32; 3]], indices: &[u32]) -> Gather {
let (mut walkable, mut min_z, mut max_z) = (0usize, f32::MAX, f32::MIN);
for t in indices.chunks_exact(3) {
for t in indices.as_chunks::<3>().0 {
let p: [[f32; 3]; 3] = [
positions[t[0] as usize],
positions[t[1] as usize],
@@ -34,7 +34,9 @@ struct Face {
fn faces(sub: &RenderSubmesh) -> Vec<Face> {
sub.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.filter_map(|t| {
let p: Vec<[f32; 3]> = t.iter().map(|&i| sub.positions[i as usize]).collect();
let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0]));
@@ -127,7 +127,7 @@ fn main() -> anyhow::Result<()> {
.get(start..start + batch.count as usize)
.unwrap_or(&[]);
let (mut dark_area, mut total_area) = (0.0f32, 0.0f32);
for t in idx.chunks_exact(3) {
for t in idx.as_chunks::<3>().0 {
let p: Vec<[f32; 3]> = t
.iter()
.filter_map(|&i| group.vertex_positions.get(i as usize))
@@ -93,7 +93,7 @@ fn main() -> anyhow::Result<()> {
// is a doubled face, wherever its copies live.
let mut buckets: HashMap<[[i64; 3]; 3], Vec<Tri>> = HashMap::new();
for (bi, s) in subs.iter().enumerate() {
for t in s.indices.chunks_exact(3) {
for t in s.indices.as_chunks::<3>().0 {
let p: Vec<[f32; 3]> = t.iter().map(|&i| s.positions[i as usize]).collect();
let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0]));
let n = cross(u, v);
@@ -141,7 +141,9 @@ fn mods_set_names(bytes: &[u8]) -> Vec<String> {
}
if &bytes[off..off + 4] == b"SDOM" {
return bytes[off + 8..data_end]
.chunks_exact(32)
.as_chunks::<32>()
.0
.iter()
.map(|rec| {
let name = &rec[..20];
let end = name.iter().position(|&b| b == 0).unwrap_or(20);
@@ -1743,8 +1743,10 @@ mod tests {
// Byte-exact, but reported as a texel count and a name — a raw 32 KiB `assert_eq!` dump of a
// 128×64 RGBA tile is unreadable, and the useful fact is *which* of the three it is not.
let differing = |got: &[u8], want: &[u8]| {
got.chunks_exact(4)
.zip(want.chunks_exact(4))
got.as_chunks::<4>()
.0
.iter()
.zip(want.as_chunks::<4>().0)
.filter(|(a, b)| a != b)
.count()
};
@@ -1883,8 +1885,10 @@ mod tests {
.any(|(tx, ty, tw, th)| x >= *tx && x < tx + tw && y >= *ty && y < ty + th)
};
let moved: Vec<usize> = plain.mips[0]
.chunks_exact(4)
.zip(crested.mips[0].chunks_exact(4))
.as_chunks::<4>()
.0
.iter()
.zip(crested.mips[0].as_chunks::<4>().0)
.enumerate()
.filter(|(_, (a, b))| a != b)
.map(|(i, _)| i)
+1 -1
View File
@@ -414,7 +414,7 @@ pub fn blp_mip_stats(blp_bytes: &[u8]) -> Result<Vec<BlpMipStats>> {
let mut outside = (0usize, u8::MAX, 0u64, u8::MIN);
let mut inside = (0usize, u8::MAX, 0u64, u8::MIN);
let mut below_128 = 0usize;
for px in m.rgba.chunks_exact(4) {
for px in m.rgba.as_chunks::<4>().0 {
let luma = ((px[0] as u32 + px[1] as u32 + px[2] as u32) / 3) as u8;
if luma < 128 {
below_128 += 1;
@@ -258,7 +258,7 @@ impl<'c> CoverageReader<'c> {
return Ok(known.clone());
}
let (width, height, rgba) = crate::read_texture_rgba(self.chain, path)?;
let alpha: Vec<u8> = rgba.chunks_exact(4).map(|px| px[3]).collect();
let alpha: Vec<u8> = rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect();
let cov = if alpha.iter().all(|&a| a >= ALPHA_KEY_REF) {
Some(Coverage::Full)
} else if alpha.iter().all(|&a| a < ALPHA_KEY_REF) {
@@ -339,7 +339,7 @@ impl Grid {
/// Rasterise every front-facing, near-clipped triangle of `sub` into the grid.
fn paint_batch(&mut self, sub: &RenderSubmesh, frame: &EyeFrame, near: f32, cov: &Coverage) {
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let Some(eye) = tri
.iter()
.map(|&i| {
@@ -522,7 +522,7 @@ pub fn batch_footprint(sub: &RenderSubmesh, cam: &M2PortraitCamera) -> BatchFoot
return fp;
};
let near = cam.near_clip.max(1e-3);
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let Some(eye) = tri
.iter()
.map(|&i| {
@@ -57,7 +57,9 @@ pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result<CollisionMesh> {
let rd = &format.model().raw_data;
let positions: Vec<[f32; 3]> = rd
.bounding_vertices
.chunks_exact(12)
.as_chunks::<12>()
.0
.iter()
.map(|c| {
[
f32::from_le_bytes([c[0], c[1], c[2], c[3]]),
@@ -69,7 +71,9 @@ pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result<CollisionMesh> {
let n = positions.len() as u32;
let indices: Vec<u32> = rd
.bounding_triangles
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
.map(|c| u32::from(u16::from_le_bytes([c[0], c[1]])))
.collect();
// Guard (verified true on every real 1.12 hull): whole triangles, all indices in range. Bail to
@@ -311,7 +311,7 @@ fn separable_billboard_bones(
};
// (2) a triangle whose vertices don't all sit wholly on one bone disqualifies every bone it
// touches — including the hard (weight-1 vs weight-1) straddle that check (1) can't see.
for t in tris.chunks_exact(3) {
for t in tris.as_chunks::<3>().0 {
let g: Vec<usize> = t
.iter()
.filter_map(|&i| lookup.get(i as usize).map(|&x| x as usize))
@@ -632,7 +632,7 @@ pub fn parse_m2_render_submeshes(
.then_some(b)
};
let mut groups: Vec<(Option<usize>, Vec<u32>)> = Vec::new();
for tri in global_indices.chunks_exact(3) {
for tri in global_indices.as_chunks::<3>().0 {
let key = primary_billboard_bone(tri[0]);
if let Some(pos) = groups.iter().position(|(k, _)| *k == key) {
groups[pos].1.extend_from_slice(tri);
@@ -199,7 +199,7 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option<FootprintTris> {
let mut indices = Vec::new();
let mut mopy_flags = Vec::new();
let mut mopy_material = Vec::new();
for (ti, tri) in group.vertex_indices.chunks_exact(3).enumerate() {
for (ti, tri) in group.vertex_indices.as_chunks::<3>().0.iter().enumerate() {
let flags = group.material_info.get(ti).map_or(0, |m| m.flags);
if flags & FOOTPRINT_REJECT != 0 {
continue;
@@ -256,7 +256,9 @@ fn find_mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a
pub fn wmo_group_doodad_refs(group_bytes: &[u8]) -> Vec<u16> {
find_mogp_subchunk(group_bytes, b"RDOM")
.map(|c| {
c.chunks_exact(2)
c.as_chunks::<2>()
.0
.iter()
.map(|r| u16::from_le_bytes([r[0], r[1]]))
.collect()
})
@@ -271,7 +273,9 @@ pub fn wmo_group_doodad_refs(group_bytes: &[u8]) -> Vec<u16> {
pub fn wmo_group_light_refs(group_bytes: &[u8]) -> Vec<u16> {
find_mogp_subchunk(group_bytes, b"RLOM")
.map(|c| {
c.chunks_exact(2)
c.as_chunks::<2>()
.0
.iter()
.map(|r| u16::from_le_bytes([r[0], r[1]]))
.collect()
})
+17 -7
View File
@@ -205,7 +205,9 @@ fn parse_wmo_group_infos(bytes: &[u8]) -> Vec<WmoGroupInfo> {
let Some(mogi) = find_wmo_chunk(bytes, b"IGOM") else {
return Vec::new();
};
mogi.chunks_exact(32)
mogi.as_chunks::<32>()
.0
.iter()
.map(|rec| {
let f = |i: usize| f32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]);
let flags = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]);
@@ -268,14 +270,18 @@ pub fn parse_wmo_portals(bytes: &[u8]) -> WmoPortals {
let vertices = find_wmo_chunk(bytes, b"VPOM") // MOPV
.map(|c| {
c.chunks_exact(12)
c.as_chunks::<12>()
.0
.iter()
.map(|r| [f(r, 0), f(r, 4), f(r, 8)])
.collect()
})
.unwrap_or_default();
let infos = find_wmo_chunk(bytes, b"TPOM") // MOPT
.map(|c| {
c.chunks_exact(20)
c.as_chunks::<20>()
.0
.iter()
.map(|r| WmoPortalInfo {
start_vertex: u16(r, 0),
count: u16(r, 2),
@@ -286,7 +292,9 @@ pub fn parse_wmo_portals(bytes: &[u8]) -> WmoPortals {
.unwrap_or_default();
let refs = find_wmo_chunk(bytes, b"RPOM") // MOPR
.map(|c| {
c.chunks_exact(8)
c.as_chunks::<8>()
.0
.iter()
.map(|r| WmoPortalRef {
portal: u16(r, 0),
group: u16(r, 2),
@@ -338,7 +346,9 @@ pub fn parse_wmo_fogs(bytes: &[u8]) -> Vec<WmoFog> {
};
let f = |b: &[u8], i: usize| f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]);
let u = |b: &[u8], i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]);
mfog.chunks_exact(48)
mfog.as_chunks::<48>()
.0
.iter()
.map(|r| WmoFog {
flags: u(r, 0),
pos: [f(r, 4), f(r, 8), f(r, 12)],
@@ -386,7 +396,7 @@ fn parse_wmo_doodads(bytes: &[u8]) -> (Vec<WmoDoodad>, Vec<WmoDoodadSet>) {
let mut doodads = Vec::new();
if let Some(modd) = find_wmo_chunk(bytes, b"DDOM") {
for rec in modd.chunks_exact(40) {
for rec in modd.as_chunks::<40>().0 {
let f = |i: usize| f32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]);
let name_and_flags = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]);
doodads.push(WmoDoodad {
@@ -402,7 +412,7 @@ fn parse_wmo_doodads(bytes: &[u8]) -> (Vec<WmoDoodad>, Vec<WmoDoodadSet>) {
let mut doodad_sets = Vec::new();
if let Some(mods) = find_wmo_chunk(bytes, b"SDOM") {
for rec in mods.chunks_exact(32) {
for rec in mods.as_chunks::<32>().0 {
let u = |i: usize| u32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]);
doodad_sets.push(WmoDoodadSet {
start: u(20),
+2 -2
View File
@@ -216,7 +216,7 @@ impl ChunkMesh {
.iter()
.find_map(|tri| triangle_z_at(tri, wow[0], wow[1]));
}
self.indices.chunks_exact(3).find_map(|t| {
self.indices.as_chunks::<3>().0.iter().find_map(|t| {
let tri = [
*self.positions.get(t[0] as usize)?,
*self.positions.get(t[1] as usize)?,
@@ -950,7 +950,7 @@ mod tests {
// The raw walk the cell lookup replaced: every triangle of every chunk, in index order.
let walk = |chunks: &[ChunkMesh], wow: [f32; 3]| {
chunks.iter().find_map(|c| {
c.indices.chunks_exact(3).find_map(|t| {
c.indices.as_chunks::<3>().0.iter().find_map(|t| {
let tri = [
c.positions[t[0] as usize],
c.positions[t[1] as usize],
+3 -1
View File
@@ -34,7 +34,9 @@ pub fn load_zone_map(chain: &mut Chain, area_name: &str) -> Result<Vec<u32>> {
);
}
Ok(bytes
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect())
}
+4 -2
View File
@@ -356,7 +356,9 @@ fn meshes_an_elwynn_terrain_tile() {
assert_eq!(alpha.len(), size * size * 4, "alpha map is RGBA size²");
assert!(
alpha
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0),
"a multi-layer chunk's alpha map should have some non-zero blend weight"
);
@@ -443,7 +445,7 @@ fn terrain_fans_wind_ccw_seen_from_above() {
let (mut pairs, mut disagreeing) = (0usize, 0usize);
for chunk in &tile.chunks {
let shading = (chunk.normals.len() == chunk.positions.len()).then_some(&chunk.normals);
for t in chunk.indices.chunks_exact(3) {
for t in chunk.indices.as_chunks::<3>().0 {
let (i, j, k) = (t[0] as usize, t[1] as usize, t[2] as usize);
let (a, b, c) = (chunk.positions[i], chunk.positions[j], chunk.positions[k]);
let (u, v) = (
+1 -1
View File
@@ -93,7 +93,7 @@ impl M2Model {
let read_u16s = |n: usize, o: usize| -> Result<Vec<u16>> {
let slice = bytes.bytes_at(o, n * 2).ok_or(Error::Truncated)?;
let mut v = Vec::with_capacity(n);
for c in slice.chunks_exact(2) {
for c in slice.as_chunks::<2>().0 {
v.push(c.u16_at(0).ok_or(Error::Truncated)?);
}
Ok(v)
+6 -2
View File
@@ -141,7 +141,9 @@ type Result<T> = std::result::Result<T, Error>;
/// Reinterpret a byte buffer (length a multiple of 4) as a `Vec<u32>` (little-endian) for table decrypt.
fn to_u32s(bytes: &[u8]) -> Vec<u32> {
bytes
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
@@ -302,7 +304,9 @@ impl Archive {
let mut otab = vec![0u8; otab_cap * 4]; // == otab_len * 4, proven <= avail above
file.read_exact(&mut otab)?;
let offsets: Vec<u32> = otab
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
c.u32_at(0)
.ok_or_else(|| Error::Corrupt(format!("{name}: corrupt sector offset table")))
+12
View File
@@ -1774,6 +1774,18 @@ pub(crate) struct Model {
pub(crate) modifiers: (bool, bool, bool),
}
impl Model {
/// The detail pane of the row `SelectQuestLogEntry` currently names — resolved HERE, per call,
/// never baked into the pushed snapshot (decision 2247). The reference's detail bindings read
/// the selection variable and peek the quest cache inside the same call, so a select-then-read
/// pair within one frame answers about the row just selected; the app's push only supplies the
/// per-row data those reads land on. `None` for no selection, an out-of-range one, or a header.
pub(crate) fn selected_quest_detail(&self) -> Option<&quest_log::QuestLogDetail> {
let index = usize::try_from(self.quest_log_selection.checked_sub(1)?).ok()?;
self.quest_log.entries.get(index)?.detail.as_ref()
}
}
impl Model {
/// A unit token's snapshot, **case-folded the way the client folds it**.
///
+118 -33
View File
@@ -10,8 +10,12 @@
//! selection is native and *synchronous* — `QuestLogTitleButton_OnClick` calls
//! `SelectQuestLogEntry(i)` then immediately re-reads `GetQuestLogSelection()` in the same click
//! (ref `QuestLogFrame.lua:318,308-346`); a drain-next-frame intent would read stale. The app
//! reads it back each frame ([`super::UiScript::quest_log_selection`]) and pushes the matching
//! detail; the refresh lands as a `QUEST_LOG_UPDATE` event, exactly like the ref's async data.
//! reads it back each frame ([`super::UiScript::quest_log_selection`]) only to RE-POINT it across
//! a rebuild. The **detail that selection names is resolved at call time**, off the row's own
//! [`QuestLogEntryView::detail`] — never from a selection baked into the push (decision 2247).
//! The reference's detail bindings peek its quest cache inside the same call, so
//! `SelectQuestLogEntry(i)` immediately changes what `GetQuestLogQuestText()` answers; resolving
//! one detail per push instead made a whole log walk answer with a single row's text.
//! - **The abandon mark** (`SetAbandonQuest`/`GetAbandonQuestName`/`AbandonQuest`) — the ref's
//! two-step confirm (mark on button click, act on the popup's Yes — ref `QuestLogFrame.xml:463-472`,
//! `StaticPopup.lua:749-761`). The mark pins the *entry index at click time* so a log shuffle
@@ -96,6 +100,15 @@ pub struct QuestLogEntryView {
/// [`QuestLogDetail`] carries only what exists for the selection alone
/// (description/rewards/money).
pub objectives: Vec<QuestLogObjectiveView>,
/// This row's detail pane — description, money, rewards, reward spell. **Per row, not per
/// selection** (decision 2247): the reference's detail bindings read `ds:0xbb7480` (what
/// `SelectQuestLogEntry 0x4dfae0` wrote, synchronously) and then PEEK the quest cache in the
/// same call (`0x4e1130` -> `0xc0e1b0`/`0x562a40`, wow-re `ui/ledger.tsv`), so a
/// select-then-read pair inside ONE frame answers about the row just selected. Carrying one
/// detail for "the selection" instead made `SelectQuestLogEntry` inert until the next push:
/// every entry of an addon's log walk answered with whichever row the snapshot happened to be
/// built against. `None` on a header row.
pub detail: Option<QuestLogDetail>,
}
/// One objective ("leaderboard") line of the selected quest — the `GetQuestLogLeaderBoard` tuple.
@@ -155,9 +168,6 @@ pub struct QuestLogState {
/// The total quest count INCLUDING quests hidden under collapsed headers — the "Quests: N/20"
/// pill must not shrink when a header collapses (`GetNumQuestLogEntries` return 2).
pub num_quests: u32,
/// The detail pane for the current selection (`None` = nothing selected, selection out of
/// range, or the template still in flight).
pub detail: Option<QuestLogDetail>,
}
impl super::UiScript {
@@ -386,9 +396,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let (desc, obj) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.quest_log
.detail
.as_ref()
.selected_quest_detail()
.map(|d| (d.description.clone(), d.objectives_text.clone()))
.unwrap_or_default()
};
@@ -449,7 +457,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
name,
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.quest_log.detail.as_ref().map(pick).unwrap_or(0))
Ok(model.selected_quest_detail().map(pick).unwrap_or(0))
})?,
)
}
@@ -474,9 +482,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.quest_log
.detail
.as_ref()
.selected_quest_detail()
.and_then(|d| i.checked_sub(1).and_then(|n| pick(d).get(n)).cloned())
};
let Some(it) = item else {
@@ -518,7 +524,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, (kind, index): (String, usize)| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest_log.detail.as_ref().and_then(|d| {
model.selected_quest_detail().and_then(|d| {
let v = match kind.as_str() {
"choice" => &d.choices,
"reward" => &d.rewards,
@@ -700,9 +706,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let spell = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.quest_log
.detail
.as_ref()
.selected_quest_detail()
.and_then(|d| d.reward_spell.clone())
};
super::quest::reward_spell_returns(lua, spell)
@@ -859,6 +863,23 @@ mod tests {
cur: 3,
req: 10,
}],
detail: Some(QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()),
}],
reward_spell: None,
}),
..Default::default()
},
QuestLogEntryView {
@@ -873,26 +894,18 @@ mod tests {
cur: 10,
req: 10,
}],
detail: Some(QuestLogDetail {
description: "The kobolds have overrun the camp.".into(),
objectives_text: "Kill 10 Kobold Vermin.".into(),
required_money: 0,
reward_money: 250,
choices: vec![],
rewards: vec![],
reward_spell: None,
}),
..Default::default()
},
],
detail: Some(QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()),
}],
reward_spell: None,
}),
}
}
@@ -1049,6 +1062,78 @@ mod tests {
assert_eq!(s.quest_log_selection(), 2);
}
/// **The bug decision 2247 fixes, in the shape every addon hits it.** The classic quest-log
/// walk — `SelectQuestLogEntry(i)` then `GetQuestLogQuestText()`, once per entry, all inside
/// ONE frame with no push between — must answer about the row just selected. It used to answer
/// with whichever row the snapshot had been built against, for every entry: Questie 3.7.1 fed
/// that text to its `getQuestHash` Levenshtein match and wrote the WRONG same-name chain step
/// into a character's saved history (two of the director's Tirisfal quests, both resolved to
/// the shortest sibling — the signature of matching against an unrelated string).
#[test]
fn a_log_walk_answers_per_entry_without_a_push_between() {
let mut s = UiScript::new().unwrap();
s.set_quest_log(two_quests());
let walk: String = s
.eval(
"local out = ''\n\
for i = 1, GetNumQuestLogEntries() do\n\
SelectQuestLogEntry(i)\n\
local desc, obj = GetQuestLogQuestText()\n\
out = out .. i .. '=' .. desc .. '|'\n\
end\n\
return out",
)
.unwrap();
assert_eq!(
walk, "1=Speak with Marshal McBride.|2=The kobolds have overrun the camp.|",
"each entry must answer with its OWN detail inside a single frame"
);
}
/// The same seam for the money/reward getters, which read the detail through their own path.
#[test]
fn the_detail_counts_follow_the_selection_too() {
let mut s = UiScript::new().unwrap();
s.set_quest_log(two_quests());
assert_eq!(
s.eval::<i64>("SelectQuestLogEntry(1); return GetQuestLogRewardMoney()")
.unwrap(),
40
);
assert_eq!(
s.eval::<i64>("SelectQuestLogEntry(2); return GetQuestLogRewardMoney()")
.unwrap(),
250
);
assert_eq!(
s.eval::<i64>("SelectQuestLogEntry(2); return GetNumQuestLogRewards()")
.unwrap(),
0
);
}
/// A header row has no quest under it, so its detail reads as nothing at all rather than as
/// the previous selection's — the same staleness, one row over.
#[test]
fn a_header_selection_has_no_detail() {
let mut s = UiScript::new().unwrap();
let mut state = two_quests();
state.entries.insert(
0,
QuestLogEntryView {
title: "Elwynn Forest".into(),
is_header: true,
..Default::default()
},
);
s.set_quest_log(state);
assert!(s
.eval::<bool>(
"SelectQuestLogEntry(1); local d = GetQuestLogQuestText(); return d == ''"
)
.unwrap());
}
#[test]
fn detail_getters_read() {
let mut s = UiScript::new().unwrap();
@@ -226,12 +226,18 @@ fn quest_item_hovers_render_the_rows_item_by_id() {
assert_eq!(s.eval::<i64>("return TT:NumLines()").unwrap(), 0);
s.set_quest_log(QuestLogState {
detail: Some(QuestLogDetail {
rewards: vec![row()],
entries: vec![QuestLogEntryView {
quest_id: 7,
detail: Some(QuestLogDetail {
rewards: vec![row()],
..Default::default()
}),
..Default::default()
}),
}],
..Default::default()
});
// The detail hangs on the row now, so the tooltip reads it through the selection (2247).
s.run("SelectQuestLogEntry(1)").unwrap();
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("reward", 1)"#)
.unwrap();
assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer");
@@ -1056,12 +1056,18 @@ fn quest_reward_spell_getters_and_hovers() {
);
s.set_quest_log(QuestLogState {
detail: Some(QuestLogDetail {
reward_spell: Some(reward),
entries: vec![QuestLogEntryView {
quest_id: 7,
detail: Some(QuestLogDetail {
reward_spell: Some(reward),
..Default::default()
}),
..Default::default()
}),
}],
..Default::default()
});
// The detail hangs on the row now, so both readers go through the selection (2247).
s.run("SelectQuestLogEntry(1)").unwrap();
assert_eq!(
s.eval::<(String, String, Option<i64>)>("return GetQuestLogRewardSpell()")
.unwrap()
@@ -346,7 +346,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, (this, kind, index): (Table, String, usize)| {
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest_log.detail.as_ref().and_then(|d| {
model.selected_quest_detail().and_then(|d| {
let v = match kind.as_str() {
"choice" => Some(&d.choices),
"reward" => Some(&d.rewards),
@@ -667,9 +667,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
let id = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.quest_log
.detail
.as_ref()
.selected_quest_detail()
.and_then(|d| d.reward_spell.as_ref())
.map(|s| (s.spell_id, s.name.clone()))
};
+8 -8
View File
@@ -243,7 +243,7 @@ fn parse_root(b: &[u8]) -> Result<WmoRoot> {
}
// MOMT entry is 64 bytes: flags@0, shader@4, blend_mode@8, texture_1@12, sidnColor@16, …
b"TMOM" => {
for m in p.chunks_exact(64) {
for m in p.as_chunks::<64>().0 {
// CImVector — BGRA bytes on disk, so the LE u32 reads B | G<<8 | R<<16 | A<<24.
let sidn = m.u32_at(0x10).ok_or(Error::Truncated("MOMT"))?;
let diff = m.u32_at(0x1c).ok_or(Error::Truncated("MOMT"))?;
@@ -311,7 +311,7 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
for (magic, s) in chunks(&mogp[68..]) {
match &magic {
b"TVOM" => {
for c in s.chunks_exact(12) {
for c in s.as_chunks::<12>().0 {
g.vertex_positions.push(Vec3 {
x: c.f32_at(0).ok_or(Error::Truncated("MOVT"))?,
y: c.f32_at(4).ok_or(Error::Truncated("MOVT"))?,
@@ -320,7 +320,7 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
}
}
b"RNOM" => {
for c in s.chunks_exact(12) {
for c in s.as_chunks::<12>().0 {
g.vertex_normals.push(Vec3 {
x: c.f32_at(0).ok_or(Error::Truncated("MONR"))?,
y: c.f32_at(4).ok_or(Error::Truncated("MONR"))?,
@@ -329,7 +329,7 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
}
}
b"VTOM" => {
for c in s.chunks_exact(8) {
for c in s.as_chunks::<8>().0 {
g.texture_coords.push(Uv {
u: c.f32_at(0).ok_or(Error::Truncated("MOTV"))?,
v: c.f32_at(4).ok_or(Error::Truncated("MOTV"))?,
@@ -337,14 +337,14 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
}
}
b"IVOM" => {
for c in s.chunks_exact(2) {
for c in s.as_chunks::<2>().0 {
g.vertex_indices
.push(c.u16_at(0).ok_or(Error::Truncated("MOVI"))?);
}
}
// MOBA: bbox_min[i16;3] bbox_max[i16;3] start_index(u32@12) count(u16@16) min/max(u16) flags(u8@22) material_id(u8@23)
b"ABOM" => {
for c in s.chunks_exact(24) {
for c in s.as_chunks::<24>().0 {
g.render_batches.push(Batch {
start_index: c.u32_at(12).ok_or(Error::Truncated("MOBA"))?,
count: c.u16_at(16).ok_or(Error::Truncated("MOBA"))?,
@@ -353,7 +353,7 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
}
}
b"VCOM" => {
for c in s.chunks_exact(4) {
for c in s.as_chunks::<4>().0 {
g.vertex_colors.push(Color {
b: c[0],
g: c[1],
@@ -364,7 +364,7 @@ fn parse_group(mogp: &[u8]) -> Result<WmoGroup> {
}
// MOPY: 2 bytes/face — flags + material id, one per triangle (collision filtering).
b"YPOM" => {
for c in s.chunks_exact(2) {
for c in s.as_chunks::<2>().0 {
g.material_info.push(MopyEntry {
flags: c[0],
material_id: c[1],
+7 -3
View File
@@ -414,7 +414,7 @@ fn ray_submesh(
})
};
let mut nearest: Option<(f32, [Vec3; 3])> = None;
for t in geo.indices.chunks_exact(3) {
for t in geo.indices.as_chunks::<3>().0 {
let (Some(a), Some(b), Some(c)) = (pos(t[0]), pos(t[1]), pos(t[2])) else {
continue; // an out-of-range index — corrupt authoring; skip the triangle, not the model
};
@@ -514,11 +514,15 @@ pub fn ray_posed_mesh(
};
let hits = match mesh.indices()? {
Indices::U16(ix) => ix
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.filter_map(|c| tri(c[0] as usize, c[1] as usize, c[2] as usize))
.fold(None::<f32>, |acc, t| Some(acc.map_or(t, |a| a.min(t)))),
Indices::U32(ix) => ix
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.filter_map(|c| tri(c[0] as usize, c[1] as usize, c[2] as usize))
.fold(None::<f32>, |acc, t| Some(acc.map_or(t, |a| a.min(t)))),
};
+3 -1
View File
@@ -319,7 +319,9 @@ pub(crate) fn spawn_liquids<'a>(
fn liquid_collider(lq: &LiquidMesh) -> Option<Collider> {
let tris: Vec<[u32; 3]> = lq
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| [c[0], c[1], c[2]])
.collect();
if tris.is_empty() {
@@ -104,8 +104,8 @@ pub(super) fn dump_emitter(
} else {
// Per-quad diagonal |v2−v0| over the WHOLE range — the young (big) quads live at the
// tail, and only reading quad 0 (the oldest, smallest) once mis-called a mesh "sane".
let diag = |q: &[EffectVertex]| (Vec3::from(q[2].pos) - Vec3::from(q[0].pos)).length();
let quads: Vec<f32> = written.chunks_exact(4).map(diag).collect();
let diag = |q: &[EffectVertex; 4]| (Vec3::from(q[2].pos) - Vec3::from(q[0].pos)).length();
let quads: Vec<f32> = written.as_chunks::<4>().0.iter().map(diag).collect();
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
for &d in &quads {
dmin = dmin.min(d);
@@ -204,7 +204,9 @@ pub fn placement_collider_data(
.collect();
let tris: Vec<[u32; 3]> = hull
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| [c[0], c[1], c[2]])
.collect();
Some((verts, tris))
@@ -229,7 +231,9 @@ pub(super) fn terrain_collider_data(
tris.extend(
chunk
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| [base + c[0], base + c[1], base + c[2]]),
);
}
@@ -163,7 +163,7 @@ fn face_stack(model: &WmoModel, x: f32, y: f32, max_z: f32) {
let mut hits: Vec<(f32, usize, u8)> = Vec::new();
for (gi, fp) in model.group_footprints.iter().enumerate() {
let Some(fp) = fp else { continue };
for (ti, tri) in fp.indices.chunks_exact(3).enumerate() {
for (ti, tri) in fp.indices.as_chunks::<3>().0.iter().enumerate() {
let (Some(&a), Some(&b), Some(&c)) = (
fp.positions.get(tri[0] as usize),
fp.positions.get(tri[1] as usize),
@@ -242,7 +242,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
let mut cp: Vec<[f32; 3]> = Vec::new();
let mut ci: Vec<u32> = Vec::new();
accumulate_wmo_group_collision(&gbytes, &mut cp, &mut ci);
for t in ci.chunks_exact(3) {
for t in ci.as_chunks::<3>().0 {
if let (Some(&a), Some(&b), Some(&c)) = (
cp.get(t[0] as usize),
cp.get(t[1] as usize),
@@ -258,7 +258,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
// exactly as the asset loader stores it (decision 0692).
let (mut dp, mut di): (Vec<[f32; 3]>, Vec<u32>) = (Vec::new(), Vec::new());
benilla_formats::accumulate_wmo_group_camera_only_collision(&gbytes, &mut dp, &mut di);
for t in di.chunks_exact(3) {
for t in di.as_chunks::<3>().0 {
if let (Some(&a), Some(&b), Some(&c)) = (
dp.get(t[0] as usize),
dp.get(t[1] as usize),
@@ -458,7 +458,7 @@ fn nearest_hit_mesh(
max_t: f32,
) -> Option<f32> {
let mut best: Option<f32> = None;
for t in indices.chunks_exact(3) {
for t in indices.as_chunks::<3>().0 {
let (Some(&a), Some(&b), Some(&c)) = (
positions.get(t[0] as usize),
positions.get(t[1] as usize),