diff --git a/crates/benilla-app/src/capture/probes/ground_census.rs b/crates/benilla-app/src/capture/probes/ground_census.rs index 6856b4db..b48b749b 100644 --- a/crates/benilla-app/src/capture/probes/ground_census.rs +++ b/crates/benilla-app/src/capture/probes/ground_census.rs @@ -170,7 +170,7 @@ fn fire_ground_census( drop, swimming, format!( - "UGD {:#018x} {} {name:?} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} \ + "UGD {:#018x} {} {name:?} display={} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} \ drop={drop:+.2} terrain={} above={} spline={} swim={}", guid.0, // Which kind this row is: since B357 the listing is not all creatures, and a @@ -180,6 +180,10 @@ fn fire_ground_census( } else { "unit" }, + // The display id, so a row hands straight to `WOW_CLAMP_TRACE=` — the + // per-frame `clmp` readout is filtered by display and nothing else, and a census + // row that names only a guid cannot be followed up with it. + net.display_id.unwrap_or(0), wow[0], wow[1], wow[2], diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index f7d4dea4..db334cd5 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -82,7 +82,7 @@ use crate::entities::StandBoxHeight; use crate::net::{Embodied, Guid, NetEntity, SelfGuid}; use crate::ui_chat::{default_color, ChatEventKind}; use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect}; -use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas}; +use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, TextSeat, UiFontAtlas}; use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_assets::{AssetSet, WorldAssets}; use benilla_world::view::WorldCamera; @@ -745,6 +745,13 @@ fn draw_bubble( }, z + Z_TEXT, spec, + // The bubble frame seats on the DEVICE pixel grid ([`device_snap`], 1398) and slides with + // the speaker; its text must be rigid against it. Snapping the block top on the UI's + // logical grid instead made the text pop a whole px every second step the frame took — + // the V-plate's defect at the sibling site, exactly as 1398 found the snap itself missing + // here. Unlike the plate this rect is real (a margin box), so the degenerate-rect + // carve-out never covered it. + TextSeat::Exact, ); drop(e); if trace { diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index d4567622..34487f5d 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -366,6 +366,9 @@ pub(crate) fn float_combat_text( outline: Outline::None, alpha_gradient: None, }, + // A world overlay riding its own rise/fade seat — the UI grid never applied (the + // degenerate rect above already skipped it; this says so out loud). + crate::ui_text::TextSeat::Exact, ); drop(e); let (alpha_text, alpha_shadow) = fade_alpha(cat, elapsed_ms); diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 63cfdf2d..f0abc085 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -717,17 +717,6 @@ pub(crate) const REGISTERED: &[Registered] = &[ // `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is // the host, exactly like `cameraSmoothTrackingStyle` above it. same("cameraTargetSmoothSpeed", "90"), - // **`cameraWaterCollision`** `[0xbe1088]` "1" (`0x50bd63`) — one of the two that ship ON, and - // this row is its SECOND life. 2149 registered it against a pivot corridor built on - // `camera-arm-liquid-blind.md`'s "the arm never touches liquid"; 2165 took that back out after - // the corridor moved the framing pivot 19/18 yd in one frame, and wow-re's - // `water-band-discontinuity.md` then refuted the verdict itself: `0x50e5ec` ORs the `0xf0000` - // ADT-liquid nibble into the trace mask ALL THREE of `0x50e570`'s queries carry — the arm sweep - // included — where `0x69cc13` gates a per-layer intersection over the chunk's four MCLQ slots. - // A capability requested through an argument flag is invisible to a census of call sites, which - // is how a correct, controlled census published the wrong negative. The reader is the camera - // boom's collision filter; the corridor is the reference's second consumer and is not rebuilt. - same("cameraWaterCollision", "1"), // `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the // four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism: // wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and @@ -1463,7 +1452,6 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { // reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in // `benilla_ui`'s `SetCVar` path, not here. "camerapivot" => knobs.camera_opts.pivot = v != 0.0, - "camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0, "camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v, "camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v, "cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v, @@ -1850,7 +1838,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 85] = [ + let session: [(&str, String); 84] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1895,7 +1883,6 @@ fn sync_cvars( "cameraTargetSmoothSpeed", camera_opts.target_smooth_speed.to_string(), ), - ("cameraWaterCollision", flag(camera_opts.water_collision)), ("cameraTerrainTilt", flag(camera_opts.terrain_tilt)), ( "cameraGroundSmoothSpeed", @@ -2420,14 +2407,6 @@ mod tests { let camera_opts = crate::player::camera_dynamics::CameraOptions::default(); assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot); assert!(camera_opts.pivot, "the binary registers cameraPivot \"1\""); - assert_eq!( - d["cameraWaterCollision"] != 0.0, - camera_opts.water_collision - ); - assert!( - camera_opts.pivot && camera_opts.water_collision, - "the binary registers cameraPivot and cameraWaterCollision both \"1\"" - ); assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt); assert!( !camera_opts.terrain_tilt, diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 4e9d7373..9daa623e 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -88,6 +88,11 @@ type MotionReadout = ( Option<&'static crate::net::Spline>, Option<&'static crate::net::RemoteMotion>, Option<&'static crate::net::UnitMoveModes>, + // The clamp's memo, for the card's `ground` line — the only place a "this mob is under the + // world" sighting becomes a measurement instead of a picture. + Option<&'static crate::net::GroundClamped>, + // Where the unit is standing *now*, which the `ground` line reads the terrain against. + &'static Transform, ); /// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render @@ -134,6 +139,9 @@ pub(super) struct InspectStores<'w, 's> { collision: Query<'w, 's, GoCollisionReadout>, lit: Query<'w, 's, EntityLightReadout>, motion: Query<'w, 's, MotionReadout>, + /// The MCNK heightfield under the hovered unit — the `ground` line's third number, and the one + /// that separates "the server put it there" from "we sank it". + points: benilla_world::world_point::WorldPoint<'w, 's>, factions: Option>, self_store: Query<'w, 's, &'static ObjectStore, With>, /// The live standings — the other half of the reaction resolve (`ring_reaction` takes the @@ -321,12 +329,13 @@ pub(super) fn inspect_ui( ) } }; - let (stores, kinds, collision, lit, motion, go_anims) = ( + let (stores, kinds, collision, lit, motion, points, go_anims) = ( &stores.stores, &stores.kinds, &stores.collision, &stores.lit, &stores.motion, + &stores.points, &stores.go_anims, ); let store = net_entity.and_then(|p| stores.get(p).ok()); @@ -562,7 +571,7 @@ pub(super) fn inspect_ui( let motion_line = net_entity .filter(|_| is_unit) .and_then(|p| motion.get(p).ok()) - .map(|(spline, remote, modes)| { + .map(|(spline, remote, modes, _, _)| { let moving = match (spline, remote) { (Some(sp), _) => format!( "motion {:.2} yd/s · {} path {:.0}% of {:.0}s", @@ -581,6 +590,42 @@ pub(super) fn inspect_ui( None => moving, } }); + // **Where its feet came from** ([`crate::net::GroundClamped`]) — the ground clamp's own memo, + // shown because "that mob is standing inside the hill" is a claim about three numbers and no + // screenshot carries them: + // + // `ground z 12.34 · seat 12.42 (drop +0.08) · terrain 12.34 · walk hit` + // + // - **`z`** is where we are drawing it; **`seat`** is the pose the server last wrote, before + // the clamp had its say, and **`drop`** = `seat − z` is the clamp's own correction. A big + // positive drop is *ours*; a `z` far under `terrain` with `drop ≈ 0` is the server's. + // - **`terrain`** is the MCNK height under it — so "is it under the world?" reads off the card + // rather than off a guess about what the hill looks like from here. + // - **the arm and the probe verdict**: `walk` is the swept step continuing a server path, + // `idle` the settle from the seat, and they answer a MISS differently — an idle miss leaves + // the unit at its seat, a walk miss descends. `MISS` is upper-case because on the `walk` arm + // it is the interesting state, not a neutral one. + // + // The same three numbers `WOW_GROUND_CENSUS` prints per unit, on the unit the director is + // actually pointing at: the census answers "is anything sunk in this scene", this answers "why + // is *that* one". + let ground_line = net_entity + .filter(|_| is_unit) + .and_then(|p| motion.get(p).ok()) + .and_then(|(_, _, _, clamped, t)| clamped.map(|c| (c, t))) + .map(|(c, t)| { + let z = t.translation.y; + format!( + "ground z {z:.2} · seat {:.2} (drop {:+.2}) · terrain {} · {} {}", + c.seat_y, + c.seat_y - z, + points + .terrain_height_under(t.translation) + .map_or_else(|| "none".to_string(), |v| format!("{v:.2}")), + if c.walking() { "walk" } else { "idle" }, + if c.hit { "hit" } else { "MISS" }, + ) + }); // An `AnimationData` id as the card names it — shared by the creature and GameObject anim // lines below, which read the same id space. let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) { @@ -672,6 +717,9 @@ pub(super) fn inspect_ui( if let Some(line) = &motion_line { lines.push(line.clone()); } + if let Some(line) = &ground_line { + lines.push(line.clone()); + } if let Some(line) = &anim_line { lines.push(line.clone()); } diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 0c30291f..da5cd612 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -79,7 +79,7 @@ use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback}; use crate::names::NameCache; use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer}; use crate::target::{ring_reaction, ring_variant, CombatFlash, Factions, RingVariant, Selection}; -use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas}; +use crate::ui_text::{layout_text_quads, FontSpec, Justify, TextSeat, UiFontAtlas}; use benilla_world::view::WorldCamera; /// The height-scale law (`0x6c6e90`): `d > KNEE ? d/KNEE · RATE · FLOOR : FLOOR`, `d` the anchor's @@ -339,6 +339,10 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { outline: Outline::None, alpha_gradient: None, }, + // A world billboard: the glyphs are re-seated by ink into name-local pitch units two + // statements below, so the UI grid never had anything to say here (the degenerate + // rect above already skipped it — this only says so out loud). + TextSeat::Exact, ); // Recenter the ink box on x = 0, then normalize px → pitch units, flipping y-down px into // y-up locals within this line's band. diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 0608ea4e..5cb585f2 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -680,7 +680,17 @@ pub(in crate::net) fn extrapolate_remote_units( steep: false, }, ); - g.center + // **The no-floor drop is declined here too** (decision 2174, closing 1545's own + // residual). 1545 named this exact defect — *"`grounded_step`'s no-hit branch + // spends that whole reach descending — right for the local mover, whose next frame + // elects a real fall, and open-loop for a remote, which has no fall election at + // all"* — and closed only the flag-still case with the `INTEGRATED` gate; a mover + // that is *moving* kept ratcheting. A miss means our world could not answer, so the + // frame keeps the height the last packet gave and takes only the horizontal. + match g.unsupported { + Some(_) => Vec3::new(g.center.x, from.y, g.center.z), + None => g.center, + } }; let resolved = bevy_to_wow(resolved_center - half_h); held = (pos[0] - resolved[0]).hypot(pos[1] - resolved[1]); @@ -1135,14 +1145,62 @@ mod under_floor { ); } - /// The control: 0626's resolve is untouched for a mover the reference *does* integrate. + /// The control: 0626's resolve is untouched for a mover the reference *does* integrate — the + /// `INTEGRATED` gate lets it through and it is stepped through the world. + /// + /// **Its Z assertion is 2174's, and it is the opposite of the one 1545 wrote here.** 1545 + /// proved "integrated and resolved" by watching this mover *descend*, which works because four + /// seconds of FORWARD walk it clean off the 10x10 terrain quad — so what it was actually + /// pinning, past the edge, was `grounded_step`'s no-floor drop running open-loop, the very + /// ratchet 1545's own comment called out two screens up and closed only for a flag-still mover. + /// Past the edge our world has nothing to answer with, so the wire's height is the answer. #[test] fn a_moving_mover_still_meets_the_world() { let (mut app, e) = half_arrived_world(move_flags::FORWARD); frames(&mut app, 240); + assert_eq!( + z_of(&app, e), + WIRE_Y, + "the terrain 2.10 yd down is outside any walking frame's election reach, so there is \ + no floor of ours to find and the wire's height stands — instead of a per-frame \ + descent that nothing here would ever end (2174)" + ); + } + + /// …and the **vertical** half of that resolve still runs, which is what 2174 must not have + /// taken away with the drop: a moving mover with ground actually inside the election's reach is + /// put on it. Without this the test above would be satisfied by an extrapolator that had + /// stopped meeting the world at all. + #[test] + fn a_moving_mover_is_still_settled_onto_ground_it_can_see() { + let (mut app, e) = half_arrived_world(move_flags::FORWARD); + // This harness's mover carries no speeds, so a direction bit alone travels nowhere and the + // election's reach collapses to its slack. Give it a walk speed: now the frame travels + // 0.033 yd and reaches `·1.8494 + 1/36` = 0.089 below the feet. + app.world_mut().entity_mut(e).insert(crate::net::UnitSpeeds( + benilla_protocol::events::MoveSpeeds { + walk: 2.0, + run: 2.0, + run_back: 2.0, + swim: 2.0, + swim_back: 2.0, + turn_rate: 0.0, + }, + )); + // A floor 0.06 under the wire Z: past the standing slack (0.028), inside the moving reach. + floor_at(&mut app, WIRE_Y - 0.06); + app.world_mut().resource_mut::().bump(); + app.update(); + frames(&mut app, 4); + let z = z_of(&app, e); assert!( - z_of(&app, e) < WIRE_Y, - "a mover carrying a direction bit is integrated and resolved, as it was before 1545" + z < WIRE_Y, + "the floor is inside a moving frame's reach, so the election still settles the body \ + onto it — 2174 declines the no-FLOOR drop, never the resolve (z={z:.3})" + ); + assert!( + z > WIRE_Y - 0.5, + "…and it settles ONTO that floor rather than carrying on down (z={z:.3})" ); } diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 9c5a4359..33f2c8d1 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -736,6 +736,22 @@ pub(in crate::net) fn ground_clamp_creatures( let t0 = cost.then(std::time::Instant::now); let (mut visited, mut skipped, mut held, mut cast, mut swept, mut hit_n, mut moved) = (0u32, 0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + // **The walker arm's two ground numbers** (decision 2174). + // + // - `noflr` — walker frames whose swept step found no walkable floor at all. Expected nonzero + // while the world streams (our colliders arrive after the units do) and it is *harmless*: + // such a frame holds the server's pose. It is the streaming-race health number. + // - `ratchet` — walker frames that found no floor and were nonetheless left BELOW the server's + // seat. **This is a tripwire and it must read 0.** It cannot happen by construction now — the + // miss branch writes `seat_y` verbatim — so any nonzero here means the no-floor drop has + // found its way back into this arm, which is what walked Stormwind's patrolling guards 31 yd + // down through the world (census `worst=+31.45`, one guard's `clmp` at 0.105 yd a frame). + // `deepest` and `worst` name the offender, because a count alone cannot be reconciled against + // `WOW_GROUND_CENSUS`'s per-unit rows. + // + // (The idle arm's miss is not either event: it has always left the unit at its seat.) + let (mut noflr, mut ratchet, mut deepest) = (0u32, 0u32, f32::NEG_INFINITY); + let mut worst: Option<(u32, [f32; 2], f32)> = None; // What re-armed each cast (1384): the unit's own seat moved, or the world's colliders changed // under a unit that didn't. The second must be ~0 in a settled scene — a nonzero steady-state // `armed` means the collider set is churning and the gate is holding nothing. @@ -893,7 +909,26 @@ pub(in crate::net) fn ground_clamp_creatures( steep: false, }, ); - let y = g.center.y - half_h.y; + // **A walker whose sweep found no floor holds the server's pose** (decision 2174) — + // the same law the idle branch below obeys, and the one [`grounded_y`] states in as + // many words: a probe miss is either a genuinely airborne pose or ground that has not + // streamed in, and an unclamped body belongs exactly where the server said. Taking + // `grounded_step`'s no-floor drop instead made this arm a ratchet: it sweeps again next + // frame from *this* answer, and once the body is under the surface the down-cast can + // never find the terrain above it again (one-sided) and the step-up can never fire + // (it needs a blocking horizontal contact, which a flat up-wound face never gives). It + // ran Stormwind's patrolling guards 31 yd below their own seat, at 0.1 yd a frame, + // until the next `SMSG_MONSTER_MOVE` re-based them. + // + // 2018's hill is untouched: a chord cutting under a rise meets that rise on the + // horizontal leg and the sweep HITS, so it rides as before. This arm is only what + // happens when our world cannot answer at all — and then the server is the only + // authority there is. + let y = if g.unsupported.is_some() { + seat_y + } else { + g.center.y - half_h.y + }; if benilla_assets::trace::enabled_for("clmp") && clamp_trace_display().is_some_and(|d| net.display_id == Some(d)) { @@ -911,6 +946,16 @@ pub(in crate::net) fn ground_clamp_creatures( ), ); } + if g.unsupported.is_some() { + noflr += 1; + if seat_y - y > 1.0e-4 { + ratchet += 1; + if seat_y - y > deepest { + deepest = seat_y - y; + worst = Some((net.display_id.unwrap_or(0), xz, y)); + } + } + } (y, g.ground.is_some()) } else { let origin = Vec3::new(t.translation.x, seat_y + IDLE_UP_BAND, t.translation.z); @@ -998,7 +1043,12 @@ pub(in crate::net) fn ground_clamp_creatures( // Per 0734's law (~10.5 ns per row visit), the walk itself is never the cost here: at ~800 // units it is ~8 µs. Only `ms` justifies the slice — quote it, not the counts. eprintln!( - "[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} swept={swept} reseat={reseat} armed={armed} hit={hit_n} moved={moved} ms={:.3}", + "[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} swept={swept} reseat={reseat} armed={armed} hit={hit_n} moved={moved} noflr={noflr} ratchet={ratchet} deepest={} worst={} ms={:.3}", + if deepest.is_finite() { format!("{deepest:+.2}") } else { "-".to_string() }, + worst.map_or_else( + || "-".to_string(), + |(d, xz, y)| format!("display={d}@({:.0},{:.0}) y={y:.2}", xz[0], xz[1]) + ), t0.elapsed().as_secs_f32() * 1000.0 ); } @@ -1153,10 +1203,10 @@ pub(crate) struct GroundClamped { /// The Y standing after that cast (written or left). It differs from [`Self::seat_y`] by /// exactly the clamp's own correction, so an *external* write is detectable as "the Y here is /// not the one I left", which re-seats. - y_written: f32, + pub(crate) y_written: f32, /// Only a HIT caches: a miss keeps casting, so a tile whose collider streams in late still /// catches its standing units. - hit: bool, + pub(crate) hit: bool, /// The collider-set stamp the answer was computed against — a cached answer outlives neither /// the unit's own pose nor the world it described. epoch: u64, @@ -1173,6 +1223,16 @@ pub(crate) struct GroundClamped { path: Option<(u32, Instant)>, } +impl GroundClamped { + /// **Which arm produced this answer** — `true` for the walker's swept step (this unit is + /// continuing a server path), `false` for the idle settle from the seat. The two obey different + /// laws on a probe MISS, so a readout that names [`Self::hit`] without naming the arm is + /// ambiguous: an idle miss leaves the unit at its seat, a walker miss descends. + pub(crate) fn walking(&self) -> bool { + self.path.is_some() + } +} + /// The reference's `0x6030c0` decision for one creature, as a pure function of everything it /// reads — [`CreatureSwimming`]'s law in one place, so both asymmetries are assertable without a /// world. Byte-VERIFIED whole (wow-re `collision/scratch/remote-swim-decision.md` §2/§3): diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index b0b38b8b..5290ad0f 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -277,6 +277,8 @@ pub(super) fn pill_quads( outline: Outline::None, alpha_gradient: None, }, + // Our own dev overlay: measured off a degenerate rect, then shifted into the pill. + crate::ui_text::TextSeat::Exact, ); drop(e); let bounds = glyphs diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index d04d3b3f..bef41166 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -1429,22 +1429,7 @@ pub(super) fn seat_camera( let boom_len = boom.length().max(1.0e-3); // The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes, // drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh. - // - // **And the waterline, under `cameraWaterCollision`** — registered `"1"`, so this is on out of - // the box. It is a change to the trace's MASK and nothing else, which is the shape the - // reference gives it (`0x50e5ec` ORs the `0xf0000` ADT-liquid nibble into the word all three of - // `0x50e570`'s queries carry). Decision 2149 read the arm as liquid-blind and built a pivot - // corridor in its place; wow-re's `water-band-discontinuity.md` refuted that — the nibble - // reaches `0x69cc13` through four direct calls and gates a per-layer intersection over the - // chunk's four MCLQ slots, so the sweep hits bare water and cannot tell it from ground. - let hit = collide.cast_camera( - cam_probe, - head, - Quat::IDENTITY, - boom, - 0.0, - dynamics.options.water_collision, - ); + let hit = collide.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0); // The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the // driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never // pivots. Written here because here is the only place that knows. diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index 3260b511..070258f3 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -71,19 +71,6 @@ pub(crate) struct CameraOptions { pub(crate) pivot_dy_min: f32, /// `cameraTargetSmoothSpeed` — see [`TARGET_SMOOTH_SPEED_DEFAULT`]. pub(crate) target_smooth_speed: f32, - /// **`cameraWaterCollision`** — registered **"1"**, so this is ON out of the box. - /// - /// One consumer, and it is a **trace mask**: it adds the liquid layer to the camera boom's - /// collision filter, so the arm stops at a waterline. Decision 2149 built a pivot corridor - /// instead, on `camera-arm-liquid-blind.md`'s verdict that the arm never touches liquid; that - /// verdict is **refuted** by wow-re's `water-band-discontinuity.md` (the capability is - /// requested through an argument flag, which no census of call sites could see), and the - /// corridor it built had a 19/18 yd discontinuity that 2165 took back out. - /// - /// The corridor is the CVar's *second* consumer in the reference and is **not** rebuilt here - /// yet: it is only safe once the framing pivot's depth is pinned the way the reference pins it, - /// and that is a separate piece of work with its own continuity sweep to earn. - pub(crate) water_collision: bool, /// `cameraTerrainTilt` — registered **"0"**, so Follow Terrain is OFF out of the box. pub(crate) terrain_tilt: bool, /// `cameraGroundSmoothSpeed`, deg/s — the ground channel's rate (`[0xbe0fc0]`, `"7.5"`). @@ -110,7 +97,6 @@ impl Default for CameraOptions { pivot_dx_max: PIVOT_DX_MAX_DEFAULT, pivot_dy_min: PIVOT_DY_MIN_DEFAULT, target_smooth_speed: TARGET_SMOOTH_SPEED_DEFAULT, - water_collision: true, terrain_tilt: false, ground_smooth_speed: GROUND_SMOOTH_SPEED_DEFAULT, tilt_time_min: TILT_TIME_MIN_DEFAULT, diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index d48bff0f..c3ebca45 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -410,16 +410,21 @@ pub(super) fn step( // The step-up probe (this is the LOCAL mover; a remote's dead-reckon is not a report // anyone is looking at): a walk frame that went nowhere writes the `stup` deep report — // the surface profile ahead, the advance ladder, the candidate faces. + // **The local controller is the caller that HAS a fall** (decision 2174), so the no-floor + // drop is spent here: `pos.z -= achieved` before the classify, exactly as `0x636e45` does, + // and the frame that leaves a ledge starts its fall lower instead of flat. The two + // open-loop callers decline it — they have nothing that would ever end it. + let resolved = g.center - Vec3::Y * g.unsupported.unwrap_or(0.0); super::step_probe::watch( world, capsule, center, - g.center, + resolved, player.horiz_vel, dt, time.elapsed_secs(), ); - center = g.center; + center = resolved; climb = g.climb; snap_probe = g.snap; player.steep_support = g.steep_support; @@ -652,6 +657,22 @@ pub(crate) struct GroundedStep { /// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up /// took the frame instead. The inner pair is `(hit distance, hit normal.y)`. pub(crate) snap: Option<(f32, Option<(f32, f32)>)>, + /// **The fall's opening drop, when the election found NO floor at all** — `Some(yd)` iff the + /// probe came back empty and the body is not mid-ride, `None` on every frame a surface was + /// found (including a step-up commit and a mid-ledge steep support, which both leave + /// [`Self::ground`] `None` for their own reasons and must not be confused with this). + /// + /// It is **not applied to [`Self::center`]** (decision 2174). The reference's finalize does + /// write `pos.z -= achieved` on its no-hit leg — and then *classifies*, electing a fall that + /// gravity finishes and a landing ends. That election is the **caller's**, and only one of this + /// function's three callers has one: the local controller ([`step`]) applies this drop and + /// falls. The creature ground clamp and the remote dead-reckon have no fall to elect and sweep + /// again next frame from their own answer, so for them the drop is not an opening — it is a + /// ratchet with no floor to end it, and it walked Stormwind's patrolling guards 31 yd down + /// through the world while their colliders were still streaming in. They read this as "our + /// world could not answer" and hold the server's pose instead, which is the law their idle + /// paths already obey ([`crate::net::motion::spline::grounded_y`]). + pub(crate) unsupported: Option, } /// **One grounded walk step, resolved against the world** — step-up → slide → election snap, from @@ -887,6 +908,8 @@ pub(crate) fn grounded_step( let snap = Some((reach, floor.map(|(d, n, _)| (d, n)))); let mut ground = None; let mut steep_support = false; + // The no-floor drop, measured here and spent by whoever has a fall to elect (see the field). + let mut unsupported = None; // **A ride's height is earned; the snap follows ground *down*, it never undoes a climb.** // // This is where our capsule and the reference's cone part company, and it has to be said out @@ -977,7 +1000,10 @@ pub(crate) fn grounded_step( // the director was still feeling after 1132 capped the other two legs: their fence // step-downs came through *this* branch, `snap miss (reach 1.25) dy=-1.250`, five of them in // one capture. The cap is a property of our body, not of which leg found the floor. - slid.y -= (reach - surface_offset).max(0.0).min(cone_reach); + // **Measured, not applied** (decision 2174) — [`GroundedStep::unsupported`] is the whole + // story: this is the first frame of a fall, and only a caller that can finish one may + // spend it. + unsupported = Some((reach - surface_offset).max(0.0).min(cone_reach)); } GroundedStep { center: slid, @@ -994,6 +1020,7 @@ pub(crate) fn grounded_step( steep_support: ((rode || popped.is_some()) && ground.is_none()) || steep_support, ground, snap, + unsupported, } } diff --git a/crates/benilla-app/src/smart_rect.rs b/crates/benilla-app/src/smart_rect.rs index b8ff3798..adb33d4e 100644 --- a/crates/benilla-app/src/smart_rect.rs +++ b/crates/benilla-app/src/smart_rect.rs @@ -179,29 +179,42 @@ fn band_flags(r: Rect, viewport: Vec2) -> u8 { /// `0x509bf0` mode 0: the flags PLUS a size-preserving clamp of each offending edge back to its /// band boundary (X to the screen edge, Y to the band edge) — the solve's seed rect. +/// +/// **The offending edge is ASSIGNED the boundary and its opposite derived from the size** — the +/// law's own spelling (`bit2 → top = Y_hi; bottom = Y_hi − height`), and not the equivalent +/// translate-by-the-gap this shipped with, because the two are not equivalent in `f32` and the +/// difference is a whole plate jumping 19 px for one frame (decision 2172). +/// +/// The seed this returns is immediately re-tested by [`band_flags`] inside [`SmartBucket::solve`] +/// (the per-node skip, `0x509bf0` mode 1), and the boundary test is a strict `<`/`>`, so a seed +/// sitting EXACTLY on the boundary passes. `r.min.y + (band - r.min.y)` does not land exactly on +/// `band`: for most inputs it does, and for the ones where the subtraction loses a bit it lands +/// one ULP below — the seed then re-fires its own flag, every try is skipped, the worklist empties +/// and the solve returns the UNCLAMPED input, which the seat tail clamps to half a plate from the +/// screen top instead. Measured on a Goldshire walk (the `vpl` trace): a plate riding the near-top +/// band at 27.54 px dropped to 9.0 for exactly one frame and came back, three times in ten +/// seconds — at `scr.y = 8.064` and nowhere either side of it, which is the signature of a +/// rounding tie rather than of any law. fn clamp_to_bands(r: Rect, viewport: Vec2) -> (Rect, u8) { let diag = viewport.length(); let flags = band_flags(r, viewport); + let (w, h) = (r.width(), r.height()); let mut r = r; if flags & 1 != 0 { - let d = -r.min.x; - r.min.x += d; - r.max.x += d; + r.min.x = 0.0; + r.max.x = w; } if flags & 2 != 0 { - let d = r.max.x - viewport.x; - r.min.x -= d; - r.max.x -= d; + r.max.x = viewport.x; + r.min.x = viewport.x - w; } if flags & 4 != 0 { - let d = DDC_NEAR_TOP * diag - r.min.y; - r.min.y += d; - r.max.y += d; + r.min.y = DDC_NEAR_TOP * diag; + r.max.y = r.min.y + h; } if flags & 8 != 0 { - let d = r.max.y - (viewport.y - DDC_NEAR_BOTTOM * diag); - r.min.y -= d; - r.max.y -= d; + r.max.y = viewport.y - DDC_NEAR_BOTTOM * diag; + r.min.y = r.max.y - h; } (r, flags) } @@ -291,6 +304,37 @@ mod tests { r } + /// **A plate riding the near-top band seats at the band edge, at EVERY approach height.** + /// + /// The seed clamp puts an offending edge on its band boundary and the per-node skip then + /// re-tests that same rect against the same boundary with a strict compare, so the clamp has + /// to land ON it, exactly. Translating by the gap instead lands one ULP below for some inputs; + /// the seed then skips itself, the worklist empties, `solve` returns the UNCLAMPED input and + /// the seat tail parks it half a plate from the screen top — 19 px above where the previous + /// frame drew it, for one frame, and back. + /// + /// The walk is the live one that found it: a 73×18 plate at the probe's 640×360 viewport, + /// approaching the band from above a tenth of a pixel at a time. `scr.y = 8.064` is the value + /// that popped on the director's screen; the sweep is here so the next such value cannot. + #[test] + fn a_plate_in_the_near_top_band_never_pops_to_the_seat_clamp() { + const VP2: Vec2 = Vec2::new(640.0, 360.0); + let band = DDC_NEAR_TOP * VP2.length(); + for step in 0..2800 { + #[allow(clippy::cast_precision_loss)] + let top = step as f32 * 0.01; + let desired = Rect::new(172.0, top, 245.0, top + 18.0); + let got = SmartBucket::default().resolve(desired, VP2); + let want = if top < band { band } else { top }; + assert!( + (got.min.y - want).abs() < 1e-3, + "a plate seeded at y={top} seats at {} — the band edge is {band} \ + (a 19px pop means the seed skipped itself)", + got.min.y + ); + } + } + /// A free rect adopts unmoved on its first try — the every-frame fast path. #[test] fn free_rect_stays_put() { diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index d2cfde64..b39e753b 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1866,6 +1866,7 @@ fn convert_entry( shadow, outline, alpha_gradient, + world_seat, } => { // No atlas (no client data / Friz Quadrata unreadable — see `ui_text`'s startup // system) means text simply doesn't render, same graceful-absence posture as a @@ -1887,6 +1888,7 @@ fn convert_entry( shadow, outline, alpha_gradient, + world_seat, }, text::TextHost { z: eq.z, diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index eaa3b828..b3bfbfb5 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -11,7 +11,7 @@ use benilla_ui::script::{EditBoxTextUi, FontShadow, JustifyH, JustifyV, Outline} use benilla_ui::widget::FrameHandle; use crate::ui_pass::{UiQuad, UvRect}; -use crate::ui_text::{layout_text_quads, layout_text_quads_links, UiFontAtlas}; +use crate::ui_text::{layout_text_quads, layout_text_quads_links, TextSeat, UiFontAtlas}; /// `WOW_TEXT_PROBE=1` — launch-time knob, read once (the check ran per Text quad per frame). static TEXT_PROBE: std::sync::LazyLock = @@ -29,6 +29,9 @@ pub(super) struct TextStyle { pub shadow: Option, pub outline: Outline, pub alpha_gradient: Option<(f32, f32)>, + /// The region's seat claim (`benilla_ui`'s `RegionData::world_seat`) — true for the V-plate's + /// name and level, which are rigid to a device-snapped overlay rather than to the UI grid. + pub world_seat: bool, } /// The host-loop context one Text quad draws under: the extracted quad's identity (z/alpha/ @@ -71,6 +74,14 @@ pub(super) fn emit( link_spans: &mut Vec<(FrameHandle, benilla_ui::layout::Rect, String, String)>, ) { let base_color = style.color.unwrap_or([1.0, 1.0, 1.0, 1.0]); + // Which pixel grid this block's top may land on (decision 2172): the interface's, or none — + // the WorldFrame overlays carry their own device-pixel seat and their text has to be rigid + // to it. The shadow pass below inherits it, like every other layout input. + let seat = if style.world_seat { + TextSeat::Exact + } else { + TextSeat::UiGrid + }; let spec = crate::ui_text::FontSpec { path: style.font.as_deref(), // The drawn px under the two size regimes × the 768-virtual scale × the owner's frame @@ -249,6 +260,7 @@ pub(super) fn emit( draw_justify, host.z, shadow_spec, + seat, ); for q in &mut sq { // Flatten rgb to the shadow color (markup tints ride the fill only); the alpha is @@ -282,6 +294,7 @@ pub(super) fn emit( }, host.z, spec, + seat, &mut spans, ); for sp in spans { @@ -308,6 +321,7 @@ pub(super) fn emit( draw_justify, host.z, spec, + seat, ) }; for q in &mut glyphs { @@ -321,21 +335,43 @@ pub(super) fn emit( // (`fontstring-vertical-placement.md`): compare `ink` against the law's `d + ascender` seat // when hunting a vertical offset. Fill quads only (the shadow pass above would smear the // bounds one px down-right). - if probe && !glyphs.is_empty() { + let vpl = style.world_seat && benilla_assets::trace::enabled_for("vpl"); + if (probe || vpl) && !glyphs.is_empty() { let (mut y0, mut y1) = (f32::MAX, f32::MIN); for q in &glyphs { y0 = y0.min(q.rect.min.y); y1 = y1.max(q.rect.max.y); } - info!( - "seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}", - host.rect.min.y, - y0, - y1, - y0 - host.rect.min.y, - y1 - host.rect.min.y, - &draw_text[..draw_text.len().min(20)] - ); + // **Where the plate's text actually inked**, on the same `vpl` tag the driver's `plate=` + // seat and the border's `paint=` line ride ([`crate::vplates`], decision 2168's + // measurement). The question the three lines answer together is not "does the text + // move?" — it is "does it move WITH the border?", which is a difference of two numbers + // per frame. Decision 2168 §5 named this line as the missing third; 2172 is what it + // found when it was built. + if vpl { + benilla_assets::trace::line( + "vpl", + &format!( + "text x={:.3} rect={:.3} ink={:.3} rel={:.3} txt={}", + host.rect.min.x, + host.rect.min.y, + y0, + y0 - host.rect.min.y, + draw_text.chars().take(16).collect::() + ), + ); + } + if probe { + info!( + "seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}", + host.rect.min.y, + y0, + y1, + y0 - host.rect.min.y, + y1 - host.rect.min.y, + draw_text.chars().take(20).collect::() + ); + } } out.extend(glyphs); // The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture — diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs index b11d91b0..5def5670 100644 --- a/crates/benilla-app/src/ui_script/guild_tests.rs +++ b/crates/benilla-app/src/ui_script/guild_tests.rs @@ -1236,9 +1236,10 @@ fn removing_a_member_names_them_in_the_confirm() { /// 5.0→5.1 vararg swap.** The one place the window and the engine are coupled by *arity* rather /// than by a name. /// -/// The reference walks the 5.0 vararg table with `for i = 1, arg.n`; ours walks -/// `for i = 1, select("#", ...)`. The two agree only while every one of the thirteen returns is -/// actually pushed — and with no rank loaded, ALL THIRTEEN ARE NIL (era booleans are `1`/`nil`). +/// The reference walks the 5.0 vararg table with `for i = 1, arg.n`, and that stock file is the +/// one this window loads, so the checkbox loop's bound *is* whatever the binding pushed. The two +/// agree only while every one of the thirteen returns is actually pushed — and with no rank +/// loaded, ALL THIRTEEN ARE NIL (era booleans are `1`/`nil`). /// A binding that returned "as many values as are true" would look identical at every other /// assertion in this file, load without an error, and leave checkboxes stale from the last rank. /// So this one deliberately does NOT install the fixture: it asks the real `script::guild`. @@ -1247,8 +1248,7 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() { let _data = benilla_formats::wow_data_or_skip!(); let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select(\"#\", GuildControlGetRankFlags())") - .unwrap(), + s.arity("GuildControlGetRankFlags()").unwrap(), 13, "an unloaded buffer is all-nil, and every one of the thirteen must still be pushed — \ `GuildControlCheckboxUpdate` drives checkbox i off argument i and nothing else" @@ -1256,9 +1256,12 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() { // …and each of them is nil, not `false`: the era boolean law, which `SetChecked` reads. assert_eq!( s.eval::( - "local n = 0 \ - for i = 1, 13 do if select(i, GuildControlGetRankFlags()) ~= nil then n = n + 1 end end \ - return n" + "local function count(...) \ + local n = 0 \ + for i = 1, 13 do if arg[i] ~= nil then n = n + 1 end end \ + return n \ + end \ + return count(GuildControlGetRankFlags())" ) .unwrap(), 0 @@ -1352,16 +1355,14 @@ fn the_roster_binding_answers_ten_values_and_the_tenth_is_status() { let _data = benilla_formats::wow_data_or_skip!(); let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select(\"#\", GetGuildRosterInfo(1))") - .unwrap(), + s.arity("GetGuildRosterInfo(1)").unwrap(), 10, "an out-of-range index still pushes all ten — the reference calls this with \ GetGuildRosterSelection(), which is 0 whenever nothing is selected, on every \ GuildStatus_Update pass before it ever checks `> 0`" ); assert_eq!( - s.eval::("return select(\"#\", GetGuildRosterInfo(0))") - .unwrap(), + s.arity("GetGuildRosterInfo(0)").unwrap(), 10, "…including index 0, the nothing-selected case the reference passes unguarded" ); diff --git a/crates/benilla-app/src/ui_script/loot_tests.rs b/crates/benilla-app/src/ui_script/loot_tests.rs index da8b4af8..c96ed104 100644 --- a/crates/benilla-app/src/ui_script/loot_tests.rs +++ b/crates/benilla-app/src/ui_script/loot_tests.rs @@ -1140,8 +1140,11 @@ fn loot_row_awaiting_its_template_opens_clean() { .unwrap(); assert_eq!((item.as_str(), quantity, quality), ("", 1, -1)); assert!( - s.eval::("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil") - .unwrap(), + s.eval::( + "local _, _, _, quality = GetLootSlotInfo(1) \ + return ITEM_QUALITY_COLORS[quality] ~= nil" + ) + .unwrap(), "the cache-miss quality must be a real row of ITEM_QUALITY_COLORS" ); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index 02d23056..101d0db7 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -326,11 +326,7 @@ fn get_ping_position_answers_two_numbers_always() { load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml"); load_xml(&s, "Interface\\FrameXML\\Minimap.xml"); - assert_eq!( - s.eval::("return select('#', Minimap:GetPingPosition())") - .unwrap(), - 2 - ); + assert_eq!(s.arity("Minimap:GetPingPosition()").unwrap(), 2); assert_eq!( s.eval::("return (Minimap:GetPingPosition())").unwrap(), 0.0, diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 842fe9c7..facb360b 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -4402,6 +4402,20 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[ one step further back than its guild twin: the rank byte streams, but the key's second \ index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet", ), + ( + "cameraWaterCollision", + "REVERTED 2026-09-10, and the row is back because the feature is not. Two consumers were \ + transcribed off `pivot-height-glide.md` §5: (1) the collision sweep's class mask \ + becomes `0x1f0171` instead of `0x100171` — the `0xf0000` nibble is the ADT liquid \ + layers — and `0x511ad0`'s bands re-base the pivot floor/cap; (2) a band CROSSING \ + arms a 5° pitch through the `+0xf4` channel. Built as (1)=the pivot corridor alone, \ + on the reading that the arm stays liquid-blind. **That corridor is discontinuous**: at \ + `excess = 2/9` the framing pivot drops 1.04 yd in one frame and pops back 0.29 yd at \ + `5/9`, so entering or leaving water at head depth slams the camera — which is what \ + the director saw. Whether the discontinuity is the reference's or a mis-transcription is \ + a live §5 (2165). Registered \"1\", so benilla is the divergence while this row \ + stands", + ), // **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from // `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row — // `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic: diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index 4610e49f..a6028026 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -207,6 +207,12 @@ fn every_query_binding_answers_the_reference_s_return_arity() { // A call that raises still says nothing about arity. The unit bindings are the reason: // they gate their arguments and raise on a nil token (1834/1836), so they are called with // a real token against the body seated above rather than with nothing. + // + // The count is taken with 5.0's own `arg.n`, for the reason [`KIND_HELPER`] states one + // gate down plus the one that settled it: `select` is 5.1's base library, not a 1.12 + // global, and `lua50::install` removes it. Where the count is wanted on the HOST side we + // ask `UiScript::arity`; these four probes have to hold it INSIDE a `pcall`, and there + // the vararg's own `n` is the 1.12 spelling. let call = if name.starts_with("Unit") { format!(r#"{name}("player")"#) } else { @@ -214,7 +220,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() { }; let probe = format!( "if type({name}) ~= 'function' then return -1 end \ - local ok, n = pcall(function() return select('#', {call}) end) \ + local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \ if not ok then return -1 end return n" ); let got: i64 = match s.eval(&probe) { @@ -256,7 +262,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() { by_name.get(n).is_some_and(|rs| { let want = rs[0].arity; s.eval::(&format!( - "local ok, n = pcall(function() return select('#', {n}()) end) \ + "local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \ if not ok then return -1 end return n" )) .is_ok_and(|got| got >= 0 && got as usize == want) @@ -374,7 +380,7 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() { let name = &r.name; let probe = format!( "if type({name}) ~= 'function' then return -1 end \ - local ok, n = pcall(function() return select('#', {name}({args})) end) \ + local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \ if not ok then return -1 end return n" ); let got: i64 = match s.eval(&probe) { @@ -572,7 +578,7 @@ fn every_widget_method_answers_the_reference_s_return_arity() { let probe = format!( "if type({obj}.{name}) ~= 'function' then return -1 end \ - local ok, n = pcall(function() return select('#', {obj}:{name}()) end) \ + local ok, n = pcall(function() return (function(...) return arg.n end)({obj}:{name}()) end) \ if not ok then return -1 end return n" ); let Ok(got) = s.eval::(&probe) else { diff --git a/crates/benilla-app/src/ui_script/skills_frame_tests.rs b/crates/benilla-app/src/ui_script/skills_frame_tests.rs index 0dc8a13a..d8feb273 100644 --- a/crates/benilla-app/src/ui_script/skills_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/skills_frame_tests.rs @@ -323,7 +323,7 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() { } // And the class lines still read as proficiencies despite their 300/300 descriptor. assert_eq!( - s.eval::("return (select(7, GetSkillLineInfo(2)))") + s.eval::("local _,_,_,_,_,_,mx = GetSkillLineInfo(2) return mx") .unwrap(), 1, "Beast Mastery's skillMaxRank" @@ -624,8 +624,11 @@ fn the_list_reaches_its_last_row_at_the_bars_end() { // The knob dragged to the end: bar → SetVerticalScroll → → // FauxScrollFrame_OnVerticalScroll → SkillFrame_UpdateSkills. - s.run("SkillListScrollFrameScrollBar:SetValue(select(2, SkillListScrollFrameScrollBar:GetMinMaxValues()))") - .unwrap(); + s.run( + "local _, hi = SkillListScrollFrameScrollBar:GetMinMaxValues() \ + SkillListScrollFrameScrollBar:SetValue(hi)", + ) + .unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert_eq!( s.eval::("return FauxScrollFrame_GetOffset(SkillListScrollFrame)") diff --git a/crates/benilla-app/src/ui_script/world_state_tests.rs b/crates/benilla-app/src/ui_script/world_state_tests.rs index 57d8e3bb..54d696d3 100644 --- a/crates/benilla-app/src/ui_script/world_state_tests.rs +++ b/crates/benilla-app/src/ui_script/world_state_tests.rs @@ -311,18 +311,10 @@ fn the_bindings_answer_the_reference_shape() { Alliance Towers Controlled|||1|2|3", "empty columns are empty strings, never nil" ); - assert_eq!( - s.eval::("return select('#', GetWorldStateUIInfo(1))") - .unwrap(), - 10 - ); + assert_eq!(s.arity("GetWorldStateUIInfo(1)").unwrap(), 10); // Out of range: ONE value, the number 0. - assert_eq!( - s.eval::("return select('#', GetWorldStateUIInfo(2))") - .unwrap(), - 1 - ); + assert_eq!(s.arity("GetWorldStateUIInfo(2)").unwrap(), 1); assert_eq!(s.eval::("return GetWorldStateUIInfo(2)").unwrap(), 0); assert_eq!( s.eval::("return GetWorldStateUIInfo(0)").unwrap(), diff --git a/crates/benilla-app/src/ui_text/layout/mod.rs b/crates/benilla-app/src/ui_text/layout/mod.rs index ff1184ff..ff3a911b 100644 --- a/crates/benilla-app/src/ui_text/layout/mod.rs +++ b/crates/benilla-app/src/ui_text/layout/mod.rs @@ -43,6 +43,28 @@ const WRAP_MIN_WIDTH: f32 = 1.0; /// line-stack stop ([`overflow::lines_allowed`]) and the ellipsis gate ([`ellipsize_to_fit`]). const HEIGHT_LIMIT_MIN: f32 = 1.0; +/// **Which grid a text block's top may land on** — the layout input decision 2172 made explicit. +/// +/// The client has one vertical anchor snap ([`snap_block_top`]) and applies it to every +/// `FontString`, because in the reference the interface *is* the framebuffer's pixel grid: the +/// snap and the rect cannot disagree. benilla has frames that live outside that grid — the +/// WorldFrame overlays (V-plates, chat bubbles, overhead names) that slide continuously over the +/// 3-D scene and take their own DEVICE-pixel seat law ([`crate::vplates::device_snap`], +/// 0188/1398). Snapping their text on the UI's coarser LOGICAL grid puts two quantizers on one +/// sliding object, and the text beats against the art it is drawn inside. +/// +/// This used to be inferred from the rect: a degenerate (zero-height) rect skipped the snap, and +/// the overlay painters all happened to pass one. Decision 2148 gave the V-plate real FontString +/// regions with real rects, and the inference silently stopped selecting them. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TextSeat { + /// The client's law: the block top takes [`snap_block_top`]. Every interface `FontString`. + UiGrid, + /// The block top is exactly where `rect` puts it; only the per-glyph device rounding + /// downstream quantizes it, on the same grid the overlay's own art is snapped to. + Exact, +} + /// A `FontString`'s two justification axes (`justifyH`/`justifyV`), bundled — they arrive together /// from the region's paint and travel together into [`layout_text_quads`]. #[derive(Clone, Copy)] @@ -179,6 +201,7 @@ pub(crate) struct LinkSpan { /// line block vertically (default `Middle`, the client's FontString default; degenerate zero-height /// rects keep top). Each line is measured once (a first pass laying glyphs at a zero line-origin), /// then shifted by the justification offset. +#[allow(clippy::too_many_arguments)] // the shared layout context, plus the seat pub(crate) fn layout_text_quads( e: &mut TextEngine, text: &str, @@ -187,8 +210,9 @@ pub(crate) fn layout_text_quads( justify: Justify, z_key: u64, font: FontSpec, + seat: TextSeat, ) -> Vec { - layout_text_quads_inner(e, text, rect, base_color, justify, z_key, font, None) + layout_text_quads_inner(e, text, rect, base_color, justify, z_key, font, seat, None) } /// [`layout_text_quads`] that also collects the laid-out [`LinkSpan`]s — the message-frame path @@ -203,6 +227,7 @@ pub(crate) fn layout_text_quads_links( justify: Justify, z_key: u64, font: FontSpec, + seat: TextSeat, links_out: &mut Vec, ) -> Vec { layout_text_quads_inner( @@ -213,6 +238,7 @@ pub(crate) fn layout_text_quads_links( justify, z_key, font, + seat, Some(links_out), ) } @@ -226,6 +252,7 @@ fn layout_text_quads_inner( justify: Justify, z_key: u64, font: FontSpec, + seat: TextSeat, mut links_out: Option<&mut Vec>, ) -> Vec { let gradient = font.alpha_gradient; @@ -324,10 +351,10 @@ fn layout_text_quads_inner( // which appears nowhere in the placement path; seating with it dropped every line ~3px too low. let ascent_ratio = e.ascent_ratio_of(r.face); let baseline_in_cell = (f64::from(r.size) * f64::from(ascent_ratio) + 0.5).floor() as f32; - // The block top takes the client's ONE vertical snap ([`snap_block_top`]); a degenerate - // (single-point / world-text) rect keeps its exact fractional origin — those callers - // (nameplates/vplates/combat_text) own their own seating laws and re-seat by ink. - let block_top = if rect.height() > f32::EPSILON { + // The block top takes the client's ONE vertical snap ([`snap_block_top`]) — unless this block + // seats outside the UI's pixel grid ([`TextSeat::Exact`], the WorldFrame overlays), or its rect + // is degenerate (a single-point / pre-measure rect has no seat to snap to). + let block_top = if seat == TextSeat::UiGrid && rect.height() > f32::EPSILON { snap_block_top(rect.min.y + v_offset) } else { rect.min.y + v_offset @@ -660,6 +687,69 @@ mod measure_fits_render { } } + /// **A world-seated block follows its rect; a UI-seated one snaps to the grid.** + /// + /// The V-plate slides over the world on the DEVICE pixel grid ([`crate::vplates::device_snap`], + /// 0188/1398) — half a logical pixel per step at 2×, which is why the border art glides. Its + /// name and level are drawn *inside* it and have to be rigid to it. Under + /// [`TextSeat::UiGrid`] the block top takes [`snap_block_top`], a WHOLE-logical-pixel + /// quantizer, so the ink holds still for one step of the border and then jumps two: the text + /// beats against the art at half the plate's step rate. That is the "level and name text look + /// janky af walking up to a mob" the director reported the day after decision 2148 moved the + /// plate's strings out of a painter that never snapped them (2172). + /// + /// The walk is the plate's own: half-pixel steps at dpi 2, the seat the driver actually hands + /// the widget layer. + #[test] + fn a_world_seated_block_tracks_its_rect_and_a_ui_one_snaps() { + let Some(mut e) = test_engine(2.0) else { + eprintln!("skipping: no install / font chain"); + return; + }; + let s = spec(13.0); + let (mut exact, mut grid) = (Vec::new(), Vec::new()); + for step in 0..24 { + #[allow(clippy::cast_precision_loss)] + let top = 100.0 + step as f32 * 0.5; + let rect = Rect::new(40.0, top, 140.0, top + 14.0); + for (seat, out) in [(TextSeat::Exact, &mut exact), (TextSeat::UiGrid, &mut grid)] { + let quads = layout_text_quads( + &mut e, + "Twilight Avenger", + rect, + [1.0; 4], + Justify { + h: JustifyH::Center, + v: JustifyV::Middle, + }, + 0, + s, + seat, + ); + assert!(!quads.is_empty(), "the name drew"); + out.push(quads.iter().map(|q| q.rect.min.y).fold(f32::MAX, f32::min)); + } + } + // Rigid: every half-pixel step of the rect is a half-pixel step of the ink. + for (i, w) in exact.windows(2).enumerate() { + assert!( + (w[1] - w[0] - 0.5).abs() < 1e-3, + "world-seated step {i}: the rect moved 0.5px and the ink moved {}", + w[1] - w[0] + ); + } + // The defect, stated so it cannot come back quietly: the UI grid quantizes this same walk + // to whole pixels — some steps move nothing and others move a full pixel, none move 0.5. + assert!( + grid.windows(2).any(|w| w[1] - w[0] > 0.9), + "the UI grid must actually quantize, else this test proves nothing: {grid:?}" + ); + assert!( + grid.windows(2).all(|w| (w[1] - w[0] - 0.5).abs() > 1e-3), + "a UI-grid seat never lands between two logical pixels: {grid:?}" + ); + } + /// **One line is ONE baseline — at every DPI, size and seat.** /// /// The emit pass's whole claim (decision 1342) is that a line is flat *by construction*: one @@ -703,6 +793,7 @@ mod measure_fits_render { }, 0, s, + TextSeat::UiGrid, ); let lo = quads .iter() @@ -763,6 +854,7 @@ mod measure_fits_render { }, 0, s, + TextSeat::UiGrid, ); let ink = quads.iter().map(|q| q.rect.max.x).fold(0.0f32, f32::max); assert!( @@ -798,6 +890,7 @@ mod measure_fits_render { }, 0, s, + TextSeat::UiGrid, ); assert!(quads.len() > 8, "the string drew"); let dpi = 2.0f32; diff --git a/crates/benilla-app/src/ui_text/mod.rs b/crates/benilla-app/src/ui_text/mod.rs index d57e7790..0d9d0272 100644 --- a/crates/benilla-app/src/ui_text/mod.rs +++ b/crates/benilla-app/src/ui_text/mod.rs @@ -72,7 +72,7 @@ pub(crate) use measurer::{measure_request, AtlasMeasurer}; pub(crate) use layout::{ ellipsize_to_fit, layout_text_quads, layout_text_quads_links, line_advances, line_origin, - line_rows, measure_text, measure_wrapped_rows, FontSpec, Justify, UI_SEAT_NUDGE, + line_rows, measure_text, measure_wrapped_rows, FontSpec, Justify, TextSeat, UI_SEAT_NUDGE, }; /// The default body text size (logical px) — WoW's own `GameFontNormal`. A `FontString` with no diff --git a/crates/benilla-ui/examples/dump_globals.rs b/crates/benilla-ui/examples/dump_globals.rs index 3afee070..b85f510f 100644 --- a/crates/benilla-ui/examples/dump_globals.rs +++ b/crates/benilla-ui/examples/dump_globals.rs @@ -24,6 +24,11 @@ //! instrument cannot see that, and did not: the arc measured coverage for two sessions with this //! gap wide open. `--members` prints `table.setn`-style rows for the stdlib tables, so the dialect //! is measurable the same way the API surface is. +//! +//! It also prints the **per-type metatables** (decision 2171), which is the same blind spot one +//! layer further down and stayed open longer because a metatable is not a *name*: 5.1 gives the +//! string type one and 5.0 cannot have one, so `("x"):upper()` worked here and raised on the +//! reference for as long as nothing thought to look. use benilla_ui::script::UiScript; /// The tables whose membership an addon can observe and depend on. @@ -60,6 +65,32 @@ fn main() -> mlua::Result<()> { ))?; rows.extend(members); } + + // **The per-type metatables — the surface `_G` and the member lists both miss** (decision + // 2171). `table.setn` taught 1194 that a `_G`-only instrument cannot see a *member*; the + // string metatable is the same lesson one layer further down, and it stayed invisible for + // longer because it is not a name at all. 1.12 installs none — `lua_setmetatable 0x6f4020` + // accepts only LUA_TTABLE and LUA_TUSERDATA and returns 0 for every other tag, so a + // primitive type there *cannot* carry one — while stock 5.1 gives `string` a metatable + // whose `__index` is the `string` table itself, which is what makes `("x"):upper()` work + // on 5.1 and raise on 5.0. Every row here should read `nil`. + // + // Written as a fixed list rather than a table because a `nil` value has no entry to + // iterate — the one type whose metatable slot is easiest to forget is the one a + // constructor silently drops. + let metatables: Vec = script.eval( + "local out = {} \ + local function probe(name, v) \ + table.insert(out, '<' .. name .. '>\\tmetatable ' .. type(getmetatable(v))) \ + end \ + probe('string', '') \ + probe('number', 0) \ + probe('boolean', true) \ + probe('nil', nil) \ + probe('function', probe) \ + return out", + )?; + rows.extend(metatables); } rows.sort(); diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index c1b6f99d..1c918c0d 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -642,11 +642,7 @@ mod tests { ); } // One value on every non-raising path — never zero values. - assert_eq!( - s.eval::("return select('#', GetActionText(4))") - .unwrap(), - 1 - ); + assert_eq!(s.arity("GetActionText(4)").unwrap(), 1); } /// A missing or non-number slot **raises** (`0x4e70be` → `0x6f4940`, which never returns). diff --git a/crates/benilla-ui/src/script/action_bar_toggles.rs b/crates/benilla-ui/src/script/action_bar_toggles.rs index 2f3cea8a..d363c3f3 100644 --- a/crates/benilla-ui/src/script/action_bar_toggles.rs +++ b/crates/benilla-ui/src/script/action_bar_toggles.rs @@ -247,9 +247,7 @@ mod tests { return a == nil and b == nil and c == nil and d == nil" ) .unwrap()); - assert!(s - .eval::("return select('#', GetActionBarToggles()) == 4") - .unwrap()); + assert_eq!(s.arity("GetActionBarToggles()").unwrap(), 4); s.set_action_bar_toggles(0x0a); assert!(s @@ -311,9 +309,7 @@ mod tests { s.run("SetActionBarToggles(1) SetActionBarToggles(1) SetActionBarToggles(nil)") .unwrap(); assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01, 0x01, 0x00]); - assert!(s - .eval::("return select('#', SetActionBarToggles(1)) == 0") - .unwrap()); + assert_eq!(s.arity("SetActionBarToggles(1)").unwrap(), 0); assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]); } } diff --git a/crates/benilla-ui/src/script/auction.rs b/crates/benilla-ui/src/script/auction.rs index d98edf06..db6cae00 100644 --- a/crates/benilla-ui/src/script/auction.rs +++ b/crates/benilla-ui/src/script/auction.rs @@ -997,9 +997,7 @@ mod tests { #[test] fn a_missing_row_still_answers_twelve_values() { let s = UiScript::new().unwrap(); - let n: i64 = s - .eval(r##"return select("#", GetAuctionItemInfo("list", 99))"##) - .unwrap(); + let n = s.arity(r#"GetAuctionItemInfo("list", 99)"#).unwrap(); assert_eq!(n, 12, "twelve, even with no session open at all"); let (count, quality): (i64, i64) = s .eval(r#"local _, _, c, q = GetAuctionItemInfo("list", 99) return c, q"#) @@ -1007,9 +1005,7 @@ mod tests { assert_eq!((count, quality), (1, -1)); // The link, by contrast, answers with NO values on a miss — not a nil. - let n: i64 = s - .eval(r##"return select("#", GetAuctionItemLink("list", 99))"##) - .unwrap(); + let n = s.arity(r#"GetAuctionItemLink("list", 99)"#).unwrap(); assert_eq!(n, 0, "zero values, not one nil"); } diff --git a/crates/benilla-ui/src/script/aura/player_buff.rs b/crates/benilla-ui/src/script/aura/player_buff.rs index 06d58d86..062a8f9e 100644 --- a/crates/benilla-ui/src/script/aura/player_buff.rs +++ b/crates/benilla-ui/src/script/aura/player_buff.rs @@ -302,7 +302,7 @@ mod tests { /// **The sentinel, and the arity that carries it.** `GetPlayerBuff` returns two numbers on every /// path — `(-1, 0)` past the end, never nil and never a different count. /// - /// The `select('#')` assertions are the half that matters, exactly as in + /// The arity assertions are the half that matters, exactly as in /// [`super::super::item_stats`]'s `get_item_info_tests`: an implementation that returned `nil` /// for a miss still "works" for every individual read, and only the arity notices. Here it is /// worse than cosmetic — the corpus's termination test is `>= 0` on the *first* value, so nil is @@ -312,14 +312,13 @@ mod tests { let mut s = with_player_cache(); assert_eq!( - s.eval::("return select('#', GetPlayerBuff(0))").unwrap(), + s.arity("GetPlayerBuff(0)").unwrap(), 2, "1.12 pushes exactly two values (`mov eax,0x2` at 0x4e471e) — a hit must not be a tuple \ of aura fields, which is the modern UnitAura shape" ); assert_eq!( - s.eval::("return select('#', GetPlayerBuff(99))") - .unwrap(), + s.arity("GetPlayerBuff(99)").unwrap(), 2, "and a MISS pushes two as well (0x4e4733), not zero and not nil" ); @@ -508,8 +507,7 @@ mod tests { ] { for arg in ["0", "-1", "99"] { assert_eq!( - s.eval::(&format!("return select('#', {verb}({arg}))")) - .unwrap(), + s.arity(&format!("{verb}({arg})")).unwrap(), 1, "{verb}({arg}) must push one value, not zero and not a tuple" ); @@ -517,11 +515,7 @@ mod tests { } // CancelPlayerBuff pushes NONE (`xor eax,eax; ret`), on every path. for arg in ["0", "-1", "99"] { - assert_eq!( - s.eval::(&format!("return select('#', CancelPlayerBuff({arg}))")) - .unwrap(), - 0 - ); + assert_eq!(s.arity(&format!("CancelPlayerBuff({arg})")).unwrap(), 0); } s.take_cancel_aura_requests(); diff --git a/crates/benilla-ui/src/script/battlefield_positions.rs b/crates/benilla-ui/src/script/battlefield_positions.rs index 3d625379..ac9ba07c 100644 --- a/crates/benilla-ui/src/script/battlefield_positions.rs +++ b/crates/benilla-ui/src/script/battlefield_positions.rs @@ -153,11 +153,7 @@ mod tests { .unwrap(), 0 ); - assert_eq!( - s.eval::("return select('#', GetBattlefieldPosition(1))") - .unwrap(), - 3 - ); + assert_eq!(s.arity("GetBattlefieldPosition(1)").unwrap(), 3); s.set_battlefield_positions( vec![ BattlefieldPositionView { diff --git a/crates/benilla-ui/src/script/battlefield_queue.rs b/crates/benilla-ui/src/script/battlefield_queue.rs index 242be964..c9a1d30c 100644 --- a/crates/benilla-ui/src/script/battlefield_queue.rs +++ b/crates/benilla-ui/src/script/battlefield_queue.rs @@ -446,32 +446,25 @@ mod tests { fn get_battlefield_info_answers_nine_or_none() { let mut s = vm(); assert_eq!( - s.eval::("return select('#', GetBattlefieldInfo())") - .unwrap(), + s.arity("GetBattlefieldInfo()").unwrap(), 0, "no list at all: the map gate" ); s.set_battlefield_list(list(&[7, 3])); s.set_unit("player", None); assert_eq!( - s.eval::("return select('#', GetBattlefieldInfo())") - .unwrap(), + s.arity("GetBattlefieldInfo()").unwrap(), 0, "no player: the other gate, read at call time" ); assert_eq!( - s.eval::("return select('#', GetBattlefieldInstanceInfo(1))") - .unwrap(), + s.arity("GetBattlefieldInstanceInfo(1)").unwrap(), 0, "…and the instance verb's" ); s = vm(); s.set_battlefield_list(list(&[7, 3])); - assert_eq!( - s.eval::("return select('#', GetBattlefieldInfo())") - .unwrap(), - 9 - ); + assert_eq!(s.arity("GetBattlefieldInfo()").unwrap(), 9); let got = s .eval::( "local n, d, a, b, c, x, y, lo, hi = GetBattlefieldInfo() \ @@ -497,16 +490,8 @@ mod tests { -1, "a numeric string passes; the id is pushed signed" ); - assert_eq!( - s.eval::("return select('#', GetBattlefieldInstanceInfo(3))") - .unwrap(), - 0 - ); - assert_eq!( - s.eval::("return select('#', GetBattlefieldInstanceInfo(0))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetBattlefieldInstanceInfo(3)").unwrap(), 0); + assert_eq!(s.arity("GetBattlefieldInstanceInfo(0)").unwrap(), 0); let err = s .run("GetBattlefieldInstanceInfo(nil)") .unwrap_err() @@ -576,10 +561,7 @@ mod tests { fn get_battlefield_status_answers_five_values_on_every_leg() { let mut s = UiScript::new().unwrap(); s.set_battlefield_queue(vec![slot(489, 1, 5), slot(0, 0, 0), slot(529, 9, 2)], 0); - let n = |s: &mut UiScript, i: &str| { - s.eval::(&format!("return select('#', GetBattlefieldStatus({i}))")) - .unwrap() - }; + let n = |s: &mut UiScript, i: &str| s.arity(&format!("GetBattlefieldStatus({i})")).unwrap(); assert_eq!(n(&mut s, "1"), 5); assert_eq!(n(&mut s, "0"), 5); assert_eq!(n(&mut s, "4"), 5); diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs index a0691800..87913baf 100644 --- a/crates/benilla-ui/src/script/binding_abi.rs +++ b/crates/benilla-ui/src/script/binding_abi.rs @@ -38,7 +38,8 @@ //! //! 1. **push nil**, one Lua value (`UnitAffectingCombat` on a false/unresolved unit, //! `GetActionText` on a non-macro slot, `UnitInRaid` on a miss). -//! 2. **zero Lua values** — distinct from `nil` for `select('#', …)` and for a multiple +//! 2. **zero Lua values** — distinct from `nil` for anything that counts the return list (5.0's +//! `arg.n`, or [`crate::script::UiScript::arity`] on the host side) and for a multiple //! assignment, identical to it for a single-value caller (`GetDefaultLanguage`'s failure edges). //! 3. **raise** — the statement is abandoned. diff --git a/crates/benilla-ui/src/script/channel.rs b/crates/benilla-ui/src/script/channel.rs index 2f83c68d..8e3a879c 100644 --- a/crates/benilla-ui/src/script/channel.rs +++ b/crates/benilla-ui/src/script/channel.rs @@ -742,8 +742,7 @@ mod command_tests { fn enumerate_server_channels_lists_the_shortcuts_the_zone_admits() { let mut s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', EnumerateServerChannels())") - .unwrap(), + s.arity("EnumerateServerChannels()").unwrap(), 0, "no zone yet — 0 values" ); @@ -907,10 +906,6 @@ mod command_tests { let e = s.run("LeaveChannelByName()").unwrap_err(); assert!(e.to_string().contains("Usage: LeaveChannelByName"), "{e}"); - assert_eq!( - s.eval::("return select('#', LeaveChannelByName('x'))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("LeaveChannelByName('x')").unwrap(), 0); } } diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index 160c3102..6310b89a 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -265,8 +265,7 @@ mod tests { assert!(s.eval::("return LoggingChat() == nil").unwrap()); assert!(s.eval::("return LoggingCombat() == nil").unwrap()); assert_eq!( - s.eval::("return select('#', LoggingChat(true))") - .unwrap(), + s.arity("LoggingChat(true)").unwrap(), 0, "the setter returns nothing" ); diff --git a/crates/benilla-ui/src/script/chat_send.rs b/crates/benilla-ui/src/script/chat_send.rs index e505038f..21d0fcd5 100644 --- a/crates/benilla-ui/src/script/chat_send.rs +++ b/crates/benilla-ui/src/script/chat_send.rs @@ -130,9 +130,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // and not an id. All **four** failure edges — no player object, a negative id, an id past the // language count, a null record — converge on `0x49fd2a xor eax,eax; ret`, i.e. **zero Lua // values**. That is shape 2 of the argument ABI ([`super::binding_abi`]) and it is the one - // place in this repo where the distinction is observable: `select('#', GetDefaultLanguage())` - // is `0` outside the world and `1` inside it, while a single-value caller reads `nil` either - // way. Returning `nil` here would be a quiet divergence, so we do not. + // place in this repo where the distinction is observable: the call's return-list COUNT is `0` + // outside the world and `1` inside it, while a single-value caller reads `nil` either way. + // Returning `nil` here would be a quiet divergence, so we do not. // // **Its sibling is misspelled in the binary, and is deliberately NOT registered here.** The // `.data` `{const char* name, void* fn}` record at `0x843628` names `0x49fb30` @@ -260,23 +260,21 @@ mod tests { /// **One string, or ZERO values — never `nil`.** The four failure edges reach /// `0x49fd2a xor eax,eax; ret` *without* passing through `luaL_error`, which is the only place - /// the "returns nothing" shape is real. A single-value caller cannot tell the two apart; - /// `select('#', …)` can, and both corpus callers feed the result straight to + /// the "returns nothing" shape is real. A single-value caller cannot tell the two apart; the + /// return-list count can ([`UiScript::arity`]), and both corpus callers feed the result straight to /// `SendChatMessage`, where the difference is an argument that exists versus one that does not. #[test] fn get_default_language_is_one_string_or_zero_values() { let mut s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', GetDefaultLanguage())") - .unwrap(), + s.arity("GetDefaultLanguage()").unwrap(), 0, "no player object → ZERO values, not nil" ); s.set_default_language(Some("Common".into())); assert_eq!( - s.eval::("return select('#', GetDefaultLanguage())") - .unwrap(), + s.arity("GetDefaultLanguage()").unwrap(), 1, "one value — not (name, id)" ); @@ -302,8 +300,7 @@ mod language_tests { let mut s = UiScript::new().unwrap(); assert_eq!(s.eval::("return GetNumLaguages()").unwrap(), 0); assert_eq!( - s.eval::("return select('#', GetLanguageByIndex(1))") - .unwrap(), + s.arity("GetLanguageByIndex(1)").unwrap(), 0, "past the end pushes nothing, not nil" ); diff --git a/crates/benilla-ui/src/script/chat_window.rs b/crates/benilla-ui/src/script/chat_window.rs index 56846666..ef90f265 100644 --- a/crates/benilla-ui/src/script/chat_window.rs +++ b/crates/benilla-ui/src/script/chat_window.rs @@ -948,11 +948,7 @@ mod tests { #[test] fn get_chat_window_info_answers_the_nine_value_tuple() { let s = UiScript::new().unwrap(); - assert_eq!( - s.eval::("return select('#', GetChatWindowInfo(1))") - .unwrap(), - 9 - ); + assert_eq!(s.arity("GetChatWindowInfo(1)").unwrap(), 9); } /// Trap 1: a stock client has never been told a window's name, so the getter answers `""` and @@ -1264,7 +1260,7 @@ mod record_tests { ("Loot".to_string(), Some(1)) ); assert!(s - .eval::("return select(7, GetChatWindowInfo(1)) == nil") + .eval::("local _, _, _, _, _, _, sh = GetChatWindowInfo(1) return sh == nil") .unwrap()); assert_eq!(s.take_chat_window_changes(), vec![0, 2]); s.run("SetChatWindowName(3, 'Loot')").unwrap(); diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index d253ca3e..7312f2b4 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -327,7 +327,7 @@ mod tests { vec!["1.12.1", "5875", "Sep 19 2006"] ); assert_eq!( - s.eval::("return select('#', GetBuildInfo())").unwrap(), + s.arity("GetBuildInfo()").unwrap(), 3, "three values, never a fourth" ); @@ -402,13 +402,13 @@ mod tests { fn the_client_identity_pair_answers_its_reference_arity() { let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', GetLocale())").unwrap(), + s.arity("GetLocale()").unwrap(), 1, "GetLocale pushes exactly one value" ); assert_eq!(s.eval::("return GetLocale()").unwrap(), "enUS"); assert_eq!( - s.eval::("return select('#', IsMacClient())").unwrap(), + s.arity("IsMacClient()").unwrap(), 1, "IsMacClient pushes one value, and it is nil" ); diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 036fb5f2..1e068ba4 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -1195,8 +1195,15 @@ mod tests { count: None, }] ); + // `local _, _, locked` — the 1.12 five-value shape, the same destructuring the positive + // assertion above uses. This read `local i = … return i.isLocked` until 2171: a leftover + // of the 1.14 `containerInfo` TABLE that 1187 reached for and 1199 corrected in the + // binding without correcting it here. It passed anyway, because 5.1's string metatable + // made indexing the `texture` string a silent `nil` and `nil` is a passing `!bool`. Taking + // the metatable away — 1.12 has none — is what turned a test that asserted nothing into a + // test that raises. assert!(!s - .eval::("local i = GetContainerItemInfo(0, 1) return i.isLocked") + .eval::("local _, _, locked = GetContainerItemInfo(0, 1) return locked") .unwrap()); } @@ -1252,8 +1259,10 @@ mod tests { count: None, }] ); + // The 1.12 five-value shape — see the sibling test above for what this used to read and + // why it passed while asserting nothing (2171). assert!(!s - .eval::("local i = GetContainerItemInfo(0, 1) return i.isLocked") + .eval::("local _, _, locked = GetContainerItemInfo(0, 1) return locked") .unwrap()); } @@ -1507,8 +1516,7 @@ mod tests { ); assert_eq!(slot("-100"), -81, "negatives run off the line too"); assert_eq!( - s.eval::("return select(\'#\', ContainerIDToInventoryID(99))") - .unwrap(), + s.arity("ContainerIDToInventoryID(99)").unwrap(), 1, "one value on every non-raising path" ); diff --git a/crates/benilla-ui/src/script/craft.rs b/crates/benilla-ui/src/script/craft.rs index 787fa09e..482efdf9 100644 --- a/crates/benilla-ui/src/script/craft.rs +++ b/crates/benilla-ui/src/script/craft.rs @@ -809,15 +809,11 @@ mod tests { ("Runed Copper Rod", Some(1), "Arcanite Rod", None) ); - // A recipe with no tools returns an empty multivalue (select('#', ...) == 0). + // A recipe with no tools returns an empty multivalue (arity 0, not one nil). let mut c2 = state(); c2.recipes[1].tools.clear(); s.set_craft(Some(c2)); - assert_eq!( - s.eval::("return select('#', GetCraftSpellFocus(1))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetCraftSpellFocus(1)").unwrap(), 0); } #[test] @@ -962,8 +958,9 @@ mod tests { .unwrap(), "|cff1eff00|Hitem:10940:0:0:0|h[Illusion Dust]|h|r" ); + assert_eq!(s.arity("GetCraftReagentItemLink(1, 5)").unwrap(), 1); assert!(s - .eval::("return select('#', GetCraftReagentItemLink(1, 5)) == 1 and GetCraftReagentItemLink(1, 5) == nil") + .eval::("return GetCraftReagentItemLink(1, 5) == nil") .unwrap()); for bad in ["GetCraftItemLink(nil)", "GetCraftReagentItemLink(1)"] { let err = s.run(bad).expect_err(bad).to_string(); @@ -972,9 +969,9 @@ mod tests { let mut beasts = state(); beasts.craft_type = 1; s.set_craft(Some(beasts)); - assert!( - s.eval::("return select('#', GetCraftItemLink(1)) == 0") - .unwrap(), + assert_eq!( + s.arity("GetCraftItemLink(1)").unwrap(), + 0, "a non-Enchanting craft answers zero values" ); } diff --git a/crates/benilla-ui/src/script/cursor/bag_verbs.rs b/crates/benilla-ui/src/script/cursor/bag_verbs.rs index d4720007..509ddc80 100644 --- a/crates/benilla-ui/src/script/cursor/bag_verbs.rs +++ b/crates/benilla-ui/src/script/cursor/bag_verbs.rs @@ -520,8 +520,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_bank_bag_slots(one_bank_bag()); assert_eq!( - s.eval::("return select('#', PickupBagFromSlot(64))") - .unwrap(), + s.arity("PickupBagFromSlot(64)").unwrap(), 0, "the delegate's eax is never tested — no return values at all" ); diff --git a/crates/benilla-ui/src/script/dialog_verbs.rs b/crates/benilla-ui/src/script/dialog_verbs.rs index 10d684ef..0faa4bbf 100644 --- a/crates/benilla-ui/src/script/dialog_verbs.rs +++ b/crates/benilla-ui/src/script/dialog_verbs.rs @@ -302,11 +302,7 @@ mod tests { #[test] fn the_meeting_stone_pair_answers_one_or_nil_and_string_or_nil() { let mut s = UiScript::new().unwrap(); - assert_eq!( - s.eval::("return select('#', IsInMeetingStoneQueue())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("IsInMeetingStoneQueue()").unwrap(), 1); assert!(s .eval::("return IsInMeetingStoneQueue() == nil") .unwrap()); diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 7dc0f290..c59b3e34 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -1451,8 +1451,7 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() { "5 ctor regions + 2 authored textures, and the adds none" ); assert_eq!( - s.eval::("return select('#', Box:GetRegions())") - .unwrap(), + s.arity("Box:GetRegions()").unwrap(), 7, "GetNumRegions is exactly the length GetRegions enumerates" ); diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index e7adc4b5..6c8af878 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -12,7 +12,8 @@ //! OnEvent (`(self, elapsed)` for OnUpdate, `(self)` for OnShow/OnHide/OnLoad) — the form Era //! addons are written against. //! -//! So inside one OnEvent handler `this == self` and `arg1 == select(1, ...)`. Handler errors are +//! So inside one OnEvent handler `this == self` and `arg1` is the first positional argument (5.0's +//! own `arg[1]`; `...` as a value, and `select` with it, are not in this dialect). Handler errors are //! caught (mlua's `Function::call` is a protected call) and returned to the caller, which records //! them in [`super::Model::errors`] — never a panic, never a print. diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 57e45dec..eb477f58 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -472,6 +472,11 @@ impl UiScript { shadow: data.font_shadow, outline: data.outline, alpha_gradient: data.alpha_gradient, + // A property of the OWNER, not of the string: the plate's own name + // and level and an addon's replacements for them are all drawn + // inside the same sliding rect. + world_seat: owner + .is_some_and(|o| super::nameplate::is_world_seated(&model, o)), } } else { // The draw gate is the TEXTURE slot, never the colour (`0x7706e0`: `+0xcc` diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs index f6bff838..447d39ca 100644 --- a/crates/benilla-ui/src/script/font_block.rs +++ b/crates/benilla-ui/src/script/font_block.rs @@ -284,7 +284,9 @@ pub(super) fn install( // ctor-determined `+0x48` gives (decision 2129). // // A nil here is not a cosmetic difference. `aux-addon/tabs/search/frame.lua:481` computes - // `select(2, child:GetFont()) + arg1*2` in its font-resize wheel handler. + // `aux.select(2, child:GetFont()) + arg1*2` in its font-resize wheel handler — `aux.select`, + // the addon's own (`util.lua:26`), which is why that line survives 2171 taking the global + // `select` away. m.set( "GetFont", lua.create_function(move |lua, this: Table| { diff --git a/crates/benilla-ui/src/script/gossip.rs b/crates/benilla-ui/src/script/gossip.rs index 0f45bb07..87b6a6fd 100644 --- a/crates/benilla-ui/src/script/gossip.rs +++ b/crates/benilla-ui/src/script/gossip.rs @@ -293,11 +293,7 @@ mod tests { let mut s = UiScript::new().unwrap(); // No menu: text nil, no options. assert!(s.eval::("return GetGossipText() == nil").unwrap()); - assert_eq!( - s.eval::("return select('#', GetGossipOptions())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0); s.set_gossip(Some(menu())); assert_eq!( @@ -305,13 +301,13 @@ mod tests { "Greetings, traveler." ); // Flat (label, type) pairs: 2 options → 4 return values. - let (n, l1, t1, l2, t2) = s - .eval::<(i64, String, String, String, String)>( + let (l1, t1, l2, t2) = s + .eval::<(String, String, String, String)>( "local a,b,c,d = GetGossipOptions()\n\ - return select('#', GetGossipOptions()), a, b, c, d", + return a, b, c, d", ) .unwrap(); - assert_eq!(n, 4); + assert_eq!(s.arity("GetGossipOptions()").unwrap(), 4); assert_eq!( (l1.as_str(), t1.as_str()), ("Let me browse your goods.", "vendor") @@ -351,16 +347,8 @@ mod tests { use super::GossipQuestRow; let mut s = UiScript::new().unwrap(); // No menu → both lists are empty, and that is `arg.n == 0`, not a nil. - assert_eq!( - s.eval::("return select('#', GetGossipAvailableQuests())") - .unwrap(), - 0 - ); - assert_eq!( - s.eval::("return select('#', GetGossipActiveQuests())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetGossipAvailableQuests()").unwrap(), 0); + assert_eq!(s.arity("GetGossipActiveQuests()").unwrap(), 0); let mut m = menu(); m.quests = vec![ @@ -422,10 +410,6 @@ mod tests { s.set_gossip(Some(menu())); s.set_gossip(None); assert!(s.eval::("return GetGossipText() == nil").unwrap()); - assert_eq!( - s.eval::("return select('#', GetGossipOptions())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0); } } diff --git a/crates/benilla-ui/src/script/item_stats.rs b/crates/benilla-ui/src/script/item_stats.rs index 50b35432..e6fa65e2 100644 --- a/crates/benilla-ui/src/script/item_stats.rs +++ b/crates/benilla-ui/src/script/item_stats.rs @@ -843,16 +843,15 @@ mod get_item_info_tests { /// `Auctioneer/Database/AucItemDB.lua:288` destructure it. /// /// **The arity assertion is half the test.** A regression to a later client's shape inserts - /// `itemLevel` at position 4 and makes this ten values; `select('#', …)` is the only check - /// that notices, because every individual read still "works". + /// `itemLevel` at position 4 and makes this ten values; the COUNT is the only check that + /// notices, because every individual read still "works". #[test] fn get_item_info_returns_the_1_12_nine_value_shape() { let mut s = UiScript::new().unwrap(); s.set_item_template(25, worn_shortsword()); assert_eq!( - s.eval::("return select('#', GetItemInfo(25))") - .unwrap(), + s.arity("GetItemInfo(25)").unwrap(), 9, "1.12 returns nine values (`mov eax,0x9` at 0x48e303) — a tenth means the modern shape" ); @@ -908,20 +907,22 @@ mod get_item_info_tests { }, ); assert_eq!( - s.eval::("return (select(4, GetItemInfo(13262)))") + s.eval::("local _, _, _, minLevel = GetItemInfo(13262) return minLevel") .unwrap(), 60, "position 4 is RequiredLevel ([record+0x3c]); ItemLevel lives at +0x38 and is not pushed" ); assert_eq!( - s.eval::("return (select(5, GetItemInfo(13262)))") + s.eval::("local _, _, _, _, itemType = GetItemInfo(13262) return itemType") .unwrap(), "Weapon", "position 5 is itemType — a modern shape would put a NUMBER (itemMinLevel) here" ); assert_eq!( - s.eval::("return (select(8, GetItemInfo(13262)))") - .unwrap(), + s.eval::( + "local _, _, _, _, _, _, _, equipLoc = GetItemInfo(13262) return equipLoc" + ) + .unwrap(), "INVTYPE_2HWEAPON" ); } @@ -963,8 +964,7 @@ mod get_item_info_tests { fn an_uncached_id_returns_nothing_and_records_the_ask() { let mut s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', GetItemInfo(2589))") - .unwrap(), + s.arity("GetItemInfo(2589)").unwrap(), 0, "an unseen template returns NO values" ); @@ -987,15 +987,8 @@ mod get_item_info_tests { assert!(s.take_item_stat_asks().is_empty(), "the push cleared it"); // Id 0 and a negative id are not askable — no junk query goes out for them. - assert_eq!( - s.eval::("return select('#', GetItemInfo(0))").unwrap(), - 0 - ); - assert_eq!( - s.eval::("return select('#', GetItemInfo(-5))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetItemInfo(0)").unwrap(), 0); + assert_eq!(s.arity("GetItemInfo(-5)").unwrap(), 0); assert!(s.take_item_stat_asks().is_empty()); } @@ -1029,7 +1022,7 @@ mod get_item_info_tests { assert_eq!(link, "item:25:2564:7:0"); // Case-insensitive prefix, and a truncated string still parses (atoi stops at the NUL). assert_eq!( - s.eval::("return (select(2, GetItemInfo(\"ITEM:25\")))") + s.eval::("local _, link = GetItemInfo(\"ITEM:25\") return link") .unwrap(), "item:25:0:0:0" ); @@ -1043,8 +1036,7 @@ mod get_item_info_tests { "\"\"", ] { assert_eq!( - s.eval::(&format!("return select('#', GetItemInfo({arg}))")) - .unwrap(), + s.arity(&format!("GetItemInfo({arg})")).unwrap(), 0, "argument {arg} resolves to item id 0" ); diff --git a/crates/benilla-ui/src/script/loot_roll.rs b/crates/benilla-ui/src/script/loot_roll.rs index ded21455..bac0c4dc 100644 --- a/crates/benilla-ui/src/script/loot_roll.rs +++ b/crates/benilla-ui/src/script/loot_roll.rs @@ -334,8 +334,7 @@ mod tests { .unwrap(); assert_eq!((count, quality), (1, 1), "the miss tail is 1, 1"); assert_eq!( - s.eval::("return select('#', GetLootRollItemInfo(99))") - .unwrap(), + s.arity("GetLootRollItemInfo(99)").unwrap(), 6, "five reference returns plus benilla's trailing item id" ); diff --git a/crates/benilla-ui/src/script/lua50.rs b/crates/benilla-ui/src/script/lua50.rs index cd58c2bb..3648cc7a 100644 --- a/crates/benilla-ui/src/script/lua50.rs +++ b/crates/benilla-ui/src/script/lua50.rs @@ -139,6 +139,62 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // "inherited rather than chosen"; it is now neither. g.set("coroutine", Value::Nil)?; + // `select` is 5.1's base library, not 5.0's, and the 1.12 client's `_G` has no row for it. + // It sat on `reference_surface`'s beyond-1.12 exception list under a reason that expired twice + // over: first "our transcribed FrameXML uses it in 16 files" — 1751's migration retired every + // one of those files, measured at zero by 2142 — then "it is mlua's, not ours to remove + // without the 5.1 varargs it comes with". 2101 deleted those varargs from the grammar, which + // is what turns this from a trade into a deletion: with `...`-as-a-value gone, `select`'s one + // idiomatic shape cannot be written at all, and what is left — `select(k, f())` — is a 5.1 + // spelling of the multiple assignment 5.0 writes with commas. + // + // **Nothing of ours called it in production**: 177 sites in 55 files were our own TESTS asking + // a binding's arity, which is a host-side question and is now asked host-side + // ([`super::UiScript::arity`]). The handful that need the count *inside* Lua use 5.0's own + // answer, `arg.n` — which this VM hands every vararg function since 2101 deleted the arm that + // cleared `VARARG_NEEDSARG`. + g.set("select", Value::Nil)?; + + // **The string type has NO metatable in 1.12**, so `("x"):upper()` — and `s:sub(1, 2)` for a + // string `s` — raises `attempt to index a string value` there and quietly worked here. 5.1's + // `luaopen_string` ends in a `createmetatable` 5.0's does not have, and mlua runs 5.1. + // + // Read out of the client, and the claim is stronger than "nobody installs one": the type + // *cannot* carry one, on the **reader** side, so the writer side never has to be argued. + // `luaT_gettmbyobj 0x6f7bd0` is the one function the VM asks for any value's metamethod (7 + // rel32 callers, covering TM_INDEX/TM_NEWINDEX/TM_CALL/order/arith). It loads `o->tt` at + // `0x6f7be2` and enumerates two tags — `sub ecx,5; je` (LUA_TTABLE), `sub ecx,2; je` + // (LUA_TUSERDATA) — then falls through at `0x6f7bee` to `mov eax,0x811bc0`, `&luaO_nilobject` + // in **`.rdata`**: sixteen zero bytes with no writers by construction. There is nothing to + // read, so a `__index` for a string cannot exist however it were installed. `G+0x80` is + // `tmname[15]`, the metamethod *name* strings, not a per-type metatable array; a ModRM/SIB + // census of every ×4-indexed operand across the text section finds no such read into writable + // `.data` at all. + // + // The writer agrees from the other end. `lua_setmetatable 0x6f4020` takes the same two arms + // (`0x6f409e`, `0x6f40a3`) into one `mov [edx+8], esi` — 5.0 puts `metatable` at offset 8 of + // both `Table` and `Udata` — and every other tag falls to `0x6f40a8`'s `xor eax,eax`: return + // 0, write nothing. `lua_getmetatable 0x6f3cf0` carries the identical `{5, 7}` switch. And the + // layout settles it a third way: `luaS_newlstr 0x6f9d00` puts a string's **hash** at `+8` + // (`0x6f9dbb`), the very offset the other two use for the metatable pointer. + // + // `luaopen_string 0x7fd810` is six instructions, `[0x7fd810, 0x7fd827)` — `push 0; + // push 0x822d88; mov edx,0x871938; call luaL_openlib; mov eax,1; ret` — with no + // `createmetatable` step, and `luaL_openlib` does not reach `0x6f4020` either. (wow-re + // `system/ui/scratch/string-metatable-closure.md`, whose §5 cross-check produced all of the + // above and retired `lua-dialect.md` §3's INFERRED flag on it. Two of ours came from that + // note before it was re-read: it said "four instructions" while quoting six, and it stopped at + // "no `lua_setmetatable` call" where the reader-side argument was available.) + // + // So this is not a policy choice about a superset — it is the type system. Note the reference + // also *raises* rather than no-ops if an addon tries it itself: base `setmetatable 0x702a40`'s + // first act is `luaL_checktype(L, 1, LUA_TTABLE)`, which mlua's 5.1 matches. + // + // Same rule as `coroutine` above, one layer below `_G`: an addon writing `s:gsub(...)` or + // testing `getmetatable("")` is asking which interpreter it is on, and the branch it should + // get is 1.12's. + lua.set_type_metatable::(None); + // ── 3 · the 5.0 compat globals the reference has and we lacked ──────────────────────────── // `sort`/`foreach`/`foreachi` are in `reference/1.12-globals.tsv`; `setn` deliberately is not, // so no bare `setn` is installed even though `table.setn` now works. The client's compat set @@ -461,7 +517,7 @@ mod tests { fn gcinfo_answers_two_numbers_as_1_12_does() { let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', gcinfo())").unwrap(), + s.arity("gcinfo()").unwrap(), 2, "`mov eax, 2` at 0x703239 — one value is 5.1's shape, not 1.12's" ); @@ -500,17 +556,12 @@ mod tests { fn collectgarbage_answers_nothing_and_takes_a_number() { let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', collectgarbage())") - .unwrap(), + s.arity("collectgarbage()").unwrap(), 0, "`xor eax,eax` at 0x70326f — 5.1 returns one value here" ); // The reference's ONLY argument form, which stock 5.1 rejects as ``invalid option `0'``. - assert_eq!( - s.eval::("return select('#', collectgarbage(0))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("collectgarbage(0)").unwrap(), 0); assert!(s.eval::<()>("collectgarbage(2048)").is_ok()); // `lua_tonumber` coerces a numeric string through `strtod`, so this one is accepted. assert!(s.eval::<()>(r#"collectgarbage("100")"#).is_ok()); @@ -584,14 +635,13 @@ mod tests { fn assert_answers_one_value_and_keeps_5_0_s_messages() { let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', assert(1, 2, 3))") - .unwrap(), + s.arity("assert(1, 2, 3)").unwrap(), 1, "5.0 truncates to the first argument; 5.1 returns them all" ); assert_eq!(s.eval::("return (assert(7, 'x'))").unwrap(), 7); // A truthy `false`-adjacent value is still truthy: only nil and false take the raise. - assert_eq!(s.eval::("return select('#', assert(0))").unwrap(), 1); + assert_eq!(s.arity("assert(0)").unwrap(), 1); let raised = |call: &str| -> String { s.eval::(&format!( @@ -928,7 +978,8 @@ mod tests { #[test] fn the_debug_family_is_six_stubs_and_two_real_ones() { let s = UiScript::new().unwrap(); - // Zero values, not nil — `select('#')` is the only check that can tell them apart. + // Zero values, not nil — a count is the only check that can tell them apart, and + // `arity` is where it is taken now that `select` is gone (2171). for name in [ "debuginfo", "debugload", @@ -938,8 +989,7 @@ mod tests { "debugtimestamp", ] { assert_eq!( - s.eval::(&format!("return select('#', {name}())")) - .unwrap(), + s.arity(&format!("{name}()")).unwrap(), 0, "{name} returns nothing at all" ); @@ -1105,4 +1155,104 @@ mod error_quoting_tests { ); } } + + /// **`select` is not a 1.12 global** (decision 2171) — and 5.0's own answer to the same + /// question still is. + /// + /// The pair matters more than the removal. `select('#', …)` was how 177 of our own tests asked + /// a binding's arity, and the property those gates rest on is that **zero returns and one + /// `nil` are different answers** (`binding_abi` §2) — a distinction no `Option` return type + /// can hold. 5.0 answers it with the implicit vararg table's `n`, which this VM hands every + /// vararg function since 2101 deleted the parser arm that cleared `VARARG_NEEDSARG`; the host + /// answers it with [`UiScript::arity`]. If either ever stops telling those apart, the arity + /// gates start silently passing a binding that returns nothing. + /// + /// The removal's own reach is in the corpus rather than here: `pfUI/libs/libpredict.lua:141` + /// gates its **TBC** HealComm parser on `select and UnitCastingInfo`, and pfUI supplies the + /// second name itself (`libs/libcast.lua:86`, loaded 3rd against libpredict's 10th), so + /// publishing `select` was the whole reason that branch ran. `BuffCheck2.lua:1157` is + /// `select = select or function(idx, ...)` — a 1.12 addon's own polyfill that ours suppressed. + #[test] + fn select_is_not_a_1_12_global_and_arg_n_answers_instead() { + let s = UiScript::new().unwrap(); + assert!( + s.eval::("return select == nil").unwrap(), + "`select` is 5.1's base library; `reference/1.12-globals.tsv` has no row for it" + ); + + // 5.0's spelling, in the shape an addon writes it. + assert_eq!( + s.eval::>( + "local function n(...) return arg.n end \ + local function two_with_a_nil() return 1, nil end \ + local function nothing() end \ + return { n(), n(nil), n(two_with_a_nil()), n(nothing()), n(1, 2, 3) }", + ) + .unwrap(), + vec![0, 1, 2, 0, 3], + "arg.n must count trailing nils AND tell zero returns from one nil" + ); + + // And the host's, which is what our own tests ask now. + assert_eq!(s.arity("nil").unwrap(), 1, "one nil is one value"); + assert_eq!(s.arity("ShowNameplates()").unwrap(), 0, "and zero is zero"); + } + + /// **1.12 installs no metatable on the string type**, so method-call syntax on a string raises + /// there and quietly worked here (decision 2171). + /// + /// The claim is stronger than "nobody installs one": the reference's `lua_setmetatable + /// 0x6f4020` accepts exactly two type tags — `LUA_TTABLE` and `LUA_TUSERDATA`, sharing one + /// `mov [edx+8], esi` because 5.0 puts `metatable` at offset 8 of both structs — and returns 0 + /// without writing for every other tag. 5.0 has no `G(L)->mt[]` array for a string to have a + /// slot in, so the type *cannot* carry one; `luaopen_string 0x7fd810`'s four instructions + /// (wow-re `lua-dialect.md` §4) are the same fact from the other end. + /// + /// The **wording** is asserted, not just the raise: it is what a player sees in a script + /// error, and 5.0's quoting convention is already load-bearing one file over — `sandbox`'s + /// `debugstack` emits 5.0 frames because `AceLibrary.lua:139`'s `argCheck` matches + /// ``"([`<].-['>])"`` and cannot see 5.4's `'name'` at all. Both strings are byte-verified + /// and executed on the reference: `.data 0x871c10` ``attempt to %s a %s value`` for the + /// anonymous form, `.data 0x871c2c` ``attempt to %s %s `%s' (a %s value)`` for the named one — + /// **backtick open, apostrophe close**. + #[test] + fn the_string_type_has_no_metatable() { + let s = UiScript::new().unwrap(); + assert!( + s.eval::(r#"return getmetatable("") == nil"#).unwrap(), + "5.1's luaopen_string ends in a createmetatable 5.0's does not have" + ); + + let literal: String = s + .eval( + r#"local ok, err = pcall(function() return ("abc"):upper() end) return tostring(err)"#, + ) + .unwrap(); + assert!( + literal.contains("attempt to index a string value"), + "5.0's own luaG_typeerror wording: {literal:?}" + ); + let named: String = s + .eval( + "local ok, err = pcall(function() local v = 'abc' return v:sub(1, 2) end) \ + return tostring(err)", + ) + .unwrap(); + assert!( + named.contains("attempt to index local `v' (a string value)"), + "the named form, with 5.0's backtick-apostrophe quoting: {named:?}" + ); + + // The other direction, and the one that makes this a regression if it ever fails: + // removing the metatable removes a *dispatch*, never a function. The 12-member string + // library and the bare aliases the reference publishes over it are untouched. + s.run( + r#"assert(string.upper("ab") == "AB") + assert(string.sub("hello", 2, 3) == "el") + assert(string.find("hello", "ll") == 3) + assert(strupper("ab") == "AB" and strsub("hello", 2, 3) == "el") + assert(format("%2$s %1$s", "a", "b") == "b a")"#, + ) + .unwrap(); + } } diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index 8debb4a7..2de6544b 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -949,15 +949,12 @@ mod tests { }); s.set_mail(Some(st)); - assert_eq!( - s.eval::("return select('#', GetInboxInvoiceInfo(1))") - .unwrap(), - 7 - ); + assert_eq!(s.arity("GetInboxInvoiceInfo(1)").unwrap(), 7); let vals: Vec = (1..=7) .map(|i| { + let discards = "_, ".repeat(i - 1); s.eval::(&format!( - "return tostring((select({i}, GetInboxInvoiceInfo(1))))" + "local {discards}v = GetInboxInvoiceInfo(1) return tostring(v)" )) .unwrap() }) @@ -979,15 +976,15 @@ mod tests { // Row 2 carries no invoice — and neither does an index off the end. for idx in [2, 99] { assert_eq!( - s.eval::(&format!("return select('#', GetInboxInvoiceInfo({idx}))")) - .unwrap(), + s.arity(&format!("GetInboxInvoiceInfo({idx})")).unwrap(), 7, "the miss is still seven values" ); let tail: Vec = (1..=7) .map(|i| { + let discards = "_, ".repeat(i - 1); s.eval::(&format!( - "return tostring((select({i}, GetInboxInvoiceInfo({idx}))))" + "local {discards}v = GetInboxInvoiceInfo({idx}) return tostring(v)" )) .unwrap() }) @@ -1010,8 +1007,7 @@ mod tests { s.set_mail(Some(st)); assert_eq!( - s.eval::("return select('#', GetInboxText(1))") - .unwrap(), + s.arity("GetInboxText(1)").unwrap(), 4, "four values, invoice or not" ); diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index bc41a717..12877d8f 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -548,6 +548,7 @@ impl UiScript { text_height: None, // message lines are never SetTextHeight'd outline: font.outline, alpha_gradient: None, + world_seat: false, // a chat frame is interface, on the interface's grid }, scale: paint.scale, }); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index f0c1ea0f..aae7e160 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -999,6 +999,28 @@ impl UiScript { self.lua.load(chunk).set_mode(mlua::ChunkMode::Text).eval() } + /// **How many values does `expr` return?** — the return-shape question, asked at the host + /// boundary instead of inside Lua. + /// + /// This is what `select('#', expr)` used to answer in ~170 of our own tests. `select` is not a + /// 1.12 global and is gone (`lua50::install`), and the replacement is not a workaround: a + /// binding's arity is a fact about the **binding ABI**, and on this side of it a multiple + /// return already *is* a `MultiValue` whose length nothing can round off. It keeps the property + /// the arity gates rest on — **zero values and one `nil` are different answers**, `0` and `1` + /// (`binding_abi`'s §2), which is exactly what a `Option` return type cannot tell apart. + /// + /// `expr` is a Lua **expression**, not a chunk: pass `"GetItemInfo(1)"`, not `"return …"`. + /// A raise propagates — a call that errors has no arity, and swallowing that into `0` is how a + /// broken binding scores as a zero-return verb. + /// + /// The one place this cannot serve is a probe that has to hold the count *inside* Lua (a + /// `pcall` loop over generated calls, as `shape_gate` runs). There the 5.0 spelling is 1.12's + /// own and reads the same: `(function(...) return arg.n end)(expr)`. + pub fn arity(&self, expr: &str) -> mlua::Result { + let values: mlua::Variadic = self.eval(&format!("return {expr}"))?; + Ok(values.len()) + } + /// The owning frame's name for an [`ExtractedQuad`] target — a debugging affordance for /// capture/probe tooling, which sees quads but not widgets ("whose quad is this?"). pub fn quad_owner_name(&self, target: ZTarget) -> Option { diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index d702b288..54e0a56b 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -843,6 +843,27 @@ fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> Re rh } +/// **Is this frame a V-plate?** — the seat question, asked of the OWNER (decision 2172). +/// +/// A plate is a WorldFrame overlay: the driver snaps its rect to the DEVICE pixel grid +/// (`vplates::device_snap`, 0188/1398) because it slides continuously over the world, and +/// everything drawn inside it has to be rigid to that rect. The renderer's UI seat snap quantizes +/// a text block's top on the coarser LOGICAL grid, so with both laws in force a plate's name and +/// level pop a whole logical pixel every second step the border takes — two quantizers on one +/// sliding object, and the "janky text walking up to a mob" the director reported the day after +/// decision 2148 moved these strings out of a painter that never snapped them (its host-measure +/// rects were degenerate, and a degenerate rect skips the snap — the carve-out the port silently +/// stopped selecting). +/// +/// **Asked of the frame rather than written on our six regions**, because it is the frame that +/// slides: pfUI and ShaguTweaks blank the stock regions and hang their *own* FontStrings on the +/// plate, and those are drawn inside the same rect and want the same answer. (A string an addon +/// puts on the plate's health-BAR child is one level further down and still takes the UI grid — +/// the named residual, and the shape of the fix if it ever bites is an ancestor walk.) +pub(super) fn is_world_seated(model: &Model, frame: FrameHandle) -> bool { + model.nameplates.by_frame.contains_key(&frame) +} + /// **The plate's glow region paints nothing here** — the region is real, shown, textured and ADD, /// and the quad walk skips it ([`super::extract`]). Decision 0184, and it is the director's call /// rather than a gap we could close by trying harder. diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index a1850fa7..dce61f17 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -614,7 +614,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // keep stale values a later `SetBackdropColor` changed. // 2. **No backdrop ⇒ ZERO Lua values, not `nil`** — the early bail is `xor eax,eax; ret`, which // for a *return* path really is "no values" (contrast `binding_abi`'s note: the same two - // bytes after a `luaL_error` are unreachable boilerplate). Observable through `select('#')`, + // bytes after a `luaL_error` are unreachable boilerplate). Observable through the count, // and it is the shape our `GetTitleRegion` will *not* have when it lands — that one pushes // nil, i.e. one value. The client cannot distinguish "never set" from `SetBackdrop(nil)`. // 3. **A partial `SetBackdrop` omits nothing on the way out.** Every `SetBackdrop` allocates a diff --git a/crates/benilla-ui/src/script/party.rs b/crates/benilla-ui/src/script/party.rs index 2ebe37b2..87a52758 100644 --- a/crates/benilla-ui/src/script/party.rs +++ b/crates/benilla-ui/src/script/party.rs @@ -1085,19 +1085,14 @@ mod tests { for index in ["1", "10", "0", "-1", "41", "9999"] { assert_eq!( - s.eval::(&format!("return select('#', GetRaidRosterInfo({index}))")) - .unwrap(), + s.arity(&format!("GetRaidRosterInfo({index})")).unwrap(), 9, "index {index} must still be a nine-value answer" ); } // And with no raid at all — the shape does not depend on being in one. let s = UiScript::new().unwrap(); - assert_eq!( - s.eval::("return select('#', GetRaidRosterInfo(1))") - .unwrap(), - 9 - ); + assert_eq!(s.arity("GetRaidRosterInfo(1)").unwrap(), 9); } /// The miss tuple is `(nil, 0, 1, 1, nil, nil, nil, nil, nil)` — **not** nine nils, and not @@ -1187,19 +1182,13 @@ mod tests { #[test] fn get_raid_roster_info_raises_only_on_a_non_number() { let s = UiScript::new().unwrap(); - let err = s - .eval::("return select('#', GetRaidRosterInfo({}))") - .unwrap_err(); + let err = s.arity("GetRaidRosterInfo({})").unwrap_err(); assert!( format!("{err}").contains("Usage: GetRaidRosterInfo(index)"), "got {err}" ); // A numeric string coerces (Lua 5.1's `lua_isnumber`) and does NOT raise. - assert_eq!( - s.eval::(r#"return select('#', GetRaidRosterInfo("3"))"#) - .unwrap(), - 9 - ); + assert_eq!(s.arity(r#"GetRaidRosterInfo("3")"#).unwrap(), 9); } /// `UnitInRaid` answers the **constant 1**, not a roster index — the value is the hard-coded diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index fcd381f4..6385d873 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -1016,10 +1016,7 @@ mod tests { ); // Exactly one return — the reference assigns it straight into a single local. - assert_eq!( - s.eval::("return select('#', GetPetIcon())").unwrap(), - 1 - ); + assert_eq!(s.arity("GetPetIcon()").unwrap(), 1); } fn hunter_stats() -> PetStats { @@ -1206,16 +1203,11 @@ mod tests { fn get_pet_food_types_returns_one_value_per_diet() { let mut s = UiScript::new().unwrap(); // No pet: ZERO returns, which is what makes the ref's `BuildListString` answer nil. - assert_eq!( - s.eval::(r##"return select("#", GetPetFoodTypes())"##) - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0); s.set_pet_stats(true, hunter_stats()); assert_eq!( - s.eval::(r##"return select("#", GetPetFoodTypes())"##) - .unwrap(), + s.arity("GetPetFoodTypes()").unwrap(), 6, "a boar's six diets are six returns, not one string" ); @@ -1235,11 +1227,7 @@ mod tests { ..hunter_stats() }, ); - assert_eq!( - s.eval::(r##"return select("#", GetPetFoodTypes())"##) - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0); } /// The family **word** answers regardless of the hunter gate — `UnitCreatureFamily 0x51a310` @@ -1267,10 +1255,6 @@ mod tests { ); // …while every hunter-gated binding still says nothing. assert!(s.eval::("return GetPetLoyalty() == nil").unwrap()); - assert_eq!( - s.eval::(r##"return select("#", GetPetFoodTypes())"##) - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0); } } diff --git a/crates/benilla-ui/src/script/petition.rs b/crates/benilla-ui/src/script/petition.rs index a4f7c7cc..2c42ee46 100644 --- a/crates/benilla-ui/src/script/petition.rs +++ b/crates/benilla-ui/src/script/petition.rs @@ -472,13 +472,13 @@ mod tests { /// /// This file first shipped an empty return, which the reference's own six-way destructure /// cannot tell apart (both give six nils… except the fourth, which is `0` here and `nil` - /// there). `select("#", …)` is what separates them, and it is the whole point of the test. + /// there). The COUNT is what separates them — [`UiScript::arity`] — and it is the whole point + /// of the test. #[test] fn the_no_record_leg_is_six_values_with_a_numeric_zero() { let s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select(\"#\", GetPetitionInfo())") - .unwrap(), + s.arity("GetPetitionInfo()").unwrap(), 6, "six values even with nothing open" ); @@ -617,8 +617,7 @@ mod tests { "an empty name is refused locally — the server answers it with silence" ); assert_eq!( - s.eval::("return select(\"#\", RenamePetition(\"Legacy\"))") - .unwrap(), + s.arity("RenamePetition(\"Legacy\")").unwrap(), 0, "rename pushes nothing at all" ); diff --git a/crates/benilla-ui/src/script/pvp/tests.rs b/crates/benilla-ui/src/script/pvp/tests.rs index b96e484e..697114ec 100644 --- a/crates/benilla-ui/src/script/pvp/tests.rs +++ b/crates/benilla-ui/src/script/pvp/tests.rs @@ -168,7 +168,7 @@ fn the_reference_destructuring_lands_every_value_in_the_right_slot() { fn a_lifetime_rank_below_five_is_reported_as_zero() { let mut s = seated(); let third = |s: &UiScript| { - s.eval::("return (select(3, GetPVPLifetimeStats()))") + s.eval::("local _, _, highest = GetPVPLifetimeStats() return highest") .unwrap() }; for (highest, reported) in [(0u8, 0i64), (1, 0), (4, 0), (5, 5), (6, 6), (18, 18)] { @@ -185,18 +185,16 @@ fn a_lifetime_rank_below_five_is_reported_as_zero() { highest_rank: 3, ..honor_state() })); - assert_eq!( - s.eval::(r##"return select("#", GetPVPLifetimeStats())"##) - .unwrap(), - 3 - ); + assert_eq!(s.arity("GetPVPLifetimeStats()").unwrap(), 3); assert!(s - .eval::("return type((select(3, GetPVPLifetimeStats()))) == 'number'") + .eval::( + "local _, _, highest = GetPVPLifetimeStats() return type(highest) == 'number'" + ) .unwrap()); } -/// Arity, measured the way Lua itself measures it. A getter that returned one value too many -/// would still pass the destructuring test above; `select("#", …)` is what catches it. +/// Arity, measured at the host boundary ([`UiScript::arity`]). A getter that returned one value +/// too many would still pass the destructuring test above; the count is what catches it. #[test] fn every_getter_returns_exactly_the_reference_arity() { let s = seated(); @@ -211,12 +209,7 @@ fn every_getter_returns_exactly_the_reference_arity() { ("GetPVPRankInfo(0)", 2), ("UnitPVPRank('player')", 1), ] { - assert_eq!( - s.eval::(&format!(r##"return select("#", {call})"##)) - .unwrap(), - width, - "{call} arity" - ); + assert_eq!(s.arity(call).unwrap(), width, "{call} arity"); } } @@ -468,8 +461,10 @@ fn the_visual_rank_arithmetic_runs_backwards_through_the_dishonorable_ranks() { seat_rank_globals(&s); for (internal, visual) in [(1i64, -4i64), (4, -1), (5, 1), (18, 14)] { assert_eq!( - s.eval::(&format!("return (select(2, GetPVPRankInfo({internal})))")) - .unwrap(), + s.eval::(&format!( + "local _, number = GetPVPRankInfo({internal}) return number" + )) + .unwrap(), visual, "GetPVPRankInfo({internal})" ); @@ -498,8 +493,7 @@ fn the_range_gate_refuses_rank_zero_and_rank_nineteen_alike() { assert_eq!(name, None, "rank {rank} names nothing"); assert_eq!(number, 0, "rank {rank} numbers 0"); assert_eq!( - s.eval::(&format!(r##"return select("#", GetPVPRankInfo({rank}))"##)) - .unwrap(), + s.arity(&format!("GetPVPRankInfo({rank})")).unwrap(), 2, "rank {rank} still answers two values" ); @@ -904,11 +898,7 @@ fn get_inspect_honor_data_returns_the_twelve_in_the_reference_order() { let mut s = seated(); s.set_inspect_honor(Some(inspect_state())); - assert_eq!( - s.eval::(r##"return select("#", GetInspectHonorData())"##) - .unwrap(), - 12 - ); + assert_eq!(s.arity("GetInspectHonorData()").unwrap(), 12); let got = s .eval::>( "local sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisweekHK, \ @@ -936,8 +926,7 @@ fn get_inspect_honor_data_returns_the_twelve_in_the_reference_order() { fn get_inspect_honor_data_answers_twelve_zeros_when_no_reply_is_held() { let mut s = seated(); assert_eq!( - s.eval::(r##"return select("#", GetInspectHonorData())"##) - .unwrap(), + s.arity("GetInspectHonorData()").unwrap(), 12, "ungated: twelve on every path" ); @@ -998,11 +987,7 @@ fn the_intent_queues_drain_and_the_honor_query_refuses_to_double_up() { assert_eq!(s.take_inspect_honor_requests(), 1); // The binding answers zero Lua values on every one of those paths (`0x4c9610`). - assert_eq!( - s.eval::(r##"return select("#", RequestInspectHonorData())"##) - .unwrap(), - 0 - ); + assert_eq!(s.arity("RequestInspectHonorData()").unwrap(), 0); s.eval::<()>("TogglePVP() TogglePVP()").unwrap(); assert_eq!(s.take_pvp_toggles(), 2, "no latch here — two packets"); diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index 18afa76d..af0ddd08 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -937,11 +937,7 @@ mod tests { let mut s = UiScript::new().unwrap(); // Empty log: the first return is still nil (so the ref's `if ( questLogTitleText )` // reads the same), but the tuple is full and the level is a NUMBER. - assert_eq!( - s.eval::("return select('#', GetQuestLogTitle(1))") - .unwrap(), - 6 - ); + assert_eq!(s.arity("GetQuestLogTitle(1)").unwrap(), 6); assert!(s .eval::("local t, l = GetQuestLogTitle(1) return t == nil and l == 0") .unwrap()); @@ -1032,10 +1028,10 @@ mod tests { state.entries[1].tag = Some("Elite".into()); s.set_quest_log(state); assert!(s - .eval::("return select(3, GetQuestLogTitle(1)) == nil") + .eval::("local _, _, tag = GetQuestLogTitle(1) return tag == nil") .unwrap()); assert!(s - .eval::("return select(3, GetQuestLogTitle(2)) == 'Elite'") + .eval::("local _, _, tag = GetQuestLogTitle(2) return tag == 'Elite'") .unwrap()); } @@ -1201,23 +1197,15 @@ mod tests { s.set_quest_log(state); // No clock sample yet: nothing to subtract from, so nothing is claimed. - assert_eq!( - s.eval::("return select('#', GetQuestTimers())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetQuestTimers()").unwrap(), 0); assert!(s .eval::("return GetQuestLogTimeLeft() == nil") .unwrap()); // 400 s in. s.set_server_unix_time(1_000_400.0); - assert!(s - .eval::( - "local n, a = select('#', GetQuestTimers()), GetQuestTimers()\n\ - return n == 1 and a == 499" - ) - .unwrap()); + assert_eq!(s.arity("GetQuestTimers()").unwrap(), 1); + assert_eq!(s.eval::("return (GetQuestTimers())").unwrap(), 499); // The timer maps back to entry 2 — the untimed row 1 does not shift the mapping. assert_eq!(s.eval::("return GetQuestIndexForTimer(1)").unwrap(), 2); assert!(s @@ -1245,11 +1233,7 @@ mod tests { // the row (its `js`), while GetQuestLogTimeLeft clamps at 0 and keeps answering. s.set_server_unix_time(1_000_910.0); assert_eq!(s.eval::("return GetQuestLogTimeLeft()").unwrap(), 0); - assert_eq!( - s.eval::("return select('#', GetQuestTimers())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetQuestTimers()").unwrap(), 0); assert!(s .eval::("return GetQuestIndexForTimer(1) == nil") .unwrap()); @@ -1268,12 +1252,8 @@ mod tests { s.set_quest_log(state); s.set_server_unix_time(1_000_400.0); - assert!(s - .eval::( - "local n, a = select('#', GetQuestTimers()), GetQuestTimers()\n\ - return n == 1 and a == 499" - ) - .unwrap()); + assert_eq!(s.arity("GetQuestTimers()").unwrap(), 1); + assert_eq!(s.eval::("return (GetQuestTimers())").unwrap(), 499); assert_eq!(s.eval::("return GetQuestIndexForTimer(1)").unwrap(), 1); s.run("SelectQuestLogEntry(2)").unwrap(); assert!(s diff --git a/crates/benilla-ui/src/script/reputation.rs b/crates/benilla-ui/src/script/reputation.rs index 8dfc4275..79f15454 100644 --- a/crates/benilla-ui/src/script/reputation.rs +++ b/crates/benilla-ui/src/script/reputation.rs @@ -746,9 +746,7 @@ mod tests { assert!(got.10.is_nil(), "isWatched"); for miss in ["GetFactionInfo(99)", "GetFactionInfo(0)"] { - let n = s - .eval::(&format!("return select(\"#\", {miss})")) - .unwrap(); + let n = s.arity(miss).unwrap(); assert_eq!(n, 11, "{miss} must still be eleven wide"); let sid = s .eval::(&format!("local _,_,s = {miss} return s")) diff --git a/crates/benilla-ui/src/script/skills.rs b/crates/benilla-ui/src/script/skills.rs index 92c33552..6fdcb6b6 100644 --- a/crates/benilla-ui/src/script/skills.rs +++ b/crates/benilla-ui/src/script/skills.rs @@ -643,8 +643,10 @@ mod tests { // The 8th return: Professions rows are abandonable (fixture rule), weapon rows and // headers are not — 1/nil, the 1.12 boolean shape. let ab = |s: &mut UiScript, i: i64| { - s.eval::>(&format!("return (select(8, GetSkillLineInfo({i})))")) - .unwrap() + s.eval::>(&format!( + "local _,_,_,_,_,_,_,ab = GetSkillLineInfo({i}) return ab" + )) + .unwrap() }; assert_eq!(ab(&mut s, 2), None, "Defense is not abandonable"); assert_eq!(ab(&mut s, 5), Some(1), "First Aid is abandonable"); @@ -757,12 +759,13 @@ mod tests { ), ("Weapon Skills", 1, Some(1), 0, 0, 0, 0) ); - let (abandon_nil, step_nil, rank_cost_nil, min_level, cost_type, count) = s - .eval::<(bool, bool, bool, i64, i64, i64)>( - "local a,st,rc,ml,ct = select(8, GetSkillLineInfo(1)) \ - return a==nil, st==nil, rc==nil, ml, ct, select('#', GetSkillLineInfo(1))", + let (abandon_nil, step_nil, rank_cost_nil, min_level, cost_type) = s + .eval::<(bool, bool, bool, i64, i64)>( + "local _,_,_,_,_,_,_,a,st,rc,ml,ct = GetSkillLineInfo(1) \ + return a==nil, st==nil, rc==nil, ml, ct", ) .unwrap(); + let count = s.arity("GetSkillLineInfo(1)").unwrap(); assert!(abandon_nil); assert!( step_nil && rank_cost_nil, @@ -792,16 +795,17 @@ mod tests { ); // The 13th return is the REAL description (SkillLine.dbc col 12 through the feed) — an // entry row's alone: a header stops at 12 (asserted above). - let (count, desc) = s - .eval::<(i64, String)>( - "return select('#', GetSkillLineInfo(2)), select(13, GetSkillLineInfo(2))", - ) + let count = s.arity("GetSkillLineInfo(2)").unwrap(); + let desc = s + .eval::("local _,_,_,_,_,_,_,_,_,_,_,_,d = GetSkillLineInfo(2) return d") .unwrap(); assert_eq!((count, desc.as_str()), (13, "About Defense.")); // minLevel and skillCostType are real NUMBERS on an entry — and the cost type is the // row's index PLUS ONE (the client's own `0x4d3a06`), so a fixture at index 0 reads 1. let (min_level, cost_type) = s - .eval::<(i64, i64)>("local ml,ct = select(11, GetSkillLineInfo(2)) return ml,ct") + .eval::<(i64, i64)>( + "local _,_,_,_,_,_,_,_,_,_,ml,ct = GetSkillLineInfo(2) return ml,ct", + ) .unwrap(); assert_eq!((min_level, cost_type), (0, 1)); } @@ -884,7 +888,7 @@ mod tests { ); // Defense (a weapon line, not mono) still reports its real 300. assert_eq!( - s.eval::("return (select(7, GetSkillLineInfo(4)))") + s.eval::("local _,_,_,_,_,_,mx = GetSkillLineInfo(4) return mx") .unwrap(), 300 ); @@ -906,8 +910,7 @@ mod tests { let s = UiScript::new().unwrap(); for idx in ["0", "1", "99"] { assert_eq!( - s.eval::(&format!("return select('#', GetSkillLineInfo({idx}))")) - .unwrap(), + s.arity(&format!("GetSkillLineInfo({idx})")).unwrap(), 13, "GetSkillLineInfo({idx}) must answer 13 values" ); diff --git a/crates/benilla-ui/src/script/social.rs b/crates/benilla-ui/src/script/social.rs index 3642cd70..0ad80c22 100644 --- a/crates/benilla-ui/src/script/social.rs +++ b/crates/benilla-ui/src/script/social.rs @@ -628,11 +628,7 @@ mod tests { "and the app hears the same click, so its copy of the chain follows" ); // Zero return values (`0x5ad9f8 xor eax,eax; ret`). - assert_eq!( - s.eval::(r#"return select('#', SortWho("name"))"#) - .unwrap(), - 0 - ); + assert_eq!(s.arity(r#"SortWho("name")"#).unwrap(), 0); assert!(s.errors().is_empty(), "{:?}", s.errors()); } @@ -703,9 +699,7 @@ mod tests { #[test] fn the_lfg_pair_stores_the_comment_and_sends_only_on_a_change() { let mut s = UiScript::new().unwrap(); - assert!(s - .eval::("return select('#', GetLookingForGroup()) == 4") - .unwrap()); + assert_eq!(s.arity("GetLookingForGroup()").unwrap(), 4); assert!(s .eval::( "local a, b, c, d = GetLookingForGroup() return a == nil and b == nil and c == nil and d == \"\"" @@ -728,7 +722,7 @@ mod tests { }] ); assert_eq!( - s.eval::("return select(4, GetLookingForGroup())") + s.eval::("local _, _, _, comment = GetLookingForGroup() return comment") .unwrap(), "LF2M UBRS" ); @@ -741,7 +735,7 @@ mod tests { .unwrap(); assert!(s.take_social_requests().is_empty()); assert_eq!( - s.eval::("return select(4, GetLookingForGroup())") + s.eval::("local _, _, _, comment = GetLookingForGroup() return comment") .unwrap(), "LF2M UBRS" ); @@ -752,7 +746,7 @@ mod tests { )) .unwrap(); assert_eq!( - s.eval::("return select(4, GetLookingForGroup())") + s.eval::("local _, _, _, comment = GetLookingForGroup() return comment") .unwrap() .len(), 127 diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 36ec3477..f1d02b92 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -568,7 +568,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let model = lua.app_data_ref::().expect("model app_data"); let Some(slot) = book_slot(&model, id, &book_type) else { // An unfilled slot inside the range answers **two** nils (`0x4b4086` → `mov eax,0x2` - // at `0x4b4095`), not one — which `select('#', …)` and a two-name assignment can + // at `0x4b4095`), not one — which a return-list count and a two-name assignment can // both tell apart. return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Nil])); }; @@ -1515,11 +1515,7 @@ mod tests { ], }); - assert_eq!( - s.eval::(r#"return select('#', GetSpellName(1, "spell"))"#) - .unwrap(), - 2 - ); + assert_eq!(s.arity(r#"GetSpellName(1, "spell")"#).unwrap(), 2); let (name, rank) = s .eval::<(String, String)>(r#"return GetSpellName(1, "spell")"#) .unwrap(); @@ -1541,12 +1537,8 @@ mod tests { ("Heroic Strike".to_string(), "Rank 1".to_string()) ); - // An unfilled slot INSIDE the range is two nils, not one — distinguishable by select('#'). - assert_eq!( - s.eval::(r#"return select('#', GetSpellName(9, "spell"))"#) - .unwrap(), - 2 - ); + // An unfilled slot INSIDE the range is two nils, not one — distinguishable by the count. + assert_eq!(s.arity(r#"GetSpellName(9, "spell")"#).unwrap(), 2); assert!(s .eval::(r#"local a, b = GetSpellName(9, "spell") return a == nil and b == nil"#) .unwrap()); @@ -1561,11 +1553,7 @@ mod tests { "got {err}" ); // ...and 1023 is inside it, so it answers rather than raising. - assert_eq!( - s.eval::(r#"return select('#', GetSpellName(1023, "spell"))"#) - .unwrap(), - 2 - ); + assert_eq!(s.arity(r#"GetSpellName(1023, "spell")"#).unwrap(), 2); } /// **`UpdateSpells()` fires `SPELLS_CHANGED` and does nothing else** — decision 1924, from a @@ -1596,10 +1584,7 @@ mod tests { "UpdateSpells must fire SPELLS_CHANGED synchronously, as SignalEvent does" ); // Zero return values — `arity = 0 (exact)` in `reference/1.12-shapes.tsv`. - assert_eq!( - s.eval::("return select('#', UpdateSpells())").unwrap(), - 0 - ); + assert_eq!(s.arity("UpdateSpells()").unwrap(), 0); assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); } @@ -1613,11 +1598,7 @@ mod tests { let s = UiScript::new().unwrap(); assert_eq!(s.eval::("return GetNumSpellTabs()").unwrap(), 0); assert_eq!(s.eval::("return PlayerHasSpells()").unwrap(), 1); - assert_eq!( - s.eval::("return select('#', PlayerHasSpells())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("PlayerHasSpells()").unwrap(), 1); } /// **Out of range answers `nil, nil, 0, 0` — four values, the last two NUMBERS** (1931). The @@ -1630,8 +1611,7 @@ mod tests { let s = UiScript::new().unwrap(); for idx in ["0", "1", "99", "-1", "0.5"] { assert_eq!( - s.eval::(&format!("return select('#', GetSpellTabInfo({idx}))")) - .unwrap(), + s.arity(&format!("GetSpellTabInfo({idx})")).unwrap(), 4, "GetSpellTabInfo({idx}) must answer four values" ); diff --git a/crates/benilla-ui/src/script/stable.rs b/crates/benilla-ui/src/script/stable.rs index c28f03fd..d17bee3e 100644 --- a/crates/benilla-ui/src/script/stable.rs +++ b/crates/benilla-ui/src/script/stable.rs @@ -660,12 +660,7 @@ mod tests { let mut s = UiScript::new().unwrap(); open(&mut s); for call in ["GetStablePetInfo(2)", "GetStablePetInfo(9)"] { - assert_eq!( - s.eval::(&format!("return select('#', {call})")) - .unwrap(), - 5, - "{call} arity" - ); + assert_eq!(s.arity(call).unwrap(), 5, "{call} arity"); // Still falsy on the reference's own `if ( GetStablePetInfo(i) )` test. assert!(s.eval::(&format!("return not {call}")).unwrap()); assert_eq!( diff --git a/crates/benilla-ui/src/script/stdlib.rs b/crates/benilla-ui/src/script/stdlib.rs index 61391c99..549d7400 100644 --- a/crates/benilla-ui/src/script/stdlib.rs +++ b/crates/benilla-ui/src/script/stdlib.rs @@ -370,7 +370,13 @@ function GetGameTime() return __benilla_game_hour, __benilla_game_minute end -- ── Blizzard's positional string.format (%N$) ────────────────────────────────────────────────── do - local _format = string.format + -- `_find`/`_sub` are captured for the same reason `_format` is: this wrapper runs on every + -- `format` call in the UI and must not follow an addon's later replacement of `string.*`. + -- They are CALLS and not method syntax because 1.12 installs no string metatable, so + -- `fmt:find(...)` — which this used to be written as — raises there (decision 2101's + -- left-open item, closed in `lua50::install`). No chunk of ours may use what the reference's + -- VM cannot resolve; the parser enforces that for the grammar, and this is its runtime twin. + local _format, _find, _sub, _len = string.format, string.find, string.sub, string.len local CONV = "diouxXeEfgGqscp" -- Lua 5.1 conversion letters local function reformat(fmt, ...) @@ -378,7 +384,7 @@ do return _format(fmt, unpack(arg, 1, arg.n)) end -- fast path: no "%" at all ⇒ definitely no positional spec. - if not fmt:find("%%%d") then + if not _find(fmt, "%%%d") then return _format(fmt, unpack(arg, 1, arg.n)) end @@ -387,33 +393,33 @@ do local order = {} -- for each real conversion: the source arg index, or false=sequential local seen_pos, seen_seq = false, false local npieces, norder = 0, 0 - local i, len = 1, string.len(fmt) + local i, len = 1, _len(fmt) while i <= len do - local c = fmt:sub(i, i) + local c = _sub(fmt, i, i) if c ~= "%" then npieces = npieces + 1; pieces[npieces] = c i = i + 1 - elseif fmt:sub(i + 1, i + 1) == "%" then + elseif _sub(fmt, i + 1, i + 1) == "%" then npieces = npieces + 1; pieces[npieces] = "%%" i = i + 2 else local j = i + 1 - local ds, de = fmt:find("^%d+%$", j) -- optional N$ + local ds, de = _find(fmt, "^%d+%$", j) -- optional N$ if ds then seen_pos = true - norder = norder + 1; order[norder] = tonumber(fmt:sub(ds, de - 1)) + norder = norder + 1; order[norder] = tonumber(_sub(fmt, ds, de - 1)) j = de + 1 else seen_seq = true norder = norder + 1; order[norder] = false end -- copy flags/width/precision + the conversion letter - local ce = fmt:find("[" .. CONV .. "]", j) + local ce = _find(fmt, "[" .. CONV .. "]", j) if not ce then error("invalid conversion in format string", 2) end - npieces = npieces + 1; pieces[npieces] = "%" .. fmt:sub(j, ce) + npieces = npieces + 1; pieces[npieces] = "%" .. _sub(fmt, j, ce) i = ce + 1 end end diff --git a/crates/benilla-ui/src/script/tests/addon_argument_abi.rs b/crates/benilla-ui/src/script/tests/addon_argument_abi.rs index 9df1885b..82cf4f74 100644 --- a/crates/benilla-ui/src/script/tests/addon_argument_abi.rs +++ b/crates/benilla-ui/src/script/tests/addon_argument_abi.rs @@ -166,9 +166,5 @@ fn an_unknown_name_echoes_itself_rather_than_a_placeholder_literal() { .unwrap(), "nil" ); - assert_eq!( - s.eval::(r##"return select("#", GetAddOnDependencies("Nope"))"##) - .unwrap(), - 0 - ); + assert_eq!(s.arity(r##"GetAddOnDependencies("Nope")"##).unwrap(), 0); } diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index b9045483..f452450d 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -60,8 +60,12 @@ fn get_backdrop_reconstructs_from_the_struct() { r#" local f = CreateFrame("Frame", "GB") - -- TRAP 2: no backdrop => ZERO values, not nil. `select('#')` is the only way to see it. - assert(select('#', f:GetBackdrop()) == 0, "unset backdrop must return no values at all") + -- How many values did that return? 5.0 answers with the implicit vararg table's `n`; + -- `select` is 5.1's base library and is not a 1.12 global (`lua50::install`). + local function count(...) return arg.n end + + -- TRAP 2: no backdrop => ZERO values, not nil. The count is the only way to see it. + assert(count(f:GetBackdrop()) == 0, "unset backdrop must return no values at all") -- TRAP 1: the result is rebuilt from the struct, not the caller's table. The alien key is -- the proof: SetBackdrop never accepted it, so it cannot come back out. @@ -71,7 +75,7 @@ fn get_backdrop_reconstructs_from_the_struct() { alien = "must not survive" } f:SetBackdrop(passed) local b = f:GetBackdrop() - assert(select('#', f:GetBackdrop()) == 1, "a set backdrop is exactly one value") + assert(count(f:GetBackdrop()) == 1, "a set backdrop is exactly one value") assert(b ~= passed, "must not hand back the caller's own table") assert(b.alien == nil, "keys SetBackdrop never read cannot reappear") assert(b.insets ~= passed.insets, "the insets subtable is rebuilt too") @@ -113,7 +117,7 @@ fn get_backdrop_reconstructs_from_the_struct() { -- SetBackdrop(nil) is indistinguishable from never having set one: back to zero values. f:SetBackdrop(nil) - assert(select('#', f:GetBackdrop()) == 0, "SetBackdrop(nil) returns to the zero-value shape") + assert(count(f:GetBackdrop()) == 0, "SetBackdrop(nil) returns to the zero-value shape") "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index a1d835ba..3df14a6a 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -1468,8 +1468,7 @@ fn button_get_text_color_answers_four_values_through_the_state_font() { .unwrap(); assert_eq!( - s.eval::("return select('#', PlainBtn:GetTextColor())") - .unwrap(), + s.arity("PlainBtn:GetTextColor()").unwrap(), 4, "arity 4 — not 3, the plausible wrong answer" ); @@ -1485,8 +1484,11 @@ fn button_get_text_color_answers_four_values_through_the_state_font() { // (`GetVertexColor`, `FontString:GetTextColor`) answers. let plain: Vec = (1..=4) .map(|i| { - s.eval::(&format!("return select({i}, PlainBtn:GetTextColor())")) - .unwrap() + let discards = "_, ".repeat(i - 1); + s.eval::(&format!( + "local {discards}v = PlainBtn:GetTextColor() return v" + )) + .unwrap() }) .collect(); assert_eq!(plain, vec![1.0, 1.0, 1.0, 1.0]); @@ -1495,8 +1497,11 @@ fn button_get_text_color_answers_four_values_through_the_state_font() { // `Button:GetFont` takes, and the one a stock `GameMenuButtonTemplate` button relies on. let themed: Vec = (1..=4) .map(|i| { - s.eval::(&format!("return select({i}, ThemedBtn:GetTextColor())")) - .unwrap() + let discards = "_, ".repeat(i - 1); + s.eval::(&format!( + "local {discards}v = ThemedBtn:GetTextColor() return v" + )) + .unwrap() }) .collect(); assert_eq!(themed, vec![1.0, 0.82, 0.0, 1.0]); @@ -1505,19 +1510,17 @@ fn button_get_text_color_answers_four_values_through_the_state_font() { s.run("ThemedBtn:SetTextColor(0.1, 0.2, 0.3, 0.4)").unwrap(); let set: Vec = (1..=4) .map(|i| { - s.eval::(&format!("return select({i}, ThemedBtn:GetTextColor())")) - .unwrap() + let discards = "_, ".repeat(i - 1); + s.eval::(&format!( + "local {discards}v = ThemedBtn:GetTextColor() return v" + )) + .unwrap() }) .collect(); assert_eq!(set, vec![0.1, 0.2, 0.3, 0.4]); // A CheckButton reaches it through Button's table, as it does the rest of the trio. - assert_eq!( - s.eval::( - r#"local c = CreateFrame("CheckButton", "ChkColorBtn") - return select('#', c:GetTextColor())"# - ) - .unwrap(), - 4 - ); + s.run(r#"CreateFrame("CheckButton", "ChkColorBtn")"#) + .unwrap(); + assert_eq!(s.arity("ChkColorBtn:GetTextColor()").unwrap(), 4); } diff --git a/crates/benilla-ui/src/script/tests/channel.rs b/crates/benilla-ui/src/script/tests/channel.rs index 262fc801..1b752564 100644 --- a/crates/benilla-ui/src/script/tests/channel.rs +++ b/crates/benilla-ui/src/script/tests/channel.rs @@ -122,11 +122,7 @@ fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() { let s = joined(); // Exactly two pairs for two joined channels — the arity `FCFDropDown_LoadChannels` steps over. - assert_eq!( - s.eval::("return select('#', GetChannelList())") - .unwrap(), - 4 - ); + assert_eq!(s.arity("GetChannelList()").unwrap(), 4); // Pair order and join order, both at once: slot 1 is the FIRST joined, not the alphabetical // first ("Trade - City" would sort ahead of "World"). @@ -152,12 +148,7 @@ fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() { // No channels joined is ZERO returns, so `{ GetChannelList() }` is an empty table rather than // a table of nils — every consumer above already handles that shape. let empty = crate::script::tests::common::script(); - assert_eq!( - empty - .eval::("return select('#', GetChannelList())") - .unwrap(), - 0 - ); + assert_eq!(empty.arity("GetChannelList()").unwrap(), 0); } /// **The guild-recruitment latch boots at AUTO and round-trips as a NUMBER** (decision 2115). @@ -237,8 +228,7 @@ fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does() 1.0 ); assert_eq!( - s.eval::("return select('#', SetGuildRecruitmentMode(0))") - .unwrap(), + s.arity("SetGuildRecruitmentMode(0)").unwrap(), 0, "0x4a0060 returns `xor eax,eax` — zero values, not a nil" ); diff --git a/crates/benilla-ui/src/script/tests/events.rs b/crates/benilla-ui/src/script/tests/events.rs index f6461ec7..1aaea2ec 100644 --- a/crates/benilla-ui/src/script/tests/events.rs +++ b/crates/benilla-ui/src/script/tests/events.rs @@ -294,11 +294,7 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() { .unwrap(); // Arity 0 — the verb answers nothing. - assert_eq!( - s.eval::("return select('#', All:RegisterAllEvents())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("All:RegisterAllEvents()").unwrap(), 0); // Anything dispatched now reaches it, including an event no name list could have enumerated. for ev in [ diff --git a/crates/benilla-ui/src/script/tests/font_object.rs b/crates/benilla-ui/src/script/tests/font_object.rs index c70c1b7a..e2802be9 100644 --- a/crates/benilla-ui/src/script/tests/font_object.rs +++ b/crates/benilla-ui/src/script/tests/font_object.rs @@ -951,11 +951,7 @@ fn the_font_block_reaches_both_message_frame_tables() { .unwrap(); assert_eq!(height, 16.0, "{obj}:GetFont reads back what SetFont wrote"); // The four-value getters the carve pins at 4 for every one of the six tables. - assert_eq!( - s.eval::(&format!("return select('#', {obj}:GetShadowColor())")) - .unwrap(), - 4 - ); + assert_eq!(s.arity(&format!("{obj}:GetShadowColor()")).unwrap(), 4); } // Styling the frame must not move its text: the lines still run flush LEFT. diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 6609b559..1a1661e1 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -141,11 +141,7 @@ fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() { let mut s = crate::script::UiScript::new().unwrap(); // The verb returns NOTHING — the answer is an event, never a return value. - assert_eq!( - s.eval::("return select('#', RequestTimePlayed())") - .unwrap(), - 0 - ); + assert_eq!(s.arity("RequestTimePlayed()").unwrap(), 0); // …and that call queued one ask. A COUNT, not a payload: the packet is empty, so two asks in a // frame are two sends rather than one collapsed intent (the pvp queue's rule). s.run("RequestTimePlayed() RequestTimePlayed()").unwrap(); diff --git a/crates/benilla-ui/src/script/tests/movable.rs b/crates/benilla-ui/src/script/tests/movable.rs index e256e559..56c67408 100644 --- a/crates/benilla-ui/src/script/tests/movable.rs +++ b/crates/benilla-ui/src/script/tests/movable.rs @@ -432,11 +432,7 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() { // GetTitleRegion answers ONE value and it is nil — not zero values, which is the asymmetry // Q6 flags against `GetBackdrop`. - assert_eq!( - s.eval::("return select('#', TFrame:GetTitleRegion())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("TFrame:GetTitleRegion()").unwrap(), 1); assert!(s .eval::>("return TFrame:GetTitleRegion() ~= nil and true or nil") .unwrap() diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index 87fba6aa..90eefabf 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -432,6 +432,77 @@ fn a_hovered_plate_emits_no_glow_quad() { ); } +/// **The plate's name and level seat off the UI pixel grid** (decision 2172). +/// +/// The plate is a WorldFrame overlay: the driver snaps its rect to the DEVICE pixel grid +/// (`vplates::device_snap` — half a logical pixel at 2×) because it slides continuously over the +/// world, and everything drawn inside it has to be rigid to that. The renderer's UI seat snap +/// quantizes a text block's top on the coarser LOGICAL grid, so with both laws in force the name +/// and the level pop a whole pixel every second step the border takes. +/// +/// The painter this port replaced never snapped them — it measured into degenerate rects, which +/// the renderer's carve-out skips — so this flag is what carries that seating law across decision +/// 2148's move into the frame system. It rides the extract, one field per Text quad, because the +/// renderer is the only thing that can act on it. +/// +/// It is answered from the OWNER, which is why the third block here matters: pfUI and +/// ShaguTweaks blank the stock regions and hang their own FontStrings on the plate, and a rule +/// written on our six regions would have left an addon's strings jittering inside a plate whose +/// own text had stopped. +#[test] +fn the_plates_texts_carry_the_world_seat_and_its_textures_do_not() { + use crate::script::QuadContent; + let mut s = vm(); + drive(&mut s, &[plate("Wolf", 30.0, 40.0)]); + + let seats: Vec<(String, bool)> = s + .extract() + .iter() + .filter_map(|q| match &q.content { + QuadContent::Text { + text: Some(t), + world_seat, + .. + } => Some((t.clone(), *world_seat)), + _ => None, + }) + .collect(); + assert_eq!( + seats, + vec![("Wolf".to_string(), true), ("12".to_string(), true)], + "the plate draws exactly its name and level, both seated off the UI grid" + ); + + // A FontString on an ordinary frame is interface, and interface is ON the grid — the flag has + // to be the plate's claim, not something every string picked up. + s.run( + r#"local f = CreateFrame("Frame", "Chrome", UIParent) f:SetAllPoints() + local t = f:CreateFontString("ChromeText") t:SetAllPoints() t:SetText("Chrome")"#, + ) + .unwrap(); + s.resolve(); + assert!(s.extract().iter().any(|q| matches!( + &q.content, + QuadContent::Text { text: Some(t), world_seat: false, .. } if t == "Chrome" + ))); + + // An addon's own string on the plate — pfUI's whole vanilla branch — is drawn inside the same + // sliding rect, so it gets the same seat. + s.run( + r#"local plate = WorldFrame:GetChildren() + local t = plate:CreateFontString() t:SetAllPoints() t:SetText("pfName")"#, + ) + .unwrap(); + s.resolve(); + assert!( + s.extract().iter().any(|q| matches!( + &q.content, + QuadContent::Text { text: Some(t), world_seat: true, .. } if t == "pfName" + )), + "an addon's FontString on the plate seats off the UI grid too" + ); +} + /// The suppression is **the reference's own glow art on an ADD region**, and nothing wider: an /// addon that re-textures the region gets its art painted like any other texture. /// diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 127a17c5..cdd871b1 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -48,28 +48,24 @@ fn reference() -> Vec<(String, String)> { fn allowed_beyond_1_12() -> HashSet<&'static str> { [ // ── our Lua runtime is 5.1 where 1.12's is 5.0 ──────────────────────────────────────── - // `select`'s old reason here — "our transcribed FrameXML uses it in 16 files as the 5.1 - // spelling of 5.0's implicit `arg` table" — **was true and is not any more**: 1751's - // stock-first migration retired those files, and `assets/ui`'s seven survivors contain - // zero `select(` call sites (measured 2026-09-09, decision 2142). What keeps it is now a - // different fact, and a weaker one: it is mlua's, not ours to remove without replacing the - // 5.1 varargs it comes with. The corpus reads it as a version probe — - // `pfUI/libs/libpredict.lua:141` and `ShaguTweaks/libs/libpredict.lua:82` both branch on - // `elseif select and UnitCastingInfo then`, and only our NOT having `UnitCastingInfo` - // keeps them off the TBC cast path. - // - // `_G` is the last one inherited rather than chosen: 1.12's base library does not export - // it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary does), but - // our own `getglobal`/`setglobal` are written over it. Closing it means rewriting those - // against the registry first. + // `_G` is the last of these inherited rather than chosen: 1.12's base library does not + // export it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary + // does), but our own `getglobal`/`setglobal` are written over it. Closing it means + // rewriting those against the registry first. // // **This list has been shrinking as the dialect got measured**: `coroutine` left in 1194 // with the 5.1-only members of `string`/`table`/`math`; `print` and `_VERSION` left in // 1197, when the RE dispatch read the base library's 36-entry array and neither was in it. + // `select` left last, and it is the one that shows what a written reason is worth — it + // carried two and outlived both. The first ("our transcribed FrameXML uses it in 16 files + // as the 5.1 spelling of 5.0's implicit `arg` table") was retired by 1751's migration and + // caught by 2142's census at *zero* sites. The second, written in its place, was that + // removing it meant giving up the 5.1 varargs it comes with — which 2101 had already given + // up, three days earlier, in the parser. A reason nobody re-derives is a reason with an + // expiry date; this list's job is to make the expiry visible, and twice it did not. // The list only ever covered globals — the members needed `dump_globals --members` before // anyone could see them at all. "_G", - "select", // ── WoW API past 1.12 — 1188 phase 5's list, and the reason that phase exists ───────── // **The reason written here was "every one of these is used by our own transcribed // FrameXML today", and as of 2026-09-09 that is true of exactly ONE of the sixteen** @@ -241,8 +237,10 @@ fn get_texture_returns_the_stripped_path_and_solid_texture_for_a_fill() { None ); assert_eq!( - s.eval::("local a,b = GTFTex:GetTexture() return select('#', GTFTex:GetTexture())") - .unwrap_or(1), + // `.unwrap()`, not the `.unwrap_or(1)` this carried while it was spelled with `select`: + // that default made a raise indistinguishable from the answer being asserted, which is the + // one thing an arity check must not do (2171). + s.arity("GTFTex:GetTexture()").unwrap(), 1, "exactly one return value" ); @@ -299,16 +297,11 @@ fn region_shadow_accessors_round_trip_four_values() { .unwrap(); assert_eq!( - s.eval::("return select('#', SHFText:GetShadowColor())") - .unwrap(), + s.arity("SHFText:GetShadowColor()").unwrap(), 4, "GetShadowColor returns FOUR values — three drops the alpha" ); - assert_eq!( - s.eval::("return select('#', SHFText:GetShadowOffset())") - .unwrap(), - 2 - ); + assert_eq!(s.arity("SHFText:GetShadowOffset()").unwrap(), 2); // NavigatorFu's line, in shape: a font object's shadow piped straight into a region's. s.run("SHFText:SetShadowColor(0, 0, 0, 0.3) SHFText:SetShadowOffset(1, -1)") @@ -379,8 +372,7 @@ fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() { // FuXPFu's line, and it returns nothing at all. assert_eq!( - s.eval::("return select('#', Spark:SetParent(B))") - .unwrap(), + s.arity("Spark:SetParent(B)").unwrap(), 0, "SetParent returns zero values on every path" ); @@ -467,7 +459,7 @@ fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() { // _LazyPig's line, on a Button that has no label at all. assert_eq!( - s.eval::(r#"return select('#', SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8))"#) + s.arity(r#"SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8)"#) .unwrap(), 0, "SetFont returns ZERO values — the delegate's 1/nil is discarded by a Button" @@ -479,8 +471,7 @@ fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() { ); assert_eq!( - s.eval::("return select('#', SFBare:GetFont())") - .unwrap(), + s.arity("SFBare:GetFont()").unwrap(), 3, "GetFont returns THREE values" ); @@ -490,11 +481,7 @@ fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() { ("Fonts\\FRIZQT__.TTF".to_string(), 8.0, String::new()) ); // Still three when nothing has ever set a font — path and height are nil, not absent. - assert_eq!( - s.eval::("return select('#', SFLabelled:GetFont())") - .unwrap(), - 3 - ); + assert_eq!(s.arity("SFLabelled:GetFont()").unwrap(), 3); assert!(s .eval::("local f = SFLabelled:GetFont() return f == nil") .unwrap()); @@ -698,8 +685,7 @@ fn editbox_font_block_return_shapes_are_the_shared_implementations() { r#"SetJustifyV("TOP")"#, ] { assert_eq!( - s.eval::(&format!("return select('#', EBShape:{call})")) - .unwrap(), + s.arity(&format!("EBShape:{call}")).unwrap(), 0, "EditBox:{call} must return ZERO values" ); @@ -716,8 +702,7 @@ fn editbox_font_block_return_shapes_are_the_shared_implementations() { ("GetJustifyV()", 1), ] { assert_eq!( - s.eval::(&format!("return select('#', EBShape:{call})")) - .unwrap(), + s.arity(&format!("EBShape:{call}")).unwrap(), want, "EditBox:{call} must return {want} value(s)" ); @@ -725,7 +710,7 @@ fn editbox_font_block_return_shapes_are_the_shared_implementations() { // SetFont: ONE value, and it is the number 1 — not `true`, and not nothing (the Button shape). assert_eq!( - s.eval::(r#"return select('#', EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#) + s.arity(r#"EBShape:SetFont("Fonts\\SKURRI.TTF", 12)"#) .unwrap(), 1, "EditBox:SetFont returns one value, unlike Button:SetFont" @@ -976,8 +961,7 @@ fn set_desaturated_reports_shader_support_and_does_not_raise() { "shaderSupported must be 1 (1|nil C shape), not true" ); assert_eq!( - s.eval::("return select('#', DsTex:SetDesaturated(true))") - .unwrap(), + s.arity("DsTex:SetDesaturated(true)").unwrap(), 1, "one return value" ); @@ -1093,11 +1077,7 @@ fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() { ); // Three values, and the second is the empty-slot background art the paper-doll buttons use. - assert_eq!( - s.eval::("return select('#', GetInventorySlotInfo('HeadSlot'))") - .unwrap(), - 3 - ); + assert_eq!(s.arity("GetInventorySlotInfo('HeadSlot')").unwrap(), 3); let (id, art) = s .eval::<(i64, String)>("return GetInventorySlotInfo('HeadSlot')") .unwrap(); @@ -1279,8 +1259,7 @@ fn the_region_map_readers_answer_the_way_the_edges_do() { ); // Out of range is five nils, like the frame twin. assert_eq!( - s.eval::("return select('#', RRPlate:GetPoint(7))") - .unwrap(), + s.arity("RRPlate:GetPoint(7)").unwrap(), 5, "an out-of-range index still answers five values, all nil" ); @@ -1320,8 +1299,7 @@ fn the_type_identity_verbs_answer_what_the_binary_answers() { "FontString" ); assert_eq!( - s.eval::("return select('#', TITex:GetObjectType('ignored'))") - .unwrap(), + s.arity("TITex:GetObjectType('ignored')").unwrap(), 1, "one value, and a stray argument is ignored rather than an arity error" ); @@ -1386,8 +1364,7 @@ fn the_type_identity_verbs_answer_what_the_binary_answers() { ); for arg in ["'Texture'", "'nope'"] { assert_eq!( - s.eval::(&format!("return select('#', TITex:IsObjectType({arg}))")) - .unwrap(), + s.arity(&format!("TITex:IsObjectType({arg})")).unwrap(), 1, "exactly one value on both the hit and the miss path" ); @@ -1750,16 +1727,11 @@ fn set_world_detail_writes_the_stop_table_and_validates_like_the_reference() { // Zero return values from the setter; exactly one number from the getter. s.run("SetWorldDetail(1)").unwrap(); assert_eq!( - s.eval::("return select('#', SetWorldDetail(1))") - .unwrap(), + s.arity("SetWorldDetail(1)").unwrap(), 0, "every `ret` in 0x488dd0 leaves eax = 0" ); - assert_eq!( - s.eval::("return select('#', GetWorldDetail())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("GetWorldDetail()").unwrap(), 1); assert_eq!( s.eval::("return type(GetWorldDetail())").unwrap(), "number" @@ -1831,7 +1803,7 @@ fn the_pfui_hdgraphic_extended_arm_runs() { /// - **No argument is read.** `ShowNameplates(false)` still shows — there is no `lua_gettop` and /// no `lua_toboolean` in any of the four. That is *why* there are four verbs instead of two /// taking a flag, and a binding typed `fn(bool)` would reject a call the reference accepts. -/// - **Zero return values, not nil.** `select('#', ...)` sees 0, which is observably different +/// - **Zero return values, not nil.** The return-list count is 0, which is observably different /// from one nil for a caller that counts. #[test] fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() { @@ -1884,8 +1856,7 @@ fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() { // ZERO return values, not one nil. assert_eq!( - s.eval::("return select('#', ShowNameplates())") - .unwrap(), + s.arity("ShowNameplates()").unwrap(), 0, "zero values — observably different from nil for a caller that counts" ); diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index bbbb10b4..7b19ba14 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -1415,12 +1415,7 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() { .unwrap(); // Arity 1 and kind string, measured the way the shape gate measures it. - assert_eq!( - s.eval::("return select('#', BTex:GetBlendMode())") - .unwrap(), - 1, - "arity 1" - ); + assert_eq!(s.arity("BTex:GetBlendMode()").unwrap(), 1, "arity 1"); assert_eq!( s.eval::("return type(BTex:GetBlendMode())") .unwrap(), @@ -1493,8 +1488,7 @@ fn tex_coord_modifies_rect_is_one_slash_nil_and_moves_no_rect_yet() { let before = region_tex_rect(&s, "TcmArt"); assert_eq!( - s.eval::("return select('#', TCMTex:GetTexCoordModifiesRect())") - .unwrap(), + s.arity("TCMTex:GetTexCoordModifiesRect()").unwrap(), 1, "arity 1" ); diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 9d51ebfa..8d936239 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -673,16 +673,8 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() { ); // Arity/kind, the way the shape gate measures them. - assert_eq!( - s.eval::("return select('#', HSF:GetHorizontalScroll())") - .unwrap(), - 1 - ); - assert_eq!( - s.eval::("return select('#', HSF:GetHorizontalScrollRange())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("HSF:GetHorizontalScroll()").unwrap(), 1); + assert_eq!(s.arity("HSF:GetHorizontalScrollRange()").unwrap(), 1); assert_eq!( s.eval::("return type(HSF:GetHorizontalScroll())") .unwrap(), @@ -694,8 +686,7 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() { "number" ); assert_eq!( - s.eval::("return select('#', HSF:SetHorizontalScroll(450))") - .unwrap(), + s.arity("HSF:SetHorizontalScroll(450)").unwrap(), 0, "the setter answers nothing" ); diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 522e955e..846e8cb9 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -611,12 +611,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() { // Arity 1, kind string, and NO nil leg — the shapes row is `(string)` with no alternative, so // a plate nobody has owned still answers a string. - assert_eq!( - s.eval::("return select('#', AnchorTip:GetAnchorType())") - .unwrap(), - 1, - "arity 1" - ); + assert_eq!(s.arity("AnchorTip:GetAnchorType()").unwrap(), 1, "arity 1"); assert_eq!( s.eval::("return type(AnchorTip:GetAnchorType())") .unwrap(), diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 9e1cb0bb..aa1e8307 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -231,15 +231,17 @@ fn set_inventory_item_renders_full_outside_compare() { -- empty slot answering ONE value hands its caller a nil where a number belongs. pfUI's -- durability scan (panel.lua:499) does `totalRep + repCost` with no guard at all and died -- exactly there. - -- Counted with `select`, NOT `table.getn` on a captured table: 5.0's `luaL_getn` counts - -- rawgeti to the first nil (decision 2102), so `{ f() }` where f answers `1, nil, 0` - -- measures ONE — a hole, not a short return. - assert(select('#', tt:SetInventoryItem("player", 16)) == 3, - "occupied: three returns, got " .. select('#', tt:SetInventoryItem("player", 16))) + -- Counted through the implicit vararg table's `n` — 5.0's own answer, and the only one + -- this VM has (`select` is 5.1's base library, not a 1.12 global). NOT `table.getn` on a + -- captured table: 5.0's `luaL_getn` counts rawgeti to the first nil (decision 2102), so + -- `{ f() }` where f answers `1, nil, 0` measures ONE — a hole, not a short return. + local function count(...) return arg.n end + assert(count(tt:SetInventoryItem("player", 16)) == 3, + "occupied: three returns, got " .. count(tt:SetInventoryItem("player", 16))) local _, _, repairCost = tt:SetInventoryItem("player", 16) assert(repairCost == 0, "repairCost is a NUMBER — the reference always pushes one; 0 INTERIM") - assert(select('#', tt:SetInventoryItem("player", 5)) == 3, - "empty: three returns too, got " .. select('#', tt:SetInventoryItem("player", 5))) + assert(count(tt:SetInventoryItem("player", 5)) == 3, + "empty: three returns too, got " .. count(tt:SetInventoryItem("player", 5))) local hasItem, _, emptyCost = tt:SetInventoryItem("player", 5) assert(hasItem == nil and emptyCost == 0, "empty slot: no item, but still a numeric repairCost") diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index c9b93445..c9ec118d 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -389,7 +389,7 @@ fn requirement_and_reagent_lines_render_in_law_order() { assert(TTTextLeft2:GetText() == "30 yd range", "cost absent: range moves left") assert(TTTextLeft3:GetText() == "Instant") assert(TTTextLeft4:GetText() == "Requires Wands", "law §3.6 sits above the reagents") - assert(TTTextLeft5:GetText():find("Light Feather") ~= nil, "law §3.8") + assert(string.find(TTTextLeft5:GetText(), "Light Feather") ~= nil, "law §3.8") assert(TTTextLeft6:GetText() == "Attack with an equipped wand.", "description last") "#, ) @@ -1014,8 +1014,7 @@ fn quest_reward_spell_getters_and_hovers() { s.set_quest(Some(QuestState::default())); assert_eq!( - s.eval::("return select('#', GetRewardSpell())") - .unwrap(), + s.arity("GetRewardSpell()").unwrap(), 3, "three values on the empty path" ); diff --git a/crates/benilla-ui/src/script/tradeskill/tests.rs b/crates/benilla-ui/src/script/tradeskill/tests.rs index c909129a..933d709d 100644 --- a/crates/benilla-ui/src/script/tradeskill/tests.rs +++ b/crates/benilla-ui/src/script/tradeskill/tests.rs @@ -266,11 +266,7 @@ fn do_trade_skill_and_getters_no_op_on_a_header_index() { assert!(s .eval::("return GetTradeSkillReagentInfo(1, 1) == nil") .unwrap()); - assert_eq!( - s.eval::("return select('#', GetTradeSkillTools(1))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetTradeSkillTools(1)").unwrap(), 0); s.run("DoTradeSkill(1, 5)").unwrap(); assert!( @@ -298,8 +294,7 @@ fn do_trade_skill_and_getters_no_op_on_a_header_index() { fn get_trade_skill_sub_classes_returns_group_names_in_order() { let mut s = UiScript::new().unwrap(); assert_eq!( - s.eval::("return select('#', GetTradeSkillSubClasses())") - .unwrap(), + s.arity("GetTradeSkillSubClasses()").unwrap(), 0, "no window open, no groups" ); @@ -563,22 +558,14 @@ fn get_trade_skill_tools_multivalue_shape() { ("Anvil", Some(1), "Mining Pick", None) ); - // A recipe with no tools returns an empty multivalue (select('#', ...) == 0). + // A recipe with no tools returns an empty multivalue (arity 0, not one nil). let mut t2 = two_recipe_state(); t2.recipes[0].tools.clear(); s.set_trade_skill(Some(t2)); - assert_eq!( - s.eval::("return select('#', GetTradeSkillTools(3))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetTradeSkillTools(3)").unwrap(), 0); // A HEADER index (row 1) also returns an empty multivalue. - assert_eq!( - s.eval::("return select('#', GetTradeSkillTools(1))") - .unwrap(), - 0 - ); + assert_eq!(s.arity("GetTradeSkillTools(1)").unwrap(), 0); } /// The verified persistence story (wow-re `tradeskill` TU-G §6, the `0xbde064` cache key): @@ -648,8 +635,7 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() { assert_eq!(s.eval::("return GetNumTradeSkills()").unwrap(), 3); assert_eq!(row_kind(&mut s, 1), ("Armor Kit".into(), "header".into())); assert_eq!( - s.eval::("return select('#', GetTradeSkillSubClasses())") - .unwrap(), + s.arity("GetTradeSkillSubClasses()").unwrap(), 4, "the dropdown vocabulary stays full under a filter" ); @@ -869,9 +855,9 @@ fn the_link_verbs_answer_the_clients_shapes() { ..Default::default() }, ); - assert!( - s.eval::("return select('#', GetTradeSkillItemLink(1)) == 0") - .unwrap(), + assert_eq!( + s.arity("GetTradeSkillItemLink(1)").unwrap(), + 0, "a header row answers zero values" ); let link = s @@ -906,10 +892,15 @@ fn the_link_verbs_answer_the_clients_shapes() { r.reagents[0].item ) ); + assert_eq!( + s.arity("GetTradeSkillReagentItemLink(2, 9)").unwrap(), + 1, + "past the reagents: still exactly one value" + ); assert!( - s.eval::("return select('#', GetTradeSkillReagentItemLink(2, 9)) == 1 and GetTradeSkillReagentItemLink(2, 9) == nil") + s.eval::("return GetTradeSkillReagentItemLink(2, 9) == nil") .unwrap(), - "past the reagents: still exactly one value, nil" + "past the reagents: that one value is nil" ); let err = s .run("GetTradeSkillReagentItemLink(2, nil)") diff --git a/crates/benilla-ui/src/script/tutorial.rs b/crates/benilla-ui/src/script/tutorial.rs index 9f881e1c..82220a5b 100644 --- a/crates/benilla-ui/src/script/tutorial.rs +++ b/crates/benilla-ui/src/script/tutorial.rs @@ -106,11 +106,7 @@ mod tests { #[test] fn tutorials_enabled_scans_the_whole_acknowledged_bank() { let mut s = UiScript::new().unwrap(); - assert_eq!( - s.eval::("return select('#', TutorialsEnabled())") - .unwrap(), - 1 - ); + assert_eq!(s.arity("TutorialsEnabled()").unwrap(), 1); assert!( s.eval::("return TutorialsEnabled() == nil").unwrap(), "no bank" diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 170ed709..3f33ba67 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -256,6 +256,17 @@ pub enum QuadContent { /// renderer multiplies each glyph's alpha by the ramp at its character position (before /// `start` opaque, the next `length` chars 1→0, beyond invisible). `None` = draw whole. alpha_gradient: Option<(f32, f32)>, + /// **Seat this block exactly where `rect` puts it**, skipping the UI grid's vertical + /// block-top snap — true for a FontString owned by a V-plate, false for every other one + /// (`super::nameplate::is_world_seated`, decision 2172). + /// + /// Not a client concept: in the reference the whole interface *is* the pixel grid, so the + /// snap and the rect agree by construction. benilla has one family of frames that lives + /// outside it — the WorldFrame overlays that slide continuously over the 3-D scene, whose + /// seat law is a DEVICE-pixel snap (`vplates::device_snap`, 0188/1398) rather than the + /// UI's logical-pixel one. Snapping their text on the coarser UI grid makes it *beat* + /// against the art it is supposed to be rigid to: two quantizers on one sliding object. + world_seat: bool, }, } diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 74718700..6b469b8e 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -892,8 +892,7 @@ fn unit_is_charmed_answers_one_or_nil_and_only_for_the_charmed_side() { "the reference pushes a number, not a boolean" ); assert_eq!( - s.eval::(r#"return select('#', UnitIsCharmed("target"))"#) - .unwrap(), + s.arity(r#"UnitIsCharmed("target")"#).unwrap(), 1, "exactly one value on the hit path" ); @@ -1801,8 +1800,7 @@ fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() { "{name}: `== true` must NEVER match — the reference cannot push tag 1" ); assert_eq!( - s.eval::(&format!("return select('#', {call})")) - .unwrap(), + s.arity(call).unwrap(), 1, "{name} returns exactly one value on the true leg" ); @@ -1824,8 +1822,7 @@ fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() { "{name}: `== false` must never match" ); assert_eq!( - s.eval::(&format!("return select('#', {call})")) - .unwrap(), + s.arity(call).unwrap(), 1, "{name} returns exactly one value on the false leg too — nil, never zero values" ); diff --git a/crates/benilla-ui/src/script/weapon_enchant.rs b/crates/benilla-ui/src/script/weapon_enchant.rs index c5aaa92d..96e14620 100644 --- a/crates/benilla-ui/src/script/weapon_enchant.rs +++ b/crates/benilla-ui/src/script/weapon_enchant.rs @@ -145,11 +145,7 @@ mod tests { let mut s = UiScript::new().unwrap(); // Nothing equipped or nothing enchanted: six nils, not zero values. - assert_eq!( - s.eval::("return select('#', GetWeaponEnchantInfo())") - .unwrap(), - 6 - ); + assert_eq!(s.arity("GetWeaponEnchantInfo()").unwrap(), 6); assert!(s .eval::( "local a, b, c, d, e, f = GetWeaponEnchantInfo() \ @@ -170,8 +166,7 @@ mod tests { None, ); assert_eq!( - s.eval::("return select('#', GetWeaponEnchantInfo())") - .unwrap(), + s.arity("GetWeaponEnchantInfo()").unwrap(), 6, "still six with only one weapon enchanted" ); @@ -243,8 +238,7 @@ mod tests { // `GetInventoryAlertStatus`, which has both), so an addon that guesses at a slot argument // still gets an answer rather than a raised error. assert_eq!( - s.eval::("return select('#', GetWeaponEnchantInfo(16, \"nonsense\"))") - .unwrap(), + s.arity("GetWeaponEnchantInfo(16, \"nonsense\")").unwrap(), 6 ); } diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index a2351706..dda85d26 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -434,9 +434,9 @@ mod tests { (pos.0 - 0.0131).abs() < 1e-5 && (pos.1 - 0.01315).abs() < 1e-5 && pos.2 == 0.0, "centred on the box: {pos:?}" ); - assert!( - s.eval::("return select('#', CreateWorldMapArrowFrame(WM)) == 0") - .unwrap(), + assert_eq!( + s.arity("CreateWorldMapArrowFrame(WM)").unwrap(), + 0, "zero values" ); } diff --git a/crates/benilla-world/src/collision.rs b/crates/benilla-world/src/collision.rs index abaf1bc6..dbb55d18 100644 --- a/crates/benilla-world/src/collision.rs +++ b/crates/benilla-world/src/collision.rs @@ -35,22 +35,6 @@ pub(crate) enum CollisionLayer { Walk, /// WMO faces only the **camera** collides with — the camera/LOS gather (skip NOCAMCOLLIDE `0x02`). Camera, - /// **Liquid surfaces** — the wet-cell lattice of every MCLQ layer and WMO pool, on a layer of - /// their own so that *no* query sees them unless it asks. - /// - /// This is `cameraWaterCollision`'s primary consumer and the shape the reference gives it: the - /// CVar does not re-lane any geometry, it adds `0xf0000` to the **trace mask** the solver hands - /// its three collision queries (`0x50e5ec`; the nibble is read at `0x69cc13` and gates a - /// per-layer intersection over the chunk's four MCLQ slots — `0x10000` river/lake, `0x20000` - /// ocean, `0x40000` magma, `0x80000` slime). A per-trace mask is exactly a `SpatialQueryFilter`, - /// which is why this is a layer and not a component the camera looks for. - /// - /// wow-re `ui/scratch/water-band-discontinuity.md`. It **refutes** that tree's own - /// `camera-arm-liquid-blind.md` §2, a VERIFIED NEGATIVE that stood from June: the census was - /// correct and controlled, but a capability requested through an argument flag is invisible to - /// any census of call sites. Decision 2149 rested benilla on that verdict and built the wrong - /// half of the feature; 2165 took it out. - Liquid, } /// `CollisionLayers` for a WMO's **walking** collider — member of [`CollisionLayer::Walk`] only, so the @@ -65,16 +49,6 @@ pub(crate) fn camera_layers() -> CollisionLayers { CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL) } -/// `CollisionLayers` for a **liquid surface** — member of [`CollisionLayer::Liquid`] only. -/// -/// Every existing filter omits that bit, so these colliders are inert to the body, the camera, the -/// mouse pick and the particle snap alike until a query asks for them by name. A swimmer must not -/// be stopped by the water they are in, and nothing but the camera sweep under -/// `cameraWaterCollision` has any business hitting a waterline. -pub(crate) fn liquid_layers() -> CollisionLayers { - CollisionLayers::new(CollisionLayer::Liquid, LayerMask::ALL) -} - /// **How many times the world's collider set has changed** — bumped when [`crate::terrain_stream`] /// attaches a batch of built colliders, and when any collider is removed (a tile or placement /// streaming out, an entity despawning). @@ -200,16 +174,10 @@ impl WorldCollision<'_, '_> { /// …and what the third-person **camera** collides with: the other way round on those two WMO /// face layers (it takes NOCAMCOLLIDE faces the body walks through, and skips the DETAIL faces /// the body walks on). - /// `liquid` adds the waterline — `cameraWaterCollision`'s primary consumer. The reference does - /// exactly this and no more: `0x50e5ec` ORs `0xf0000` into the trace mask word the solver hands - /// its three collision queries, so the CVar changes *what this one query is allowed to see* and - /// re-lanes nothing. `false` and it is byte-for-byte the filter it always was. - pub(crate) fn camera_filter(liquid: bool) -> SpatialQueryFilter { - let mut mask = CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(); - if liquid { - mask |= CollisionLayer::Liquid.to_bits(); - } - SpatialQueryFilter::from_mask(LayerMask(mask)) + pub(crate) fn camera_filter() -> SpatialQueryFilter { + SpatialQueryFilter::from_mask(LayerMask( + CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(), + )) } /// Sweep `shape` along `movement` against the body's world. @@ -251,10 +219,6 @@ impl WorldCollision<'_, '_> { /// Deliberately avian's own two-sided cast rather than the one-sided law above: the facing /// gate exists because a *body* must not stand on a face wound away from it, and a camera has /// no such contract — it just must not end up inside geometry, from either side. - /// - /// `liquid` is `cameraWaterCollision`, straight through: with it set the boom stops at a - /// waterline the way it stops at ground, which is the whole of the option's primary effect and - /// what its own tooltip promises the player. pub fn cast_camera( &self, shape: &Collider, @@ -262,7 +226,6 @@ impl WorldCollision<'_, '_> { rotation: Quat, movement: Vec3, skin_width: f32, - liquid: bool, ) -> Option { self.ms.cast_move( shape, @@ -270,7 +233,7 @@ impl WorldCollision<'_, '_> { rotation, movement, skin_width, - &Self::camera_filter(liquid), + &Self::camera_filter(), ) } @@ -389,85 +352,6 @@ impl WorldCollision<'_, '_> { } } -/// **`cameraWaterCollision` is a trace mask and nothing else.** The same waterline collider is a -/// wall to the camera boom with the CVar on, thin air with it off, and thin air to the walking body -/// either way — which is the whole of the reference's `0x50e5ec`, and the reason a swimmer is never -/// stopped by the water they are in. -#[cfg(test)] -mod liquid_trace_mask { - use super::*; - use bevy::ecs::system::RunSystemOnce; - use bevy::prelude::*; - - /// A headless world holding one 10×10 horizontal surface on [`CollisionLayer::Liquid`]. - fn world_with_waterline() -> App { - let mut app = App::new(); - app.add_plugins(( - MinimalPlugins, - bevy::transform::TransformPlugin, - bevy::asset::AssetPlugin::default(), - bevy::scene::ScenePlugin, - PhysicsPlugins::new(bevy::app::PostUpdate), - )); - app.init_asset::(); - app.world_mut().spawn(( - RigidBody::Static, - Collider::trimesh( - vec![ - Vec3::new(-5.0, 0.0, -5.0), - Vec3::new(5.0, 0.0, -5.0), - Vec3::new(5.0, 0.0, 5.0), - Vec3::new(-5.0, 0.0, 5.0), - ], - vec![[0u32, 2, 1], [0, 3, 2]], - ), - liquid_layers(), - Transform::default(), - )); - app.finish(); - app.cleanup(); - app.update(); - app - } - - /// Drop a small probe from above the surface onto it, through `filter`. - fn descend(app: &mut App, filter: SpatialQueryFilter) -> Option { - app.world_mut() - .run_system_once(move |ms: MoveAndSlide| { - ms.cast_move( - &Collider::sphere(0.1), - Vec3::new(0.0, 3.0, 0.0), - Quat::IDENTITY, - Vec3::new(0.0, -6.0, 0.0), - 0.0, - &filter, - ) - .map(|h| h.distance) - }) - .expect("system runs") - } - - #[test] - fn the_waterline_stops_the_camera_only_when_the_cvar_asks_for_it() { - let mut app = world_with_waterline(); - let on = descend(&mut app, WorldCollision::camera_filter(true)); - assert!( - on.is_some_and(|d| (d - 2.9).abs() < 0.05), - "with cameraWaterCollision the boom stops at the surface, got {on:?}" - ); - assert_eq!( - descend(&mut app, WorldCollision::camera_filter(false)), - None, - "with the CVar off the camera passes through, exactly as it did before this existed" - ); - assert_eq!( - descend(&mut app, WorldCollision::body_filter()), - None, - "and the BODY passes through either way — a swimmer is not stopped by their own water" - ); - } -} - /// The contact pipeline we deliberately do not run (decision 1232, disabled in `world_plugins`). /// /// These two tests are a matched pair: the first shows the cost was real — a kinematic trimesh diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index f276001f..51ea2d70 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -24,9 +24,7 @@ use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool}; -use crate::collision::liquid_layers; use crate::lighting::WATER_SHININESS; -use avian3d::prelude::{Collider, RigidBody}; use benilla_assets::coords::wow_to_bevy; use benilla_assets::materials::{LiquidExt, LiquidMaterial}; use benilla_assets::LockRecover; @@ -294,41 +292,9 @@ pub(crate) fn spawn_liquids<'a>( .entity(*entities.last().expect("just pushed")) .insert(FoamPatch); } - // **The waterline the camera sweep can hit** under `cameraWaterCollision` — inert to every - // other query, because nothing else asks for [`CollisionLayer::Liquid`]. - if let Some(collider) = liquid_collider(lq) { - commands - .entity(*entities.last().expect("just pushed")) - .insert((collider, RigidBody::Static, liquid_layers())); - } } } -/// The **collision** shape for one [`LiquidMesh`] — the same wet-cell triangles the render mesh -/// draws, as a parry trimesh on [`CollisionLayer::Liquid`](crate::collision::CollisionLayer). -/// -/// `lq.indices` is already the wet derivative (6 per wet cell), so this is the waterline exactly -/// where there is water and nothing over the dry cells of a part-flooded chunk — which is the -/// difference between a camera that stops at a lake's edge and one that stops in mid-air over the -/// shore. Built inline rather than through [`crate::terrain_stream::PendingCollider`]'s async pool: -/// an MCNK layer's 9×9 lattice is at most 64 cells, 128 triangles, next to the 256 a terrain chunk -/// defers, and a WMO pool's MLIQ is the same order. -/// -/// `None` for a degenerate layer with no triangles, which is a real case (an MCLQ layer whose cells -/// are all dry) and not a failure. -fn liquid_collider(lq: &LiquidMesh) -> Option { - let tris: Vec<[u32; 3]> = lq - .indices - .chunks_exact(3) - .map(|c| [c[0], c[1], c[2]]) - .collect(); - if tris.is_empty() { - return None; - } - let verts: Vec = lq.positions.iter().map(|p| wow_to_bevy(*p)).collect(); - Some(Collider::trimesh(verts, tris)) -} - /// Build the Bevy render mesh for one [`LiquidMesh`]: positions mapped WoW→Bevy (`lq.positions` are /// raw WoW coords — absolute for MCLQ, WMO-model-local for WMO liquid), a flat up normal, the tiling /// UVs, and the per-vertex swatch `V` packed into UV1.x for the shader's colour/opacity ramp. The @@ -473,15 +439,6 @@ pub(crate) fn spawn_wmo_liquids<'a>( if !lq.kind.is_fullbright() { commands.entity(surface).insert(FoamPatch); } - // The camera's waterline, as on the ADT path. The shape is MODEL-LOCAL here and the - // entity carries the placement `transform`, so avian lifts it into the world the same way - // it lifts the render mesh — no second bake, and a rotated pool stays consistent with the - // surface the player sees. - if let Some(collider) = liquid_collider(lq) { - commands - .entity(surface) - .insert((collider, RigidBody::Static, liquid_layers())); - } entities.push(surface); } }