diff --git a/crates/benilla-adt/src/combined_alpha.rs b/crates/benilla-adt/src/combined_alpha.rs index b2c51a9c..05316818 100644 --- a/crates/benilla-adt/src/combined_alpha.rs +++ b/crates/benilla-adt/src/combined_alpha.rs @@ -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 { diff --git a/crates/benilla-adt/src/lib.rs b/crates/benilla-adt/src/lib.rs index 13dd79cb..ebee4e03 100644 --- a/crates/benilla-adt/src/lib.rs +++ b/crates/benilla-adt/src/lib.rs @@ -342,14 +342,16 @@ fn split_strings(blob: &[u8]) -> Vec { /// `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> { - 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> { 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> { fn read_modf(data: &[u8]) -> Result> { 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 { .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 { diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 90ada1c9..1e2d0ba7 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -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, }); } diff --git a/crates/benilla-app/src/aura_visual/tests.rs b/crates/benilla-app/src/aura_visual/tests.rs index 70165c45..65b5b748 100644 --- a/crates/benilla-app/src/aura_visual/tests.rs +++ b/crates/benilla-app/src/aura_visual/tests.rs @@ -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; diff --git a/crates/benilla-app/src/capture/depth_probe.rs b/crates/benilla-app/src/capture/depth_probe.rs index 891b071a..d79689ac 100644 --- a/crates/benilla-app/src/capture/depth_probe.rs +++ b/crates/benilla-app/src/capture/depth_probe.rs @@ -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; diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index a59c397a..cfd1a3cf 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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!( diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 9daa623e..34db476b 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -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() ) }); diff --git a/crates/benilla-app/src/entities/display.rs b/crates/benilla-app/src/entities/display.rs index f78ed8e3..fc567cba 100644 --- a/crates/benilla-app/src/entities/display.rs +++ b/crates/benilla-app/src/entities/display.rs @@ -629,7 +629,9 @@ fn model_local_collider(hull: &CollisionMesh) -> Option { let verts: Vec = 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)) diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 33f2c8d1..2671438b 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -2525,7 +2525,9 @@ mod inside_a_hull { let verts: Vec = 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(); diff --git a/crates/benilla-app/src/portrait/test_bake.rs b/crates/benilla-app/src/portrait/test_bake.rs index 2972afc8..a4c388ba 100644 --- a/crates/benilla-app/src/portrait/test_bake.rs +++ b/crates/benilla-app/src/portrait/test_bake.rs @@ -328,8 +328,8 @@ pub(crate) fn encode_target_readback(shot: &Image) -> Option { } 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. diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index 42602475..a28df927 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -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, +#ifdef VERTEX_UVS + @location(2) uv: vec2, +#endif +#ifdef VERTEX_COLORS + @location(4) color: vec4, +#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, + @location(0) uv: vec2, +#ifdef VERTEX_COLORS + @location(1) color: vec4, +#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` 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, +} + +@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(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(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 additive: u32; @group(2) @binding(1) var quad_texture: texture_2d; @@ -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 uv_clamp: vec4; -// 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 tint: vec4; // 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 { 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(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 ``, `|cff…`, quality colors), so this multiply IS the FFP's diff --git a/crates/benilla-app/src/sound/mixer.rs b/crates/benilla-app/src/sound/mixer.rs index e2b61fe3..cf3370c3 100644 --- a/crates/benilla-app/src/sound/mixer.rs +++ b/crates/benilla-app/src/sound/mixer.rs @@ -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); } diff --git a/crates/benilla-app/src/sound/output/cpal.rs b/crates/benilla-app/src/sound/output/cpal.rs index 1266c132..8766dd4b 100644 --- a/crates/benilla-app/src/sound/output/cpal.rs +++ b/crates/benilla-app/src/sound/output/cpal.rs @@ -351,7 +351,7 @@ impl) + Send + 'static> Callback { fn spread>(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>(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..] { diff --git a/crates/benilla-app/src/sound/vocal.rs b/crates/benilla-app/src/sound/vocal.rs index 6fd3e266..a8426f0c 100644 --- a/crates/benilla-app/src/sound/vocal.rs +++ b/crates/benilla-app/src/sound/vocal.rs @@ -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 } diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 881003ad..ac78cfe2 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -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 = 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); diff --git a/crates/benilla-app/src/target/hover_probe.rs b/crates/benilla-app/src/target/hover_probe.rs new file mode 100644 index 00000000..3a3f8cbe --- /dev/null +++ b/crates/benilla-app/src/target/hover_probe.rs @@ -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 | +//! | `,` | 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 { + static AIM: std::sync::OnceLock> = 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 { + 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, +} + +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")); + } +} diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index a33bcfe3..26a65ac1 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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 { + 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; diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 0cbffe90..f62ea8f1 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -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) -> Option { // 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). diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 2deb3d44..1cacca5d 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -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); /// 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` 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` 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>, materials: Res>, @@ -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, MatKey, [f32; 4]); - #[derive(Default)] struct BatchPools { entities: Vec, @@ -897,14 +922,11 @@ struct BatchPools { /// Goldshire pin). offsets: Vec, materials: std::collections::HashMap>, - /// Per slot: the solo-run material (key + tint it was built with), `None` while pooled. - solo: Vec>, - /// 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>, /// Per slot: the material the batch entity currently carries. bound: Vec>>, + /// Per slot: the [`MeshTag`] the batch entity currently carries — the run's one colour + /// ([`tint_tag`]), rewritten only when it moves. + tags: Vec>, } /// 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| { - 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` 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::() + .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::>::default(); + let mut material_events = |app: &App| { + let (mut added, mut modified) = (0, 0); + for e in cursor.read( + app.world() + .resource::>>(), + ) { + 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>() + .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::(); + q.quads[0].color = gold; + q.dirty = true; + } + app.update(); + + let cost = app.world().resource::(); + 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::>().len(), + 1, + "still the one material: colour is not part of its identity" + ); + let tag = app + .world() + .get::(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::(batch).expect("the batch's mesh"); + let mesh = app + .world() + .resource::>() + .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 `` // (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners` diff --git a/crates/benilla-app/src/ui_pet/tests.rs b/crates/benilla-app/src/ui_pet/tests.rs index 76c479a1..27af3220 100644 --- a/crates/benilla-app/src/ui_pet/tests.rs +++ b/crates/benilla-app/src/ui_pet/tests.rs @@ -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, diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index 6cf3bd52..36cb602a 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -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, + 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, ) -> Option { - // 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, +) -> Option { + 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, ) -> Vec { 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 = Vec::new(); let mut entry_slots: Vec> = Vec::new(); let mut header_keys: Vec> = 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, + ¯os, + 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, - ¯os, - 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, ""); 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, ""); } @@ -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, ""); 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, ""); 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, ""); 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, ""); } @@ -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, ""); 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, ""); 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, ""); 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, ""); + assert_eq!(objectives[1].text, ""); + assert_eq!(objectives[2].text, ""); + 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, ""); + // The item line wears the in-flight placeholder, never the creature's text. + assert_eq!(objectives[1].text, ""); + } + + /// `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, } } diff --git a/crates/benilla-app/src/ui_script/quest_timer_tests.rs b/crates/benilla-app/src/ui_script/quest_timer_tests.rs index 297b0140..62a1a49f 100644 --- a/crates/benilla-app/src/ui_script/quest_timer_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_timer_tests.rs @@ -71,7 +71,6 @@ fn log(entries: Vec) -> QuestLogState { QuestLogState { num_quests: entries.len() as u32, entries, - detail: None, } } diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 0cab2834..8b8c1ec6 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -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, - }), } } diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 221ee0bb..eca054ec 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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). diff --git a/crates/benilla-app/src/weapon_trail/tests.rs b/crates/benilla-app/src/weapon_trail/tests.rs index d1a127a0..c3618429 100644 --- a/crates/benilla-app/src/weapon_trail/tests.rs +++ b/crates/benilla-app/src/weapon_trail/tests.rs @@ -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" diff --git a/crates/benilla-assets/examples/face_facing_at.rs b/crates/benilla-assets/examples/face_facing_at.rs index 049c1261..ea97c4c9 100644 --- a/crates/benilla-assets/examples/face_facing_at.rs +++ b/crates/benilla-assets/examples/face_facing_at.rs @@ -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), diff --git a/crates/benilla-assets/src/adt.rs b/crates/benilla-assets/src/adt.rs index 4db73573..86955f9c 100644 --- a/crates/benilla-assets/src/adt.rs +++ b/crates/benilla-assets/src/adt.rs @@ -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]), diff --git a/crates/benilla-assets/src/terrain.rs b/crates/benilla-assets/src/terrain.rs index 11ad1dca..984efb9b 100644 --- a/crates/benilla-assets/src/terrain.rs +++ b/crates/benilla-assets/src/terrain.rs @@ -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"); } diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index f583e57b..15fbc6cc 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -262,7 +262,7 @@ pub fn footprint_tri_grids(footprints: &[Option]) -> Vec = 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 = 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) = (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), diff --git a/crates/benilla-assets/src/world_assets/images.rs b/crates/benilla-assets/src/world_assets/images.rs index efb9c4e8..3c496364 100644 --- a/crates/benilla-assets/src/world_assets/images.rs +++ b/crates/benilla-assets/src/world_assets/images.rs @@ -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. diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index f9a7d434..c46e8d73 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -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; diff --git a/crates/benilla-blp/src/bin/blpx.rs b/crates/benilla-blp/src/bin/blpx.rs index a60b437b..3dfb3ffc 100644 --- a/crates/benilla-blp/src/bin/blpx.rs +++ b/crates/benilla-blp/src/bin/blpx.rs @@ -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); diff --git a/crates/benilla-formats/examples/clutter_cut.rs b/crates/benilla-formats/examples/clutter_cut.rs index 5ce84665..36f71d69 100644 --- a/crates/benilla-formats/examples/clutter_cut.rs +++ b/crates/benilla-formats/examples/clutter_cut.rs @@ -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 = mips.mips[0].chunks_exact(4).map(|p| p[3]).collect(); + let mip0_alpha: Vec = mips.mips[0] + .as_chunks::<4>() + .0 + .iter() + .map(|p| p[3]) + .collect(); let mut averaged: Vec> = 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 = mip.chunks_exact(4).map(|p| p[3]).collect(); + let alpha: Vec = 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}", diff --git a/crates/benilla-formats/examples/clutter_state.rs b/crates/benilla-formats/examples/clutter_state.rs index b75cb23f..bf7b560e 100644 --- a/crates/benilla-formats/examples/clutter_state.rs +++ b/crates/benilla-formats/examples/clutter_state.rs @@ -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, diff --git a/crates/benilla-formats/examples/eye_burial_anim.rs b/crates/benilla-formats/examples/eye_burial_anim.rs index 9db2f418..5332cb37 100644 --- a/crates/benilla-formats/examples/eye_burial_anim.rs +++ b/crates/benilla-formats/examples/eye_burial_anim.rs @@ -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]]); } } diff --git a/crates/benilla-formats/examples/eye_quad_pass.rs b/crates/benilla-formats/examples/eye_quad_pass.rs index 03cc754d..93bff631 100644 --- a/crates/benilla-formats/examples/eye_quad_pass.rs +++ b/crates/benilla-formats/examples/eye_quad_pass.rs @@ -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], diff --git a/crates/benilla-formats/examples/wmo_bsp.rs b/crates/benilla-formats/examples/wmo_bsp.rs index 0dff5be2..08ddc048 100644 --- a/crates/benilla-formats/examples/wmo_bsp.rs +++ b/crates/benilla-formats/examples/wmo_bsp.rs @@ -89,7 +89,9 @@ fn main() -> anyhow::Result<()> { let mobn = nodes.len() / 0x10; let mobr: Vec = 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() }) diff --git a/crates/benilla-formats/examples/wmo_collision_cost.rs b/crates/benilla-formats/examples/wmo_collision_cost.rs index 6008e693..b23ef2e2 100644 --- a/crates/benilla-formats/examples/wmo_collision_cost.rs +++ b/crates/benilla-formats/examples/wmo_collision_cost.rs @@ -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], diff --git a/crates/benilla-formats/examples/wmo_coplanar.rs b/crates/benilla-formats/examples/wmo_coplanar.rs index 241e4fe7..0a06920e 100644 --- a/crates/benilla-formats/examples/wmo_coplanar.rs +++ b/crates/benilla-formats/examples/wmo_coplanar.rs @@ -34,7 +34,9 @@ struct Face { fn faces(sub: &RenderSubmesh) -> Vec { 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])); diff --git a/crates/benilla-formats/examples/wmo_dark_floors.rs b/crates/benilla-formats/examples/wmo_dark_floors.rs index 165576e1..44f6e6b8 100644 --- a/crates/benilla-formats/examples/wmo_dark_floors.rs +++ b/crates/benilla-formats/examples/wmo_dark_floors.rs @@ -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)) diff --git a/crates/benilla-formats/examples/wmo_doubled.rs b/crates/benilla-formats/examples/wmo_doubled.rs index a6099c9b..265a0aa1 100644 --- a/crates/benilla-formats/examples/wmo_doubled.rs +++ b/crates/benilla-formats/examples/wmo_doubled.rs @@ -93,7 +93,7 @@ fn main() -> anyhow::Result<()> { // is a doubled face, wherever its copies live. let mut buckets: HashMap<[[i64; 3]; 3], Vec> = 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); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs index df412f74..c8f7f553 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs @@ -141,7 +141,9 @@ fn mods_set_names(bytes: &[u8]) -> Vec { } 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); diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index 86df8ae4..d8eaf0a7 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -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 = 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) diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 1e2b833c..88d94c2f 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -414,7 +414,7 @@ pub fn blp_mip_stats(blp_bytes: &[u8]) -> Result> { 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; diff --git a/crates/benilla-formats/src/models/art_extent.rs b/crates/benilla-formats/src/models/art_extent.rs index 27e0b202..acf57cab 100644 --- a/crates/benilla-formats/src/models/art_extent.rs +++ b/crates/benilla-formats/src/models/art_extent.rs @@ -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 = rgba.chunks_exact(4).map(|px| px[3]).collect(); + let alpha: Vec = 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| { diff --git a/crates/benilla-formats/src/models/collision.rs b/crates/benilla-formats/src/models/collision.rs index 477d9df3..dd8e3133 100644 --- a/crates/benilla-formats/src/models/collision.rs +++ b/crates/benilla-formats/src/models/collision.rs @@ -57,7 +57,9 @@ pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result { 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 { let n = positions.len() as u32; let indices: Vec = 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 diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 1865e8c4..a9c1d111 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -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 = 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, Vec)> = 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); diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index 648aa2fa..da92d364 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -199,7 +199,7 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option { 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 { 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 { pub fn wmo_group_light_refs(group_bytes: &[u8]) -> Vec { 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() }) diff --git a/crates/benilla-formats/src/models/wmo/root.rs b/crates/benilla-formats/src/models/wmo/root.rs index 6f0fb612..81e9df8d 100644 --- a/crates/benilla-formats/src/models/wmo/root.rs +++ b/crates/benilla-formats/src/models/wmo/root.rs @@ -205,7 +205,9 @@ fn parse_wmo_group_infos(bytes: &[u8]) -> Vec { 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 { }; 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, Vec) { 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, Vec) { 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), diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs index 59d45958..6c7420ca 100644 --- a/crates/benilla-formats/src/terrain.rs +++ b/crates/benilla-formats/src/terrain.rs @@ -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], diff --git a/crates/benilla-formats/src/zone_map.rs b/crates/benilla-formats/src/zone_map.rs index 045c5842..01bcead1 100644 --- a/crates/benilla-formats/src/zone_map.rs +++ b/crates/benilla-formats/src/zone_map.rs @@ -34,7 +34,9 @@ pub fn load_zone_map(chain: &mut Chain, area_name: &str) -> Result> { ); } 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()) } diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index 320331b1..0f7d0763 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -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) = ( diff --git a/crates/benilla-m2/src/skin.rs b/crates/benilla-m2/src/skin.rs index 1d3a44b6..81be601b 100644 --- a/crates/benilla-m2/src/skin.rs +++ b/crates/benilla-m2/src/skin.rs @@ -93,7 +93,7 @@ impl M2Model { let read_u16s = |n: usize, o: usize| -> Result> { 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) diff --git a/crates/benilla-mpq/src/lib.rs b/crates/benilla-mpq/src/lib.rs index 489b854b..8b40451b 100644 --- a/crates/benilla-mpq/src/lib.rs +++ b/crates/benilla-mpq/src/lib.rs @@ -141,7 +141,9 @@ type Result = std::result::Result; /// Reinterpret a byte buffer (length a multiple of 4) as a `Vec` (little-endian) for table decrypt. fn to_u32s(bytes: &[u8]) -> Vec { 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 = 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"))) diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 9b5b1cb2..23932f11 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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**. /// diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index af0ddd08..11121f36 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -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, + /// 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, } /// 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, } impl super::UiScript { @@ -386,9 +396,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let (desc, obj) = { let model = lua.app_data_ref::().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::().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::().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::().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::().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::("SelectQuestLogEntry(1); return GetQuestLogRewardMoney()") + .unwrap(), + 40 + ); + assert_eq!( + s.eval::("SelectQuestLogEntry(2); return GetQuestLogRewardMoney()") + .unwrap(), + 250 + ); + assert_eq!( + s.eval::("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::( + "SelectQuestLogEntry(1); local d = GetQuestLogQuestText(); return d == ''" + ) + .unwrap()); + } + #[test] fn detail_getters_read() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs index 9208cacd..a5bfd48b 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs @@ -226,12 +226,18 @@ fn quest_item_hovers_render_the_rows_item_by_id() { assert_eq!(s.eval::("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"); diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index c9ec118d..d14a109a 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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)>("return GetQuestLogRewardSpell()") .unwrap() diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 20b3d60f..d1896bd3 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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::().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), diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index d78e8a5a..3a0278e5 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -667,9 +667,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let id = { let model = lua.app_data_ref::().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())) }; diff --git a/crates/benilla-wmo/src/lib.rs b/crates/benilla-wmo/src/lib.rs index 49091249..9fd1e09e 100644 --- a/crates/benilla-wmo/src/lib.rs +++ b/crates/benilla-wmo/src/lib.rs @@ -243,7 +243,7 @@ fn parse_root(b: &[u8]) -> Result { } // 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 { 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 { } } 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 { } } 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 { } } 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 { } } 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 { } // 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], diff --git a/crates/benilla-world/src/interact/pick.rs b/crates/benilla-world/src/interact/pick.rs index b3d5f8bb..554e389a 100644 --- a/crates/benilla-world/src/interact/pick.rs +++ b/crates/benilla-world/src/interact/pick.rs @@ -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::, |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::, |acc, t| Some(acc.map_or(t, |a| a.min(t)))), }; diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index f276001f..8bf6b52c 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -319,7 +319,9 @@ pub(crate) fn spawn_liquids<'a>( fn liquid_collider(lq: &LiquidMesh) -> Option { 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() { diff --git a/crates/benilla-world/src/particles/depthdump.rs b/crates/benilla-world/src/particles/depthdump.rs index d0320578..2963ba10 100644 --- a/crates/benilla-world/src/particles/depthdump.rs +++ b/crates/benilla-world/src/particles/depthdump.rs @@ -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 = 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 = 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); diff --git a/crates/benilla-world/src/terrain_stream/collider.rs b/crates/benilla-world/src/terrain_stream/collider.rs index bd68fcc7..2d9ec8b3 100644 --- a/crates/benilla-world/src/terrain_stream/collider.rs +++ b/crates/benilla-world/src/terrain_stream/collider.rs @@ -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]]), ); } diff --git a/crates/benilla-world/src/wmo_portal/audit/light_probe.rs b/crates/benilla-world/src/wmo_portal/audit/light_probe.rs index 7ba11c00..2de9880b 100644 --- a/crates/benilla-world/src/wmo_portal/audit/light_probe.rs +++ b/crates/benilla-world/src/wmo_portal/audit/light_probe.rs @@ -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), diff --git a/crates/benilla-world/src/wmo_portal/audit/mod.rs b/crates/benilla-world/src/wmo_portal/audit/mod.rs index cca61e98..28a1c043 100644 --- a/crates/benilla-world/src/wmo_portal/audit/mod.rs +++ b/crates/benilla-world/src/wmo_portal/audit/mod.rs @@ -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 = 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) = (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 { let mut best: Option = 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),