plate text settles, mobs stop sinking, and select goes with Lua 5.1
- a nameplate's name and level stop popping a pixel every other step as the plate slides, and the whole plate no longer jumps 19 px to the top of the screen on a rounding tie; the chat bubble takes the same fix (2172) - creatures and other players stop sinking through the ground while the world is still streaming in, where a Stormwind patroller had been 31 yd under its street: a body our world cannot yet answer for holds the server's pose (2174) - select and the string metatable are gone, as in 1.12's Lua 5.0: pfUI's heal prediction parses its messages with the right format, BuffCheck2's own select wins its fallback, and a method call on a string raises the reference's own error (2171) - the camera's water collision comes back out for now: with the pivot a hair under the plane, a surface swimmer's camera snapped in and out against the waterline, so the boom ignores water again (2173) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<display>` — 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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Res<'w, crate::target::ring::Factions>>,
|
||||
self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
|
||||
/// 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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<ColliderEpoch>().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})"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
/// **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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool> =
|
||||
@@ -29,6 +29,9 @@ pub(super) struct TextStyle {
|
||||
pub shadow: Option<FontShadow>,
|
||||
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::<String>()
|
||||
),
|
||||
);
|
||||
}
|
||||
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::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
out.extend(glyphs);
|
||||
// The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture —
|
||||
|
||||
@@ -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::<i64>("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::<i64>(
|
||||
"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::<i64>("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::<i64>("return select(\"#\", GetGuildRosterInfo(0))")
|
||||
.unwrap(),
|
||||
s.arity("GetGuildRosterInfo(0)").unwrap(),
|
||||
10,
|
||||
"…including index 0, the nothing-selected case the reference passes unguarded"
|
||||
);
|
||||
|
||||
@@ -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::<bool>("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil")
|
||||
.unwrap(),
|
||||
s.eval::<bool>(
|
||||
"local _, _, _, quality = GetLootSlotInfo(1) \
|
||||
return ITEM_QUALITY_COLORS[quality] ~= nil"
|
||||
)
|
||||
.unwrap(),
|
||||
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
|
||||
);
|
||||
|
||||
|
||||
@@ -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::<i64>("return select('#', Minimap:GetPingPosition())")
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(s.arity("Minimap:GetPingPosition()").unwrap(), 2);
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return (Minimap:GetPingPosition())").unwrap(),
|
||||
0.0,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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::<i64>(&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::<i64>(&probe) else {
|
||||
|
||||
@@ -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::<i64>("return (select(7, GetSkillLineInfo(2)))")
|
||||
s.eval::<i64>("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 → <OnVerticalScroll> →
|
||||
// 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::<i64>("return FauxScrollFrame_GetOffset(SkillListScrollFrame)")
|
||||
|
||||
@@ -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::<i64>("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::<i64>("return select('#', GetWorldStateUIInfo(2))")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.arity("GetWorldStateUIInfo(2)").unwrap(), 1);
|
||||
assert_eq!(s.eval::<i64>("return GetWorldStateUIInfo(2)").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetWorldStateUIInfo(0)").unwrap(),
|
||||
|
||||
@@ -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<UiQuad> {
|
||||
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<LinkSpan>,
|
||||
) -> Vec<UiQuad> {
|
||||
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<LinkSpan>>,
|
||||
) -> Vec<UiQuad> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
@@ -642,11 +642,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
// One value on every non-raising path — never zero values.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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).
|
||||
|
||||
@@ -247,9 +247,7 @@ mod tests {
|
||||
return a == nil and b == nil and c == nil and d == nil"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("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::<bool>("return select('#', SetActionBarToggles(1)) == 0")
|
||||
.unwrap());
|
||||
assert_eq!(s.arity("SetActionBarToggles(1)").unwrap(), 0);
|
||||
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<i64>("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::<i64>("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::<i64>(&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::<i64>(&format!("return select('#', CancelPlayerBuff({arg}))"))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity(&format!("CancelPlayerBuff({arg})")).unwrap(), 0);
|
||||
}
|
||||
s.take_cancel_aura_requests();
|
||||
|
||||
|
||||
@@ -153,11 +153,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldPosition(1))")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(s.arity("GetBattlefieldPosition(1)").unwrap(), 3);
|
||||
s.set_battlefield_positions(
|
||||
vec![
|
||||
BattlefieldPositionView {
|
||||
|
||||
@@ -446,32 +446,25 @@ mod tests {
|
||||
fn get_battlefield_info_answers_nine_or_none() {
|
||||
let mut s = vm();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("return select('#', GetBattlefieldInfo())")
|
||||
.unwrap(),
|
||||
s.arity("GetBattlefieldInfo()").unwrap(),
|
||||
0,
|
||||
"no player: the other gate, read at call time"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("return select('#', GetBattlefieldInfo())")
|
||||
.unwrap(),
|
||||
9
|
||||
);
|
||||
assert_eq!(s.arity("GetBattlefieldInfo()").unwrap(), 9);
|
||||
let got = s
|
||||
.eval::<String>(
|
||||
"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::<i64>("return select('#', GetBattlefieldInstanceInfo(3))")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>(&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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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::<i64>("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::<i64>("return select('#', LeaveChannelByName('x'))")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("LeaveChannelByName('x')").unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,8 +265,7 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return LoggingChat() == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return LoggingCombat() == nil").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', LoggingChat(true))")
|
||||
.unwrap(),
|
||||
s.arity("LoggingChat(true)").unwrap(),
|
||||
0,
|
||||
"the setter returns nothing"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("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::<i64>("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::<i64>("return GetNumLaguages()").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetLanguageByIndex(1))")
|
||||
.unwrap(),
|
||||
s.arity("GetLanguageByIndex(1)").unwrap(),
|
||||
0,
|
||||
"past the end pushes nothing, not nil"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("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::<bool>("return select(7, GetChatWindowInfo(1)) == nil")
|
||||
.eval::<bool>("local _, _, _, _, _, _, sh = GetChatWindowInfo(1) return sh == nil")
|
||||
.unwrap());
|
||||
assert_eq!(s.take_chat_window_changes(), vec![0, 2]);
|
||||
s.run("SetChatWindowName(3, 'Loot')").unwrap();
|
||||
|
||||
@@ -327,7 +327,7 @@ mod tests {
|
||||
vec!["1.12.1", "5875", "Sep 19 2006"]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("return select('#', GetLocale())").unwrap(),
|
||||
s.arity("GetLocale()").unwrap(),
|
||||
1,
|
||||
"GetLocale pushes exactly one value"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return GetLocale()").unwrap(), "enUS");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', IsMacClient())").unwrap(),
|
||||
s.arity("IsMacClient()").unwrap(),
|
||||
1,
|
||||
"IsMacClient pushes one value, and it is nil"
|
||||
);
|
||||
|
||||
@@ -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::<bool>("local i = GetContainerItemInfo(0, 1) return i.isLocked")
|
||||
.eval::<bool>("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::<bool>("local i = GetContainerItemInfo(0, 1) return i.isLocked")
|
||||
.eval::<bool>("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::<i64>("return select(\'#\', ContainerIDToInventoryID(99))")
|
||||
.unwrap(),
|
||||
s.arity("ContainerIDToInventoryID(99)").unwrap(),
|
||||
1,
|
||||
"one value on every non-raising path"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("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::<bool>("return select('#', GetCraftReagentItemLink(1, 5)) == 1 and GetCraftReagentItemLink(1, 5) == nil")
|
||||
.eval::<bool>("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::<bool>("return select('#', GetCraftItemLink(1)) == 0")
|
||||
.unwrap(),
|
||||
assert_eq!(
|
||||
s.arity("GetCraftItemLink(1)").unwrap(),
|
||||
0,
|
||||
"a non-Enchanting craft answers zero values"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,8 +520,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_bank_bag_slots(one_bank_bag());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', PickupBagFromSlot(64))")
|
||||
.unwrap(),
|
||||
s.arity("PickupBagFromSlot(64)").unwrap(),
|
||||
0,
|
||||
"the delegate's eax is never tested — no return values at all"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return select('#', IsInMeetingStoneQueue())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.arity("IsInMeetingStoneQueue()").unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<bool>("return IsInMeetingStoneQueue() == nil")
|
||||
.unwrap());
|
||||
|
||||
@@ -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 <FontString> adds none"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', Box:GetRegions())")
|
||||
.unwrap(),
|
||||
s.arity("Box:GetRegions()").unwrap(),
|
||||
7,
|
||||
"GetNumRegions is exactly the length GetRegions enumerates"
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -293,11 +293,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// No menu: text nil, no options.
|
||||
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("return select('#', GetGossipAvailableQuests())")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<bool>("return GetGossipText() == nil").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetGossipOptions())")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<i64>("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::<i64>("return (select(4, GetItemInfo(13262)))")
|
||||
s.eval::<i64>("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::<String>("return (select(5, GetItemInfo(13262)))")
|
||||
s.eval::<String>("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::<String>("return (select(8, GetItemInfo(13262)))")
|
||||
.unwrap(),
|
||||
s.eval::<String>(
|
||||
"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::<i64>("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::<i64>("return select('#', GetItemInfo(0))").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<String>("return (select(2, GetItemInfo(\"ITEM:25\")))")
|
||||
s.eval::<String>("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::<i64>(&format!("return select('#', GetItemInfo({arg}))"))
|
||||
.unwrap(),
|
||||
s.arity(&format!("GetItemInfo({arg})")).unwrap(),
|
||||
0,
|
||||
"argument {arg} resolves to item id 0"
|
||||
);
|
||||
|
||||
@@ -334,8 +334,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!((count, quality), (1, 1), "the miss tail is 1, 1");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetLootRollItemInfo(99))")
|
||||
.unwrap(),
|
||||
s.arity("GetLootRollItemInfo(99)").unwrap(),
|
||||
6,
|
||||
"five reference returns plus benilla's trailing item id"
|
||||
);
|
||||
|
||||
@@ -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::<mlua::String>(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::<i64>("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::<i64>("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::<i64>("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::<i64>("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::<i64>("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::<i64>("return select('#', assert(0))").unwrap(), 1);
|
||||
assert_eq!(s.arity("assert(0)").unwrap(), 1);
|
||||
|
||||
let raised = |call: &str| -> String {
|
||||
s.eval::<String>(&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::<i64>(&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<T>` 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::<bool>("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::<Vec<i64>>(
|
||||
"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::<bool>(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,15 +949,12 @@ mod tests {
|
||||
});
|
||||
s.set_mail(Some(st));
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return select('#', GetInboxInvoiceInfo(1))")
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(s.arity("GetInboxInvoiceInfo(1)").unwrap(), 7);
|
||||
let vals: Vec<String> = (1..=7)
|
||||
.map(|i| {
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
s.eval::<String>(&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::<usize>(&format!("return select('#', GetInboxInvoiceInfo({idx}))"))
|
||||
.unwrap(),
|
||||
s.arity(&format!("GetInboxInvoiceInfo({idx})")).unwrap(),
|
||||
7,
|
||||
"the miss is still seven values"
|
||||
);
|
||||
let tail: Vec<String> = (1..=7)
|
||||
.map(|i| {
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
s.eval::<String>(&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::<usize>("return select('#', GetInboxText(1))")
|
||||
.unwrap(),
|
||||
s.arity("GetInboxText(1)").unwrap(),
|
||||
4,
|
||||
"four values, invoice or not"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<T>` 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<usize> {
|
||||
let values: mlua::Variadic<mlua::Value> = 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<String> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1085,19 +1085,14 @@ mod tests {
|
||||
|
||||
for index in ["1", "10", "0", "-1", "41", "9999"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&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::<i64>("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::<i64>("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::<i64>(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
|
||||
|
||||
@@ -1016,10 +1016,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Exactly one return — the reference assigns it straight into a single local.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>(r##"return select("#", GetPetFoodTypes())"##)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
||||
|
||||
s.set_pet_stats(true, hunter_stats());
|
||||
assert_eq!(
|
||||
s.eval::<i64>(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::<i64>(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::<bool>("return GetPetLoyalty() == nil").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r##"return select("#", GetPetFoodTypes())"##)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<i64>("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::<i64>("return select(\"#\", RenamePetition(\"Legacy\"))")
|
||||
.unwrap(),
|
||||
s.arity("RenamePetition(\"Legacy\")").unwrap(),
|
||||
0,
|
||||
"rename pushes nothing at all"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return (select(3, GetPVPLifetimeStats()))")
|
||||
s.eval::<i64>("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::<i64>(r##"return select("#", GetPVPLifetimeStats())"##)
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(s.arity("GetPVPLifetimeStats()").unwrap(), 3);
|
||||
assert!(s
|
||||
.eval::<bool>("return type((select(3, GetPVPLifetimeStats()))) == 'number'")
|
||||
.eval::<bool>(
|
||||
"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::<i64>(&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::<i64>(&format!("return (select(2, GetPVPRankInfo({internal})))"))
|
||||
.unwrap(),
|
||||
s.eval::<i64>(&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::<i64>(&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::<i64>(r##"return select("#", GetInspectHonorData())"##)
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
assert_eq!(s.arity("GetInspectHonorData()").unwrap(), 12);
|
||||
let got = s
|
||||
.eval::<Vec<i64>>(
|
||||
"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::<i64>(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::<i64>(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");
|
||||
|
||||
@@ -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::<i64>("return select('#', GetQuestLogTitle(1))")
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
assert_eq!(s.arity("GetQuestLogTitle(1)").unwrap(), 6);
|
||||
assert!(s
|
||||
.eval::<bool>("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::<bool>("return select(3, GetQuestLogTitle(1)) == nil")
|
||||
.eval::<bool>("local _, _, tag = GetQuestLogTitle(1) return tag == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return select(3, GetQuestLogTitle(2)) == 'Elite'")
|
||||
.eval::<bool>("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::<i64>("return select('#', GetQuestTimers())")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("GetQuestTimers()").unwrap(), 0);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetQuestLogTimeLeft() == nil")
|
||||
.unwrap());
|
||||
|
||||
// 400 s in.
|
||||
s.set_server_unix_time(1_000_400.0);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"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::<i64>("return (GetQuestTimers())").unwrap(), 499);
|
||||
// The timer maps back to entry 2 — the untimed row 1 does not shift the mapping.
|
||||
assert_eq!(s.eval::<i64>("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::<i64>("return GetQuestLogTimeLeft()").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetQuestTimers())")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity("GetQuestTimers()").unwrap(), 0);
|
||||
assert!(s
|
||||
.eval::<bool>("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::<bool>(
|
||||
"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::<i64>("return (GetQuestTimers())").unwrap(), 499);
|
||||
assert_eq!(s.eval::<i64>("return GetQuestIndexForTimer(1)").unwrap(), 1);
|
||||
s.run("SelectQuestLogEntry(2)").unwrap();
|
||||
assert!(s
|
||||
|
||||
@@ -746,9 +746,7 @@ mod tests {
|
||||
assert!(got.10.is_nil(), "isWatched");
|
||||
|
||||
for miss in ["GetFactionInfo(99)", "GetFactionInfo(0)"] {
|
||||
let n = s
|
||||
.eval::<i64>(&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::<i64>(&format!("local _,_,s = {miss} return s"))
|
||||
|
||||
@@ -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::<Option<i64>>(&format!("return (select(8, GetSkillLineInfo({i})))"))
|
||||
.unwrap()
|
||||
s.eval::<Option<i64>>(&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::<String>("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::<i64>("return (select(7, GetSkillLineInfo(4)))")
|
||||
s.eval::<i64>("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::<i64>(&format!("return select('#', GetSkillLineInfo({idx}))"))
|
||||
.unwrap(),
|
||||
s.arity(&format!("GetSkillLineInfo({idx})")).unwrap(),
|
||||
13,
|
||||
"GetSkillLineInfo({idx}) must answer 13 values"
|
||||
);
|
||||
|
||||
@@ -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::<i64>(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::<bool>("return select('#', GetLookingForGroup()) == 4")
|
||||
.unwrap());
|
||||
assert_eq!(s.arity("GetLookingForGroup()").unwrap(), 4);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"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::<String>("return select(4, GetLookingForGroup())")
|
||||
s.eval::<String>("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::<String>("return select(4, GetLookingForGroup())")
|
||||
s.eval::<String>("local _, _, _, comment = GetLookingForGroup() return comment")
|
||||
.unwrap(),
|
||||
"LF2M UBRS"
|
||||
);
|
||||
@@ -752,7 +746,7 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return select(4, GetLookingForGroup())")
|
||||
s.eval::<String>("local _, _, _, comment = GetLookingForGroup() return comment")
|
||||
.unwrap()
|
||||
.len(),
|
||||
127
|
||||
|
||||
@@ -568,7 +568,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let model = lua.app_data_ref::<Model>().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::<i64>(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::<i64>(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::<bool>(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::<i64>(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::<i64>("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::<i64>("return GetNumSpellTabs()").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return PlayerHasSpells()").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>(&format!("return select('#', GetSpellTabInfo({idx}))"))
|
||||
.unwrap(),
|
||||
s.arity(&format!("GetSpellTabInfo({idx})")).unwrap(),
|
||||
4,
|
||||
"GetSpellTabInfo({idx}) must answer four values"
|
||||
);
|
||||
|
||||
@@ -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::<i64>(&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::<bool>(&format!("return not {call}")).unwrap());
|
||||
assert_eq!(
|
||||
|
||||
@@ -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 "%<digit>" 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
|
||||
|
||||
@@ -166,9 +166,5 @@ fn an_unknown_name_echoes_itself_rather_than_a_placeholder_literal() {
|
||||
.unwrap(),
|
||||
"nil"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r##"return select("#", GetAddOnDependencies("Nope"))"##)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.arity(r##"GetAddOnDependencies("Nope")"##).unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1468,8 +1468,7 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
s.eval::<f32>(&format!("return select({i}, PlainBtn:GetTextColor())"))
|
||||
.unwrap()
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
s.eval::<f32>(&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<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
s.eval::<f32>(&format!("return select({i}, ThemedBtn:GetTextColor())"))
|
||||
.unwrap()
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
s.eval::<f32>(&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<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
s.eval::<f32>(&format!("return select({i}, ThemedBtn:GetTextColor())"))
|
||||
.unwrap()
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
s.eval::<f32>(&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::<i64>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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::<i64>("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::<i64>("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::<i64>("return select('#', SetGuildRecruitmentMode(0))")
|
||||
.unwrap(),
|
||||
s.arity("SetGuildRecruitmentMode(0)").unwrap(),
|
||||
0,
|
||||
"0x4a0060 returns `xor eax,eax` — zero values, not a nil"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("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 [
|
||||
|
||||
@@ -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::<i64>(&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.
|
||||
|
||||
@@ -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::<i64>("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();
|
||||
|
||||
@@ -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::<i64>("return select('#', TFrame:GetTitleRegion())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.arity("TFrame:GetTitleRegion()").unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<Option<bool>>("return TFrame:GetTitleRegion() ~= nil and true or nil")
|
||||
.unwrap()
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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::<i64>("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::<i64>("return select('#', SHFText:GetShadowColor())")
|
||||
.unwrap(),
|
||||
s.arity("SHFText:GetShadowColor()").unwrap(),
|
||||
4,
|
||||
"GetShadowColor returns FOUR values — three drops the alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("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::<i64>(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::<i64>("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::<i64>("return select('#', SFLabelled:GetFont())")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(s.arity("SFLabelled:GetFont()").unwrap(), 3);
|
||||
assert!(s
|
||||
.eval::<bool>("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::<i64>(&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::<i64>(&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::<i64>(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::<i64>("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::<i64>("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::<i64>("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::<i64>("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::<i64>(&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::<usize>("return select('#', SetWorldDetail(1))")
|
||||
.unwrap(),
|
||||
s.arity("SetWorldDetail(1)").unwrap(),
|
||||
0,
|
||||
"every `ret` in 0x488dd0 leaves eax = 0"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return select('#', GetWorldDetail())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.arity("GetWorldDetail()").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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::<usize>("return select('#', ShowNameplates())")
|
||||
.unwrap(),
|
||||
s.arity("ShowNameplates()").unwrap(),
|
||||
0,
|
||||
"zero values — observably different from nil for a caller that counts"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return select('#', BTex:GetBlendMode())")
|
||||
.unwrap(),
|
||||
1,
|
||||
"arity 1"
|
||||
);
|
||||
assert_eq!(s.arity("BTex:GetBlendMode()").unwrap(), 1, "arity 1");
|
||||
assert_eq!(
|
||||
s.eval::<String>("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::<i64>("return select('#', TCMTex:GetTexCoordModifiesRect())")
|
||||
.unwrap(),
|
||||
s.arity("TCMTex:GetTexCoordModifiesRect()").unwrap(),
|
||||
1,
|
||||
"arity 1"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return select('#', HSF:GetHorizontalScroll())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<String>("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::<i64>("return select('#', HSF:SetHorizontalScroll(450))")
|
||||
.unwrap(),
|
||||
s.arity("HSF:SetHorizontalScroll(450)").unwrap(),
|
||||
0,
|
||||
"the setter answers nothing"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return select('#', AnchorTip:GetAnchorType())")
|
||||
.unwrap(),
|
||||
1,
|
||||
"arity 1"
|
||||
);
|
||||
assert_eq!(s.arity("AnchorTip:GetAnchorType()").unwrap(), 1, "arity 1");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(AnchorTip:GetAnchorType())")
|
||||
.unwrap(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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::<i64>("return select('#', GetRewardSpell())")
|
||||
.unwrap(),
|
||||
s.arity("GetRewardSpell()").unwrap(),
|
||||
3,
|
||||
"three values on the empty path"
|
||||
);
|
||||
|
||||
@@ -266,11 +266,7 @@ fn do_trade_skill_and_getters_no_op_on_a_header_index() {
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTradeSkillReagentInfo(1, 1) == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("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::<i64>("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::<i64>("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::<i64>("return GetNumTradeSkills()").unwrap(), 3);
|
||||
assert_eq!(row_kind(&mut s, 1), ("Armor Kit".into(), "header".into()));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<bool>("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::<bool>("return select('#', GetTradeSkillReagentItemLink(2, 9)) == 1 and GetTradeSkillReagentItemLink(2, 9) == nil")
|
||||
s.eval::<bool>("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)")
|
||||
|
||||
@@ -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::<i64>("return select('#', TutorialsEnabled())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.arity("TutorialsEnabled()").unwrap(), 1);
|
||||
assert!(
|
||||
s.eval::<bool>("return TutorialsEnabled() == nil").unwrap(),
|
||||
"no bank"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<i64>(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::<i64>(&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::<i64>(&format!("return select('#', {call})"))
|
||||
.unwrap(),
|
||||
s.arity(call).unwrap(),
|
||||
1,
|
||||
"{name} returns exactly one value on the false leg too — nil, never zero values"
|
||||
);
|
||||
|
||||
@@ -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::<i64>("return select('#', GetWeaponEnchantInfo())")
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
assert_eq!(s.arity("GetWeaponEnchantInfo()").unwrap(), 6);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local a, b, c, d, e, f = GetWeaponEnchantInfo() \
|
||||
@@ -170,8 +166,7 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<i64>("return select('#', GetWeaponEnchantInfo(16, \"nonsense\"))")
|
||||
.unwrap(),
|
||||
s.arity("GetWeaponEnchantInfo(16, \"nonsense\")").unwrap(),
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::<bool>("return select('#', CreateWorldMapArrowFrame(WM)) == 0")
|
||||
.unwrap(),
|
||||
assert_eq!(
|
||||
s.arity("CreateWorldMapArrowFrame(WM)").unwrap(),
|
||||
0,
|
||||
"zero values"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MoveHitData> {
|
||||
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::<Mesh>();
|
||||
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<f32> {
|
||||
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
|
||||
|
||||
@@ -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<Collider> {
|
||||
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<Vec3> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user