Quiver loads, teleports stop losing the city, the camera eases right
- Quiver works end to end (the reported nil-field error): 1.12's widget method tables are flat, so the 19 Region methods are ONE function per name — a frame's `GetHeight` IS the texture's — where we had two implementations behind two disjoint registries; `Button:SetFontString` and the Set/GetMin/MaxResize quad land with it, and the 219-addon survey moves loads-without-error 150 → 151 with every other addon byte-identical - a teleport stops dropping you into a bare city: the reveal gate now waits on geometry that can actually draw (14 regions had sat undrawn for 630 ms with the cover already down), a relocating snap ≥100 yd raises the cover, and the pipeline warm pass runs once per process — a far same-map teleport's cover drops 3.8 s → 0.8 s - the empty-world flash on a fully-resident teleport dies with it: the camera's world pose publishes before anything in Update reads it, so the visibility verdict stops being computed up to 644 yd from the eye — the lag reads 0 yd on every snap frame of every arm - the camera follow becomes the engine's own curve — an edge-armed cosine smoothstep clamped to 0.1–2 s behind a 7-state input classifier, the enum 0/1/2 with Smart staying the default — and overlapping chat bubbles stack as whole cards, one frame level per bubble, nearest to the camera on top Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -852,7 +852,12 @@
|
||||
local v = OptionsRow_Get(row)
|
||||
local match
|
||||
for _, e in ipairs(row.entries) do
|
||||
if e.value == v then match = e end
|
||||
-- `alt` is a second value the same entry ANSWERS to (1502): 1.12's own Camera
|
||||
-- Following Style dropdown writes "3" for Never where the engine's tables are
|
||||
-- indexed 0/1/2, so a config or addon carrying that value means Never — and the
|
||||
-- nearest-stop fallback below, which assumes the values are ordinal, would show
|
||||
-- "Always" for it. A categorical dropdown says which strays are its own.
|
||||
if e.value == v or e.alt == v then match = e end
|
||||
end
|
||||
if not match and tonumber(v) then
|
||||
local best
|
||||
@@ -1989,14 +1994,22 @@
|
||||
"22" means the wheel stops at 22 yards, which is the
|
||||
thing being chosen. Our default is the factor fully
|
||||
raised (30 yd), where 1.12 ships 1.0. -->
|
||||
<!-- Camera Following Style (decision 1493): 1.12's own
|
||||
dropdown (UIOptionsFrameCameraDropDown), values and
|
||||
order and all — Smart "1", Always "2", Never "3", with
|
||||
each entry carrying the reference's own description key
|
||||
so the row's plate changes with the selection, the way
|
||||
that dropdown's does. Seated at the head of the camera
|
||||
block because it decides whether the camera follows at
|
||||
all; the distance slider under it decides how far. -->
|
||||
<!-- Camera Following Style (1493, values re-pinned by
|
||||
1502): 1.12's own dropdown
|
||||
(UIOptionsFrameCameraDropDown) in its own order —
|
||||
Smart, Always, Never — each entry carrying the
|
||||
reference's own description key (keyed by POSITION,
|
||||
OPTION_TOOLTIP_CAMERA1..3) so the row's plate changes
|
||||
with the selection, the way that dropdown's does.
|
||||
The VALUES are the ENGINE's, not that dropdown's: the
|
||||
reference writes 1/2/3 and 3 is not a style at all —
|
||||
its validator accepts it while the terrain-tilt
|
||||
consumer indexes 360 bytes off the end of its table.
|
||||
0/1/2 is what the client's own tables are indexed by
|
||||
(wow-re camera-smooth-style.md §2/§4). Seated at the
|
||||
head of the camera block because it decides whether
|
||||
the camera follows at all; the distance slider under
|
||||
it decides how far. -->
|
||||
<Frame name="$parentRowCameraFollowStyle" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
@@ -2008,7 +2021,7 @@
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = "Smart", value = "1", tip = "OPTION_TOOLTIP_CAMERA1" },
|
||||
{ text = "Always", value = "2", tip = "OPTION_TOOLTIP_CAMERA2" },
|
||||
{ text = "Never", value = "3", tip = "OPTION_TOOLTIP_CAMERA3" },
|
||||
{ text = "Never", value = "0", alt = "3", tip = "OPTION_TOOLTIP_CAMERA3" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -117,7 +117,7 @@ pub(crate) use probes::{
|
||||
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
|
||||
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock,
|
||||
ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
|
||||
SchedCensusPlugin, UnitVisualsPlugin,
|
||||
RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
|
||||
};
|
||||
use scenarios::GlueScreen;
|
||||
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
|
||||
|
||||
@@ -47,6 +47,11 @@ pub(crate) use visual_census::UnitVisualsPlugin;
|
||||
mod dress_census;
|
||||
pub(crate) use dress_census::DressCensusPlugin;
|
||||
|
||||
/// The reveal audit — per-frame, from a snap: every term that decides whether the world about
|
||||
/// to be shown is actually drawable (decision 1498).
|
||||
mod reveal;
|
||||
pub(crate) use reveal::RevealAuditPlugin;
|
||||
|
||||
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
|
||||
/// census — "what is resident right now, and what is it made of".
|
||||
mod world_census;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The **reveal audit** (`WOW_REVEAL=<frames>`) — one line per frame from a snap, naming every
|
||||
//! term that decides whether the world the player is about to be shown is actually there.
|
||||
//!
|
||||
//! "Teleported and the city wasn't drawn for a frame or more" is a *temporal* claim about the
|
||||
//! frames either side of a reveal, and neither a screenshot nor the loading screen's own 3-second
|
||||
//! wait line can hold one: the screenshot is one frame with no state attached, and the wait line
|
||||
//! only speaks for loads slow enough to be stuck. This prints the whole window — the cover, the
|
||||
//! residency terms behind [`WorldLoadProgress::presentable`], the settle hold, and the retained
|
||||
//! pass's collected-vs-published region counts — so "which term let the reveal through" is read
|
||||
//! off a column instead of guessed.
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_CHAT=".go xyz -9250.45 160.86 67.90 0;.tele stormwind" \
|
||||
//! WOW_PROBE_CHAT_AT=20 WOW_PROBE_CHAT_EVERY=25 WOW_REVEAL=120 \
|
||||
//! WOW_PROBE_EXIT_AT=70 cargo run -q -p benilla | grep REVEAL
|
||||
//! ```
|
||||
//!
|
||||
//! The arming edge is the snap itself (a same-map teleport or a worldport), so the window covers
|
||||
//! the frames a cover would have to be up for — and, when none is, the frames the player is
|
||||
//! looking at the destination through nothing at all.
|
||||
//!
|
||||
//! **It runs in `Last`, and that is load-bearing.** Half of what decides whether a frame has a
|
||||
//! world in it is settled in `PostUpdate` — bevy's visibility check, the exterior-scene gate, the
|
||||
//! retained pass's own scene walk — and a line printed in `Update` reports those columns from the
|
||||
//! *previous* frame. Against a one-or-two-frame artefact an off-by-one instrument is worse than
|
||||
//! none: it attributes the hole to the frame beside it. In `Last` every column below is this
|
||||
//! frame's, residency and draw alike.
|
||||
//!
|
||||
//! The columns split into two halves on purpose. **Residency** (`res`, `focus`, `scene`, `place`,
|
||||
//! `coll`, `merge`, `gx`) is *what the world has* — the terms behind
|
||||
//! [`WorldLoadProgress::presentable`], the ones the cover is raised and cleared on. **Draw**
|
||||
//! (`drawn`, `hid`, `sel`, `room`, `win`, `pvseye`) is *what the frame put on screen*. A reveal
|
||||
//! defect where every residency term reads ready and the draw half has collapsed is not a
|
||||
//! streaming bug at all — it is the visibility authority answering about somewhere else.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::ProbeClock;
|
||||
use benilla_world::static_gx::StaticGx;
|
||||
use benilla_world::terrain_stream::WorldLoadProgress;
|
||||
use benilla_world::world_census::WorldCensus;
|
||||
|
||||
/// The audit's window: how many frames after a snap to report, from `WOW_REVEAL`.
|
||||
#[derive(Resource)]
|
||||
struct RevealAudit {
|
||||
frames: u32,
|
||||
/// Frames printed since the current arm; `None` = not armed.
|
||||
n: Option<u32>,
|
||||
/// [`ProbeClock`] seconds at the arming snap — the `t=` column. The wall clock, like every
|
||||
/// other probe schedule (decision 0789): a reveal window is measured in real milliseconds,
|
||||
/// and the virtual clock clamps exactly the hitching frames this instrument exists to see.
|
||||
since: f32,
|
||||
}
|
||||
|
||||
pub(crate) struct RevealAuditPlugin;
|
||||
|
||||
impl Plugin for RevealAuditPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let frames = std::env::var("WOW_REVEAL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(120u32);
|
||||
app.insert_resource(RevealAudit {
|
||||
frames,
|
||||
n: None,
|
||||
since: 0.0,
|
||||
})
|
||||
// In `Last`: after the streamer published residency, after the screen decided what to do
|
||||
// with it, and after `PostUpdate` settled what actually draws (see the module docs).
|
||||
.add_systems(Last, drive_reveal_audit);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_reveal_audit(
|
||||
mut audit: ResMut<RevealAudit>,
|
||||
progress: Res<WorldLoadProgress>,
|
||||
gx: Option<Res<StaticGx>>,
|
||||
screen: Res<crate::loading_screen::LoadingScreen>,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
census: WorldCensus,
|
||||
cam: Query<&GlobalTransform, With<benilla_world::view::WorldCamera>>,
|
||||
time: ProbeClock,
|
||||
mut teleports: MessageReader<crate::net::TeleportMessage>,
|
||||
mut worldports: MessageReader<crate::net::WorldportMessage>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
if teleports.read().next().is_some() || worldports.read().next().is_some() {
|
||||
audit.n = Some(0);
|
||||
audit.since = now;
|
||||
}
|
||||
let Some(n) = audit.n else {
|
||||
return;
|
||||
};
|
||||
if n >= audit.frames {
|
||||
audit.n = None;
|
||||
return;
|
||||
}
|
||||
audit.n = Some(n + 1);
|
||||
|
||||
let (collected, published, selected) = gx
|
||||
.as_deref()
|
||||
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::wmo_census);
|
||||
let (cells_drawn, wmo_drawn, groups_drawn) = gx
|
||||
.as_deref()
|
||||
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::draw_census);
|
||||
let seen = census.take();
|
||||
// How far the visibility authority's pose is from the eye this frame is actually drawn from
|
||||
// — ~0 in steady play, the whole jump on a snap frame it has not caught up with.
|
||||
let pvs_lag = match (seen.pvs_eye, cam.iter().next()) {
|
||||
(Some(eye), Some(now_eye)) => eye.distance(now_eye.translation()),
|
||||
_ => f32::NAN,
|
||||
};
|
||||
info!(
|
||||
"REVEAL n={n} t={:.0}ms cover={} res={}/{} focus={} scene={} place={} coll={} merge={} \
|
||||
gx={} wmo={collected}/{published}/{selected} settling={} | drawn={}/{} hid={}/{} \
|
||||
sel={cells_drawn}/{wmo_drawn}/{groups_drawn} room={} win={} pvslag={pvs_lag:.0}",
|
||||
(now - audit.since) * 1000.0,
|
||||
u8::from(screen.covering()),
|
||||
progress.ready,
|
||||
progress.total,
|
||||
u8::from(progress.focus_resident),
|
||||
u8::from(progress.scene_ready),
|
||||
progress.placements_pending,
|
||||
progress.colliders_pending,
|
||||
progress.merge_pending,
|
||||
progress.gx_pending,
|
||||
u8::from(player.is_some_and(|p| p.settling)),
|
||||
seen.drawn,
|
||||
seen.submeshes,
|
||||
seen.hidden,
|
||||
seen.tagged,
|
||||
seen.room.as_deref().unwrap_or("-"),
|
||||
seen.windows.as_deref().unwrap_or("-"),
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,14 @@
|
||||
//! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at
|
||||
//! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line**
|
||||
//! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406).
|
||||
//! - **Stacking** (`0x4b1060`, the per-frame pass over ALL live bubbles — one caller, `0x4817a3`):
|
||||
//! each bubble is its own frame and its frame LEVEL is restamped every frame from the CAMERA's
|
||||
//! distance to the speaker. The list re-sorts farthest-first (`0x4b1360`, comparing
|
||||
//! `[bubble+0x350]` = |cam − unit|², written at `0x4b12c0`), then a head→tail walk stamps
|
||||
//! `SetFrameLevel(2), (3), (4)…` (`0x4b1309`). Level outranks draw layer in the client's total
|
||||
//! order, so overlapping bubbles stack as WHOLE cards with the nearest speaker's on top —
|
||||
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's
|
||||
//! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.)
|
||||
//!
|
||||
//! Named divergences (all deliberate, decisions 0598/0599):
|
||||
//! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p`
|
||||
@@ -71,7 +79,7 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
|
||||
use crate::entities::StandBoxHeight;
|
||||
use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
|
||||
use crate::ui_chat::{default_color, ChatEventKind};
|
||||
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||||
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||||
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
|
||||
use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates};
|
||||
use benilla_assets::{AssetSet, WorldAssets};
|
||||
@@ -121,15 +129,25 @@ const WRAP_W: f32 = 0.2;
|
||||
/// (`G44·16.0/(S·1024.0)` — G44/S nets the width in the diagonal gx basis).
|
||||
const BORDER_FRAC: f32 = 16.0 / 1024.0;
|
||||
|
||||
/// Paint order inside the overlays lane: the whole bubble sits UNDER the V-plates' 4..8 band
|
||||
/// (same-unit overlap can't happen — the mutual exclusion — so this only orders cross-unit
|
||||
/// stacking). The tail draws over the frame's bottom edge piece: its art carries the border
|
||||
/// lines that make the seam read continuous.
|
||||
/// Paint order **inside one bubble's frame level** — the four pieces, back→front. The tail draws
|
||||
/// over the frame's bottom edge piece: its art carries the border lines that make the seam read
|
||||
/// continuous, and the reference gets the same result from its own layer key (the tail is an
|
||||
/// ARTWORK texture, the text an ARTWORK font string, and a batch drains every texture before
|
||||
/// every font string — wow-re `ui/scratch/draw-order-law.md` §4).
|
||||
const Z_BG: u64 = 0;
|
||||
const Z_EDGE: u64 = 1;
|
||||
const Z_TAIL: u64 = 2;
|
||||
const Z_TEXT: u64 = 3;
|
||||
|
||||
/// The z base for the bubble at `rank` in this frame's farthest→nearest order — the port of the
|
||||
/// reference's per-frame `SetFrameLevel(2 + i)` walk (`0x4b12d5`–`0x4b1312`, decision 1504). One
|
||||
/// LEVEL per bubble, [`overlay_z::BUBBLE_STRIDE`] keys wide, so a whole bubble stacks over a whole
|
||||
/// bubble; the whole band stays under the V-plates ([`crate::ui_pass::overlay_z`]).
|
||||
fn level_z(rank: usize) -> u64 {
|
||||
let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL);
|
||||
overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE
|
||||
}
|
||||
|
||||
/// Which CVar gates this chat kind's bubble — `None` = the kind never bubbles in v1.
|
||||
/// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`).
|
||||
/// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an
|
||||
@@ -455,6 +473,7 @@ fn drive_bubbles(
|
||||
let scale = window.single().map_or(1.0, Window::scale_factor);
|
||||
let trace = std::env::var("WOW_BUBBLE_TRACE").as_deref() == Ok("1");
|
||||
let mut dead = Vec::new();
|
||||
let mut pending: Vec<Pending> = Vec::new();
|
||||
for (guid, b) in bubbles.0.iter_mut() {
|
||||
let Some((entity, _, tf)) = find(*guid) else {
|
||||
dead.push(*guid); // the speaker despawned — the unit teardown takes its bubble
|
||||
@@ -485,9 +504,9 @@ fn drive_bubbles(
|
||||
if b.alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (Some((cam, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art)
|
||||
else {
|
||||
continue; // no camera/atlas/art — lifetimes still tick, nothing draws
|
||||
// No camera/atlas/art — lifetimes still tick, nothing draws.
|
||||
let (Some((cam, cam_pose)), true) = (cam, atlas.is_some() && art.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
// World anchor (`0x4b0c30`): the unit's position, Z lifted by the LATCHED Stand-box height
|
||||
// + 0.7 yd — seated BOTTOM at the projected point, growing upward. Every term is this
|
||||
@@ -509,24 +528,80 @@ fn drive_bubbles(
|
||||
refuse("no-viewport", *guid);
|
||||
continue;
|
||||
};
|
||||
// Seated and projected here, DRAWN below: the level a bubble draws at is a property of
|
||||
// the whole live set, not of this bubble ([`level_z`]), so the draw waits for the sort.
|
||||
pending.push(Pending {
|
||||
guid: *guid,
|
||||
entity,
|
||||
seat,
|
||||
viewport,
|
||||
// The sort key (`0x4b1251`–`0x4b12c0`): |camera − speaker|², the CAMERA's distance,
|
||||
// not the local player's (which is the eligibility gate's, above).
|
||||
cam_dist_sq: cam_pose.translation.distance_squared(tf.translation),
|
||||
anchor: seat_world,
|
||||
unit_pos: tf.translation,
|
||||
});
|
||||
}
|
||||
for g in dead {
|
||||
bubbles.0.remove(&g);
|
||||
}
|
||||
|
||||
// ── The frame-level pass (`0x4b12d5`–`0x4b1312`) ─────────────────────────────────────────
|
||||
// The reference re-sorts its live-bubble list by camera distance, FARTHEST at the head, and
|
||||
// then walks head→tail stamping `SetFrameLevel(2), (3), (4)…` — so every bubble owns a frame
|
||||
// level of its own and the NEAREST speaker's bubble ends up on top. (It only re-sorts when
|
||||
// the camera moved or a bubble is dirty; the stamping walk runs unconditionally. Sorting
|
||||
// every frame is the same list with no state to keep.) Ties break on guid so an exact
|
||||
// distance tie can't flip the stack frame to frame.
|
||||
stack_sort(&mut pending);
|
||||
let (Some((_, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art) else {
|
||||
return; // nothing reached `pending` either — the same gate refused it in the tick
|
||||
};
|
||||
for (rank, p) in pending.iter().enumerate() {
|
||||
let Some(b) = bubbles.0.get(&p.guid) else {
|
||||
continue; // despawned between the tick and the draw
|
||||
};
|
||||
draw_bubble(
|
||||
atlas,
|
||||
&mut quads,
|
||||
art,
|
||||
b,
|
||||
seat,
|
||||
viewport,
|
||||
p.seat,
|
||||
p.viewport,
|
||||
scale,
|
||||
trace,
|
||||
entity,
|
||||
seat_world,
|
||||
tf.translation,
|
||||
p.entity,
|
||||
p.anchor,
|
||||
p.unit_pos,
|
||||
cam_pose,
|
||||
level_z(rank),
|
||||
);
|
||||
}
|
||||
for g in dead {
|
||||
bubbles.0.remove(&g);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack order: **farthest camera distance first**, so the enumeration index that follows is
|
||||
/// the reference's frame level and the nearest speaker's bubble draws last (on top). Ties break on
|
||||
/// guid — the reference's list keeps its previous order across an exact tie because it re-inserts
|
||||
/// only ahead of a *strictly* closer node, and a `HashMap` walk has no such memory.
|
||||
fn stack_sort(pending: &mut [Pending]) {
|
||||
pending.sort_by(|a, b| {
|
||||
b.cam_dist_sq
|
||||
.total_cmp(&a.cam_dist_sq)
|
||||
.then(a.guid.cmp(&b.guid))
|
||||
});
|
||||
}
|
||||
|
||||
/// One bubble that passed the tick and projected on screen, waiting for the frame-level sort.
|
||||
struct Pending {
|
||||
guid: u64,
|
||||
entity: Entity,
|
||||
seat: Vec2,
|
||||
viewport: Vec2,
|
||||
/// |camera − speaker|², the reference's own sort key (`[bubble+0x350]`).
|
||||
cam_dist_sq: f32,
|
||||
/// The world seat and the speaker's position — the `bub` jitter trace's decomposition.
|
||||
anchor: Vec3,
|
||||
unit_pos: Vec3,
|
||||
}
|
||||
|
||||
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
|
||||
@@ -546,6 +621,8 @@ fn draw_bubble(
|
||||
anchor: Vec3,
|
||||
unit_pos: Vec3,
|
||||
cam_pose: &Transform,
|
||||
// This bubble's frame level, flattened to a z base ([`level_z`]).
|
||||
z: u64,
|
||||
) {
|
||||
let basis = plate_basis(viewport);
|
||||
let border = border_px(viewport, basis);
|
||||
@@ -628,7 +705,7 @@ fn draw_bubble(
|
||||
};
|
||||
quads.overlays.push(UiQuad {
|
||||
rect,
|
||||
z_key: if p.is_bg { Z_BG } else { Z_EDGE },
|
||||
z_key: z + if p.is_bg { Z_BG } else { Z_EDGE },
|
||||
texture: Some(if p.is_bg {
|
||||
art.bg.clone()
|
||||
} else {
|
||||
@@ -645,7 +722,7 @@ fn draw_bubble(
|
||||
let cx = (frame.min.x + frame.max.x) * 0.5;
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: Rect::new(cx - border, tail_top, cx, tail_top + border),
|
||||
z_key: Z_TAIL,
|
||||
z_key: z + Z_TAIL,
|
||||
texture: Some(art.tail.clone()),
|
||||
uv: UvRect::FULL,
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
@@ -663,7 +740,7 @@ fn draw_bubble(
|
||||
h: JustifyH::Center,
|
||||
v: JustifyV::Middle,
|
||||
},
|
||||
Z_TEXT,
|
||||
z + Z_TEXT,
|
||||
spec,
|
||||
);
|
||||
drop(e);
|
||||
@@ -893,6 +970,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every bubble owns a frame LEVEL: its four pieces are one contiguous band, bands ascend
|
||||
/// with rank, and no two bubbles can interleave. This is the whole 1504 fix — with a flat
|
||||
/// per-piece key, every bubble's background painted under every bubble's text, so a speaker's
|
||||
/// line bled across a neighbour's card.
|
||||
#[test]
|
||||
fn each_bubble_gets_its_own_level_band() {
|
||||
// The four pieces of one bubble, in paint order, all inside one level.
|
||||
const { assert!(Z_BG < Z_EDGE && Z_EDGE < Z_TAIL && Z_TAIL < Z_TEXT) };
|
||||
const { assert!(Z_TEXT < overlay_z::BUBBLE_STRIDE) };
|
||||
for rank in 0..8usize {
|
||||
// ...and every piece strictly below the NEXT bubble's background.
|
||||
assert!(level_z(rank) + Z_TEXT < level_z(rank + 1) + Z_BG);
|
||||
}
|
||||
assert!(level_z(1) > level_z(0), "later rank draws later");
|
||||
}
|
||||
|
||||
/// The band is bounded: however many bubbles are live, none of them can reach the V-plates'
|
||||
/// band above ([`crate::ui_pass::overlay_z`]) or the floating combat text's below.
|
||||
#[test]
|
||||
fn the_bubble_band_stays_between_its_neighbours() {
|
||||
for rank in [0, 1, 1_000, usize::MAX] {
|
||||
let z = level_z(rank);
|
||||
assert!(z >= overlay_z::BUBBLE, "over the floating numbers");
|
||||
assert!(z + Z_TEXT < overlay_z::VPLATE, "under the V-plates");
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack order is the reference's: farthest camera distance first (so the nearest
|
||||
/// speaker's bubble ends up on top), guid breaking an exact tie deterministically.
|
||||
#[test]
|
||||
fn the_stack_is_sorted_farthest_first() {
|
||||
let at = |guid: u64, d2: f32| Pending {
|
||||
guid,
|
||||
entity: Entity::PLACEHOLDER,
|
||||
seat: Vec2::ZERO,
|
||||
viewport: Vec2::ONE,
|
||||
cam_dist_sq: d2,
|
||||
anchor: Vec3::ZERO,
|
||||
unit_pos: Vec3::ZERO,
|
||||
};
|
||||
let mut p = vec![at(7, 25.0), at(3, 400.0), at(9, 1.0), at(2, 400.0)];
|
||||
stack_sort(&mut p);
|
||||
assert_eq!(
|
||||
p.iter().map(|q| q.guid).collect::<Vec<_>>(),
|
||||
vec![2, 3, 7, 9],
|
||||
"farthest first; the 400-yd² pair ordered by guid"
|
||||
);
|
||||
// The rank the draw uses IS the frame level: the nearest speaker draws last.
|
||||
assert!(level_z(0) < level_z(p.len() - 1));
|
||||
}
|
||||
|
||||
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
|
||||
/// nets width/64), damped past the plate knee like every other size.
|
||||
#[test]
|
||||
|
||||
@@ -113,7 +113,7 @@ use bevy::prelude::*;
|
||||
use benilla_ui::script::{JustifyH, JustifyV, Outline};
|
||||
|
||||
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
|
||||
use crate::ui_pass::{UiQuadAppend, UiQuads};
|
||||
use crate::ui_pass::{overlay_z, UiQuadAppend, UiQuads};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
@@ -158,8 +158,9 @@ pub(crate) struct WorldTexts(Vec<WorldText>);
|
||||
const MAX_PER_UNIT: usize = 4;
|
||||
|
||||
/// World text draws beneath every scripted UI quad (the real client renders it in the world scene,
|
||||
/// under all UI): z 0 sorts first against the frame quads' packed strata keys.
|
||||
const Z_WORLD_TEXT: u64 = 0;
|
||||
/// under all UI) and beneath the other two append-lane systems: the append lane's bottom band
|
||||
/// ([`crate::ui_pass::overlay_z`]) sorts first against the frame quads' packed strata keys.
|
||||
const Z_WORLD_TEXT: u64 = overlay_z::WORLD_TEXT;
|
||||
|
||||
/// Admit a spawn against the per-unit cap: the client scans the unit's 4 slots for the first
|
||||
/// NULL and **returns** when all are occupied — a hard drop, no eviction, no queue (`0x6c73f0`).
|
||||
|
||||
@@ -31,7 +31,9 @@ use bevy::prelude::*;
|
||||
use crate::chat_bubble::BubbleConfig;
|
||||
use crate::minimap::MinimapZoom;
|
||||
use crate::nameplates::NameConfig;
|
||||
use crate::player::camera::{FollowStyle, LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
|
||||
use crate::player::camera::{
|
||||
FollowConfig, FollowStyle, LookConfig, ZoomLimit, FOLLOW_SPEED_RANGE, MOUSE_SPEED_RANGE,
|
||||
};
|
||||
use crate::portrait::PaneRate;
|
||||
use crate::sound::SoundConfig;
|
||||
use crate::target::ClickConfig;
|
||||
@@ -107,13 +109,22 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// raised — because that IS benilla's shipped 30 yd ceiling, a knowing divergence from the
|
||||
// reference's registrar "1" that camera.rs has carried in prose since it was written.
|
||||
("cameraDistanceMaxFactor", "2"),
|
||||
// Camera Following Style (1493): 1.12's `cameraSmoothStyle` — the auto-follow that swings the
|
||||
// camera back behind the character. Registered "1" = Smart, which is BOTH the reference's
|
||||
// registrar default and the director's call; benilla behaved as "3" (Never) unconditionally
|
||||
// until this row, so the knob is what un-freezes a camera that was frozen by design. The three
|
||||
// values are the reference dropdown's own (UIOptionsFrameCameraDropDown: 1 Smart, 2 Always,
|
||||
// 3 Never) — see `FollowStyle`.
|
||||
// Camera Following Style (1493, re-pinned by 1502): 1.12's `cameraSmoothStyle` — the
|
||||
// auto-return that swings the camera back behind the character. Registered "1" = Smart, which
|
||||
// is BOTH the reference's registrar default (byte-verified: the argument is loaded from
|
||||
// `[0x84f4f4]` -> "1" at the `0x50ba92` register site) and the director's call; benilla behaved
|
||||
// as Never unconditionally until this row. The enum is the ENGINE's — 0 Never, 1 Smart,
|
||||
// 2 Always — NOT the 1/2/3 the reference's own dropdown writes; see `FollowStyle`.
|
||||
("cameraSmoothStyle", "1"),
|
||||
// Its sibling selector (1502), also registered "1": the reference reads THIS style instead
|
||||
// whenever the state mask contains Track or Fear — the externally-driven states — indexing the
|
||||
// same matrices. No row on any 1.12 panel, here or there; the reader is the host.
|
||||
("cameraSmoothTrackingStyle", "1"),
|
||||
// The auto-follow's rate (1502), °/s — 1.12's own AUTO_FOLLOW_SPEED slider
|
||||
// (`UIOptionsFrameSliders`, 90..270 by 10), registered at the binary's "180.0" (`[0xbe1070]`).
|
||||
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
|
||||
// slew. No row yet — the slider is a one-line follow-on now that the knob exists.
|
||||
("cameraYawSmoothSpeed", "180"),
|
||||
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
|
||||
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
|
||||
// written waiting for this key and reads it on every repaint). Default "0": the reference's
|
||||
@@ -241,7 +252,7 @@ struct Knobs<'a> {
|
||||
minimap: &'a mut MinimapZoom,
|
||||
bubbles: &'a mut BubbleConfig,
|
||||
zoom: &'a mut ZoomLimit,
|
||||
follow: &'a mut FollowStyle,
|
||||
follow: &'a mut FollowConfig,
|
||||
video: &'a mut VideoConfig,
|
||||
pane_rate: &'a mut PaneRate,
|
||||
}
|
||||
@@ -271,7 +282,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"cameradistancemaxfactor" => knobs.zoom.set_factor(v),
|
||||
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
|
||||
// default rather than as a dead camera (`FollowStyle::from_cvar`).
|
||||
"camerasmoothstyle" => *knobs.follow = FollowStyle::from_cvar(v),
|
||||
"camerasmoothstyle" => knobs.follow.style = FollowStyle::from_cvar(v),
|
||||
// Its sibling selector — the one the reference swaps in for the externally-driven states.
|
||||
"camerasmoothtrackingstyle" => knobs.follow.tracking_style = FollowStyle::from_cvar(v),
|
||||
// The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range.
|
||||
"camerayawsmoothspeed" => {
|
||||
knobs.follow.yaw_speed =
|
||||
v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end());
|
||||
}
|
||||
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
|
||||
"mousespeed" => {
|
||||
knobs.look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
|
||||
@@ -332,7 +350,7 @@ fn load_config(
|
||||
mut minimap: ResMut<MinimapZoom>,
|
||||
mut bubbles: ResMut<BubbleConfig>,
|
||||
mut zoom: ResMut<ZoomLimit>,
|
||||
mut follow: ResMut<FollowStyle>,
|
||||
mut follow: ResMut<FollowConfig>,
|
||||
mut video: ResMut<VideoConfig>,
|
||||
mut pane_rate: ResMut<PaneRate>,
|
||||
) {
|
||||
@@ -427,7 +445,7 @@ fn sync_cvars(
|
||||
mut minimap: ResMut<MinimapZoom>,
|
||||
mut bubbles: ResMut<BubbleConfig>,
|
||||
mut zoom: ResMut<ZoomLimit>,
|
||||
mut follow: ResMut<FollowStyle>,
|
||||
mut follow: ResMut<FollowConfig>,
|
||||
mut video: ResMut<VideoConfig>,
|
||||
mut pane_rate: ResMut<PaneRate>,
|
||||
) {
|
||||
@@ -449,7 +467,7 @@ fn sync_cvars(
|
||||
);
|
||||
script.register_cvars(REGISTERED.iter().copied());
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 26] = [
|
||||
let session: [(&str, String); 28] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -464,7 +482,12 @@ fn sync_cvars(
|
||||
("mouseInvertPitch", flag(look.invert_pitch)),
|
||||
("mousespeed", look.sensitivity.to_string()),
|
||||
("cameraDistanceMaxFactor", zoom.factor().to_string()),
|
||||
("cameraSmoothStyle", follow.cvar().to_string()),
|
||||
("cameraSmoothStyle", follow.style.cvar().to_string()),
|
||||
(
|
||||
"cameraSmoothTrackingStyle",
|
||||
follow.tracking_style.cvar().to_string(),
|
||||
),
|
||||
("cameraYawSmoothSpeed", follow.yaw_speed.to_string()),
|
||||
("autoLootDefault", flag(loot.auto_loot)),
|
||||
("UnitNamePlayer", flag(names.player)),
|
||||
("UnitNameNPC", flag(names.npc)),
|
||||
@@ -548,7 +571,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
|
||||
Option<ResMut<MinimapZoom>>,
|
||||
Option<ResMut<BubbleConfig>>,
|
||||
Option<ResMut<ZoomLimit>>,
|
||||
Option<ResMut<FollowStyle>>,
|
||||
Option<ResMut<FollowConfig>>,
|
||||
Option<ResMut<VideoConfig>>,
|
||||
Option<ResMut<PaneRate>>,
|
||||
)> = bevy::ecs::system::SystemState::new(world);
|
||||
@@ -756,13 +779,19 @@ mod tests {
|
||||
);
|
||||
assert_eq!(d["mousespeed"], LookConfig::default().sensitivity);
|
||||
assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor());
|
||||
// The following style (1493) welds to the enum's own Default — and BOTH sides are the
|
||||
// reference's registrar "1" = Smart, the one row in this arc that agrees with the binary
|
||||
// outright.
|
||||
// The follow trio (1493/1502) welds to FollowConfig's own defaults — and all three ARE
|
||||
// the binary's registrar values, byte-verified: "1"/"1"/"180.0". The one corner of this
|
||||
// arc that agrees with the reference outright.
|
||||
let follow = FollowConfig::default();
|
||||
assert_eq!(
|
||||
d["cameraSmoothStyle"],
|
||||
FollowStyle::default().cvar().parse::<f32>().unwrap()
|
||||
follow.style.cvar().parse::<f32>().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
d["cameraSmoothTrackingStyle"],
|
||||
follow.tracking_style.cvar().parse::<f32>().unwrap()
|
||||
);
|
||||
assert_eq!(d["cameraYawSmoothSpeed"], follow.yaw_speed);
|
||||
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
|
||||
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
|
||||
// The name trio (0992) welds to NameConfig's defaults the same way.
|
||||
@@ -810,7 +839,7 @@ mod tests {
|
||||
let mut minimap = MinimapZoom::default();
|
||||
let mut bubbles = BubbleConfig::default();
|
||||
let mut zoom = ZoomLimit::default();
|
||||
let mut follow = FollowStyle::default();
|
||||
let mut follow = FollowConfig::default();
|
||||
let mut video = VideoConfig::default();
|
||||
let mut pane_rate = PaneRate::default();
|
||||
let mut knobs = Knobs {
|
||||
@@ -851,14 +880,25 @@ mod tests {
|
||||
assert_eq!(knobs.look.sensitivity, 1.4);
|
||||
assert!(apply_to_knobs("mousespeed", "9", &mut knobs));
|
||||
assert_eq!(knobs.look.sensitivity, 1.5);
|
||||
// The following style lands as the enum, on the reference's own numbering — and an
|
||||
// off-grid value reads as the registrar default rather than as a dead camera.
|
||||
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
|
||||
assert_eq!(*knobs.follow, FollowStyle::Never);
|
||||
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
|
||||
assert_eq!(*knobs.follow, FollowStyle::Always);
|
||||
// The following style lands as the ENGINE's enum (0 Never / 1 Smart / 2 Always), and the
|
||||
// "3" the reference's own dropdown writes for Never still means Never.
|
||||
assert!(apply_to_knobs("cameraSmoothStyle", "0", &mut knobs));
|
||||
assert_eq!(*knobs.follow, FollowStyle::Smart);
|
||||
assert_eq!(knobs.follow.style, FollowStyle::Never);
|
||||
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
|
||||
assert_eq!(knobs.follow.style, FollowStyle::Always);
|
||||
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
|
||||
assert_eq!(knobs.follow.style, FollowStyle::Never);
|
||||
assert!(apply_to_knobs("cameraSmoothStyle", "1", &mut knobs));
|
||||
assert_eq!(knobs.follow.style, FollowStyle::Smart);
|
||||
// Its two siblings land on the same knob — the tracking selector, and the rate, which
|
||||
// clamps to 1.12's AUTO_FOLLOW_SPEED slider range.
|
||||
assert!(apply_to_knobs("cameraSmoothTrackingStyle", "2", &mut knobs));
|
||||
assert_eq!(knobs.follow.tracking_style, FollowStyle::Always);
|
||||
assert_eq!(knobs.follow.style, FollowStyle::Smart, "and only that one");
|
||||
assert!(apply_to_knobs("cameraYawSmoothSpeed", "270", &mut knobs));
|
||||
assert_eq!(knobs.follow.yaw_speed, 270.0);
|
||||
assert!(apply_to_knobs("cameraYawSmoothSpeed", "9000", &mut knobs));
|
||||
assert_eq!(knobs.follow.yaw_speed, *FOLLOW_SPEED_RANGE.end());
|
||||
// The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2.
|
||||
assert!(apply_to_knobs("cameraDistanceMaxFactor", "1", &mut knobs));
|
||||
assert_eq!(knobs.zoom.max, 15.0);
|
||||
@@ -953,7 +993,7 @@ mod tests {
|
||||
.init_resource::<MinimapZoom>()
|
||||
.init_resource::<BubbleConfig>()
|
||||
.init_resource::<ZoomLimit>()
|
||||
.init_resource::<FollowStyle>()
|
||||
.init_resource::<FollowConfig>()
|
||||
.init_resource::<VideoConfig>()
|
||||
.init_resource::<PaneRate>()
|
||||
.add_plugins(CvarPlugin);
|
||||
@@ -1024,7 +1064,7 @@ mod tests {
|
||||
.init_resource::<MinimapZoom>()
|
||||
.init_resource::<BubbleConfig>()
|
||||
.init_resource::<ZoomLimit>()
|
||||
.init_resource::<FollowStyle>()
|
||||
.init_resource::<FollowConfig>()
|
||||
.init_resource::<VideoConfig>()
|
||||
.init_resource::<PaneRate>()
|
||||
.add_plugins(CvarPlugin);
|
||||
|
||||
@@ -203,6 +203,13 @@ impl Plugin for DevProbesPlugin {
|
||||
if std::env::var("WOW_DRESS_CENSUS").is_ok() {
|
||||
app.add_plugins(crate::capture::DressCensusPlugin);
|
||||
}
|
||||
// The reveal audit: `WOW_REVEAL=<frames>` prints one line per frame from a snap —
|
||||
// the cover, every residency term behind `presentable()`, the settle hold and the
|
||||
// retained pass's collected-vs-published regions. The instrument for "the teleport
|
||||
// showed me a world with no buildings in it" (see `capture::RevealAuditPlugin`).
|
||||
if std::env::var("WOW_REVEAL").is_ok() {
|
||||
app.add_plugins(crate::capture::RevealAuditPlugin);
|
||||
}
|
||||
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
|
||||
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
|
||||
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
|
||||
|
||||
@@ -69,6 +69,14 @@ const CLEAR_AFTER_READY_FRAMES: u32 = 3;
|
||||
/// nothing extra.
|
||||
const WAIT_LOG_AFTER: f32 = 3.0;
|
||||
const WAIT_LOG_EVERY: f32 = 2.0;
|
||||
/// How far a same-map snap must move the body before its destination is treated as a **load** at
|
||||
/// all (yards). Under this, a teleport cannot outrun the streamer — the ground and buildings you
|
||||
/// land on are the ones you left — and the raise below would only be able to flash a cover over
|
||||
/// content that happened to be arriving anyway. Sized to clear every *combat* relocation with
|
||||
/// room to spare (charge/intercept 25 yd, blink 20 yd, the knockbacks under 30), because those
|
||||
/// end in a server teleport too and a black screen mid-fight is the one thing this must never
|
||||
/// do; the reported case — `.tele` across a city — is 458 yd.
|
||||
const SNAP_LOAD_MIN_YD: f32 = 100.0;
|
||||
|
||||
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
|
||||
/// path table). Paired with [`MapCatalogRes`] (the `mapId` → `LoadingScreenID` FK) to resolve art.
|
||||
@@ -109,6 +117,10 @@ pub(crate) struct LoadingScreen {
|
||||
/// [`WAIT_LOG_AFTER`]).
|
||||
active_since: f32,
|
||||
last_wait_log: f32,
|
||||
/// The avatar's position as of LAST frame — so a snap's own displacement is knowable here
|
||||
/// (the snap is applied in `Input`, a stage before this one). Read only by the snap raise
|
||||
/// below, which uses it to tell a relocation from a spell's little hop.
|
||||
last_pos: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl LoadingScreen {
|
||||
@@ -385,7 +397,8 @@ fn drive_loading_screen(
|
||||
for w in worldports.read() {
|
||||
screen.raise("worldport", false, Some(w.map_id), now);
|
||||
}
|
||||
if teleports.read().next().is_some() {
|
||||
let teleported = teleports.read().next().is_some();
|
||||
if teleported {
|
||||
screen.awaiting_snap = false;
|
||||
screen.pending_map = None;
|
||||
}
|
||||
@@ -437,6 +450,34 @@ fn drive_loading_screen(
|
||||
screen.raise("focus not resident", false, None, now);
|
||||
}
|
||||
|
||||
// --- …and the same test at the SNAP, on everything the reveal actually needs. The backstop
|
||||
// above watches one term — the focus TERRAIN tile — and a teleport inside the streamer's keep
|
||||
// band (up to ~1600 yd, three tiles) lands on ground that is already resident, so it never
|
||||
// fires. The destination's *buildings* are a different question: their placements may still be
|
||||
// spawning, and since the retained pass (1429) a spawned building still has a bake between it
|
||||
// and the screen. That is how `.tele` across a city put the player down in a Stormwind with no
|
||||
// Stormwind in it, uncovered, for the frames it took to arrive.
|
||||
//
|
||||
// Only at a snap, and only against the same predicate that CLEARS the screen: a summon across
|
||||
// a room whose world is already there raises nothing (no flash), and ordinary walking — which
|
||||
// crosses tile lines with placements pending all day — is not a snap and cannot reach this. ---
|
||||
let body = player.as_ref().map(|p| p.pos);
|
||||
let relocated = match (teleported, screen.last_pos, body) {
|
||||
(true, Some(was), Some(now_pos)) => was.distance(now_pos) >= SNAP_LOAD_MIN_YD,
|
||||
// No previous position to compare (the entry frame): treat the snap as a relocation, the
|
||||
// conservative arm — a covered load is recoverable, a naked one is what this closes.
|
||||
(true, _, _) => true,
|
||||
_ => false,
|
||||
};
|
||||
screen.last_pos = body;
|
||||
if relocated
|
||||
&& !screen.active
|
||||
&& *state.get() == crate::char_select::ClientState::InWorld
|
||||
&& !(progress.is_ready() && progress.presentable())
|
||||
{
|
||||
screen.raise("teleport, destination not presentable", false, None, now);
|
||||
}
|
||||
|
||||
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
|
||||
// placements + colliders — [`WorldLoadProgress::presentable`]), and the physics hold is done,
|
||||
// sustained a few frames. ---
|
||||
|
||||
@@ -290,6 +290,16 @@ pub(crate) struct WarmPass {
|
||||
effect_tex: Option<Handle<Image>>,
|
||||
/// Consecutive covered+in-world frames seen while idle (see [`WARM_COVER_PRESENT_FRAMES`]).
|
||||
covered_frames: u32,
|
||||
/// **The menagerie has drained cleanly once in this process.** The warm set is a fixed
|
||||
/// variant cross built from `Startup`-populated stores (see `menagerie`) — not the resident
|
||||
/// map's art — and `PipelineCache` is process-global and never evicts, so a second pass
|
||||
/// compiles nothing. Re-running it under every later cover was pure cost, and *visible*
|
||||
/// cost: it holds the cover (`satisfied`) for the whole paced reveal, which measured 3.8 s
|
||||
/// on a mid-session teleport whose world was resident in 0.7 s. Later covers therefore skip
|
||||
/// the pass. If that is ever wrong the tripwire says so out loud — `PipeWatch`'s "compiled
|
||||
/// LIVE" warn is exactly the instrument for it — which is why this can be a latch and not a
|
||||
/// guess. A timeout does NOT latch it: something was still pending.
|
||||
warmed_once: bool,
|
||||
/// Pacing state (1116): rigs warmed so far, when the last slice went out, how many frames
|
||||
/// the reveal spanned, and the slice currently on screen (hidden again next frame).
|
||||
revealed: usize,
|
||||
@@ -410,6 +420,11 @@ fn run_warm_pass(
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
// Already warmed in this process: nothing to compile, so nothing to hold the cover for.
|
||||
if warm.warmed_once {
|
||||
warm.done = true;
|
||||
return;
|
||||
}
|
||||
let Some(spawned) = warm.spawned_at else {
|
||||
// The cover just rose (or the world just became live under one): raise the gate and
|
||||
// spawn the menagerie once the camera + shared light exist (both are entry-frame-early;
|
||||
@@ -507,6 +522,7 @@ fn run_warm_pass(
|
||||
.saturating_sub(watch.0.settled.load(Ordering::Relaxed));
|
||||
if all_revealed && pending == 0 && now - last_reveal >= WARM_SETTLE_SECS {
|
||||
warm.done = true;
|
||||
warm.warmed_once = true;
|
||||
warm.effect_tex = None;
|
||||
despawn_rigs(&mut commands, &rigs);
|
||||
info!(
|
||||
|
||||
@@ -189,7 +189,7 @@ impl Plugin for PlayerPlugin {
|
||||
);
|
||||
app.init_resource::<camera::LookConfig>();
|
||||
app.init_resource::<camera::ZoomLimit>();
|
||||
app.init_resource::<camera::FollowStyle>();
|
||||
app.init_resource::<camera::FollowConfig>();
|
||||
// Far sight: resolve `PLAYER_FARSIGHT` into a pose before `control` reads it to seat the
|
||||
// camera. A separate system rather than another query on `control` for a hard reason —
|
||||
// `control` already holds the self entity's `Transform` mutably, so it cannot also read an
|
||||
@@ -351,7 +351,7 @@ fn control(
|
||||
Res<AccumulatedMouseMotion>,
|
||||
Res<camera::LookConfig>,
|
||||
Res<camera::ZoomLimit>,
|
||||
Res<camera::FollowStyle>,
|
||||
Res<camera::FollowConfig>,
|
||||
),
|
||||
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
|
||||
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
|
||||
@@ -493,15 +493,24 @@ fn control(
|
||||
let view_subject = &speed_capsule.8;
|
||||
let self_guid = speed_capsule.9 .0;
|
||||
let scoped = &speed_capsule.10;
|
||||
// The auto-follow style (decision 1493), with far sight's one exception folded in here so both
|
||||
// camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
|
||||
// Totem), our own facing is not what "behind" means, so the chase is forced off rather than
|
||||
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
|
||||
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
|
||||
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
|
||||
// reeling that camera toward a heading with nothing to do with the view.
|
||||
let follow_style = if view_subject.remote.is_some() {
|
||||
camera::FollowStyle::Never
|
||||
} else {
|
||||
*pointer.3
|
||||
let follow_cfg = camera::FollowConfig {
|
||||
style: if view_subject.remote.is_some() {
|
||||
camera::FollowStyle::Never
|
||||
} else {
|
||||
pointer.3.style
|
||||
},
|
||||
tracking_style: if view_subject.remote.is_some() {
|
||||
camera::FollowStyle::Never
|
||||
} else {
|
||||
pointer.3.tracking_style
|
||||
},
|
||||
..*pointer.3
|
||||
};
|
||||
|
||||
let dt = time.delta_secs();
|
||||
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
|
||||
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
|
||||
@@ -522,6 +531,61 @@ fn control(
|
||||
let both_buttons =
|
||||
(buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held;
|
||||
|
||||
// The camera's **input command word** (decision 1502) — 1.12's `[InputControl+0x4]`, bit for
|
||||
// bit. The auto-follow is armed by *edges on this word* and its state is classified from it, so
|
||||
// it is built once here, from the same binding state the movement code below reads, and handed
|
||||
// to both camera seats. MOVEANDSTEER sets both mouse bits because that is what the reference's
|
||||
// binding does — it runs the identical CameraOrSelectOrMove + TurnOrAction pair.
|
||||
let follow_command = {
|
||||
use camera::follow_cmd as bit;
|
||||
let mut w = 0;
|
||||
let mut set = |on: bool, b: u32| {
|
||||
if on {
|
||||
w |= b;
|
||||
}
|
||||
};
|
||||
set(
|
||||
buttons.pressed(MouseButton::Right) || steer_held,
|
||||
bit::RIGHT_MOUSE,
|
||||
);
|
||||
set(
|
||||
buttons.pressed(MouseButton::Left) || steer_held,
|
||||
bit::LEFT_MOUSE,
|
||||
);
|
||||
// `/follow` is the forward bit in the reference too — the same setter the W key drives
|
||||
// (decision 0890), so it arms the camera exactly like a held W.
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
|
||||
bit::FORWARD,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
|
||||
bit::BACKWARD,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::STRAFE_LEFT),
|
||||
bit::STRAFE_LEFT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::STRAFE_RIGHT),
|
||||
bit::STRAFE_RIGHT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::TURN_LEFT),
|
||||
bit::TURN_LEFT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::TURN_RIGHT),
|
||||
bit::TURN_RIGHT,
|
||||
);
|
||||
set(player.autorun, bit::AUTORUN);
|
||||
// The two externally-driven flags, which the reference folds into the camera's own
|
||||
// Track/Fear bits rather than the input word — carried here so one word carries every edge.
|
||||
set(player.server_riding, bit::TRACK);
|
||||
set(player.control_lost, bit::FEAR);
|
||||
w
|
||||
};
|
||||
|
||||
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
|
||||
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
|
||||
// Changed regardless of writes — and bevy_winit's `changed_cursor_options` then re-applied
|
||||
@@ -733,16 +797,9 @@ fn control(
|
||||
// `reseat` window is excluded — there the resource still describes the body we are
|
||||
// letting go of, and `apply_server_moves` owns the adoption.
|
||||
//
|
||||
// The same sync answers the auto-follow's Smart gate (1493): is the body we are NOT
|
||||
// driving nevertheless moving? A taxi, a Charge, a knockback or a fear all translate
|
||||
// the avatar while the controller stands down, and a camera that ignored them would
|
||||
// sit at the angle you left it while the body ran out from under it. A spline ride is
|
||||
// translation by definition; a driven body is measured against the pose we held.
|
||||
let mut driven_moving = player.server_riding;
|
||||
if player.control_lost && !player.reseat {
|
||||
if let Ok((_, t, ..)) = body.single() {
|
||||
let yaw = server_ride::yaw_of(t.rotation);
|
||||
driven_moving |= t.translation.distance_squared(player.pos) > 1.0e-6;
|
||||
player.pos = t.translation;
|
||||
player.face_yaw = yaw;
|
||||
player.model_yaw = yaw;
|
||||
@@ -772,10 +829,15 @@ fn control(
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
// The auto-follow still runs here — a taxi, a Charge, a knockback or a fear
|
||||
// all translate the avatar while the controller stands down, and the reference
|
||||
// has states for exactly those (`Track`, `Fear`: a 0.4 s delay and a lazy 18 °/s
|
||||
// return under Smart). The word carries both flags, so the edge into and out of
|
||||
// one of them is what arms it.
|
||||
&camera::FollowInput {
|
||||
style: follow_style,
|
||||
cfg: follow_cfg,
|
||||
face_yaw: player.face_yaw,
|
||||
moving: driven_moving,
|
||||
command: follow_command,
|
||||
},
|
||||
);
|
||||
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
|
||||
@@ -1706,12 +1768,12 @@ fn control(
|
||||
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
|
||||
// carries the camera unconditionally; only input turns respect `seat_camera`'s
|
||||
// look-session gate).
|
||||
// The auto-follow's own three facts (decision 1493): the player's style, where "behind"
|
||||
// is, and whether the character is translating — Smart's gate.
|
||||
// The auto-follow's own three facts (decisions 1493/1502): the knobs, where "behind" is,
|
||||
// and the input word whose edges arm a return.
|
||||
let follow = camera::FollowInput {
|
||||
style: follow_style,
|
||||
cfg: follow_cfg,
|
||||
face_yaw: player.face_yaw,
|
||||
moving,
|
||||
command: follow_command,
|
||||
};
|
||||
seat_camera(
|
||||
dt,
|
||||
|
||||
@@ -195,43 +195,65 @@ impl LookConfig {
|
||||
LOOK_SENSITIVITY * self.sensitivity
|
||||
}
|
||||
}
|
||||
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, whose registrar default is
|
||||
/// **180 °/s** (`WoW.exe` `[0xbe1070]`, the yaw transition channel's own CVar float — wow-re
|
||||
/// `ui/scratch/camera-control.md`). The reference exposes it as the AUTO_FOLLOW_SPEED slider
|
||||
/// (`UIOptionsFrameSliders`, 90 … 270 °/s by 10) and *disables that slider* when the style is
|
||||
/// Never; benilla ships the registrar default as a constant, since it has no row for it yet.
|
||||
const FOLLOW_RATE: f32 = std::f32::consts::PI;
|
||||
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, registrar default **180 °/s**
|
||||
/// (`WoW.exe` `[0xbe1070]`, read at `0x512d75`). It is not a slew rate but a *duration* divisor:
|
||||
/// the transition below lasts `|Δyaw| / rate × factor`, so 180 °/s is the **average** rate at
|
||||
/// factor 1, and the cosine profile peaks at π/2 × that. 1.12 exposes it as the AUTO_FOLLOW_SPEED
|
||||
/// slider (`UIOptionsFrameSliders`, 90 … 270 by 10), disabled while the style is Never.
|
||||
pub(crate) const FOLLOW_SPEED_DEFAULT: f32 = 180.0;
|
||||
/// The 1.12 slider's own range for [`FollowConfig::yaw_speed`].
|
||||
pub(crate) const FOLLOW_SPEED_RANGE: std::ops::RangeInclusive<f32> = 90.0..=270.0;
|
||||
/// `cameraSmoothTimeMin`/`Max` — the transition's duration floor and ceiling, **VERIFIED
|
||||
/// 0.1 s / 2.0 s** (`[0xbe105c]`/`[0xbe1038]`, clamped at `0x510f4d`). These are why the felt
|
||||
/// rate is *not* 180 °/s at the ends: a 5° correction still takes 0.1 s, and a lazy `Track`
|
||||
/// return (factor 10) is capped at 2 s however far it has to come.
|
||||
const FOLLOW_TIME_MIN: f32 = 0.1;
|
||||
const FOLLOW_TIME_MAX: f32 = 2.0;
|
||||
/// The "already there / already arming this" epsilon — the reference's own `0.001`
|
||||
/// (`[0x801360]`, `0x512ce4`/`0x512d41`). A float-equality guard, not a perceptible deadzone.
|
||||
const FOLLOW_EPS: f32 = 1.0e-3;
|
||||
|
||||
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decision 1493): does the camera swing
|
||||
/// back behind the character on its own?
|
||||
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decisions 1493/1502): does the camera
|
||||
/// return to behind the character on its own?
|
||||
///
|
||||
/// benilla shipped the reference behaviour *removed* from the day the camera was written (the
|
||||
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, and
|
||||
/// the director's call now is the reference's own default: **Smart**.
|
||||
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, at
|
||||
/// the reference's own default: **Smart**.
|
||||
///
|
||||
/// The three values are the reference's, dropdown order and all (`UIOptionsFrameCameraDropDown`,
|
||||
/// which writes this CVar): `"1"` Smart, `"2"` Always, `"3"` Never — note that Never is **3**, not
|
||||
/// 0. The registrar default is `"1"`.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
/// **The enum is the ENGINE's, `0 = Never · 1 = Smart · 2 = Always`** — byte-verified twice over
|
||||
/// (wow-re `ui/scratch/camera-smooth-style.md` §2: the registration loop walks
|
||||
/// `{"Never","Smart","Always"}` filling three blocks in order, and both consumers index by
|
||||
/// `style × stride`). 1.12's own *dropdown* writes `1/2/3` instead (`UIOptionsFrameCameraDropDown`
|
||||
/// — Smart 1, Always 2, Never **3**), and 3 is not a style at all: the validator accepts it
|
||||
/// (`0x50b330(v, 0, 3)`) but the terrain-tilt consumer does not bound-check and indexes 360 bytes
|
||||
/// past its table. So the reference's shipped UI writes an out-of-range value for Never, and a
|
||||
/// client must clamp to 0..2. We do: our own dropdown writes the engine's numbers, and a stray
|
||||
/// `3` is *read* as Never — what whoever wrote it meant.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum FollowStyle {
|
||||
/// Stay where placed, *except while the character is moving* — the reference's own
|
||||
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
|
||||
/// benilla did unconditionally before 1493. It does **not** stop the rigid keyboard-turn
|
||||
/// carry — in the reference that is a different mechanism entirely (the camera yaw is stored
|
||||
/// *relative* to the followed unit's facing), and Never leaves it running.
|
||||
Never,
|
||||
/// Stay where placed, *except* while the character is being driven — the reference's own
|
||||
/// "(Recommended Mode)" and its registrar default.
|
||||
#[default]
|
||||
Smart,
|
||||
/// Always prefer being behind the character.
|
||||
/// Always prefer being behind the character: every input edge arms a return, standing still
|
||||
/// included.
|
||||
Always,
|
||||
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
|
||||
/// benilla did unconditionally before 1493.
|
||||
Never,
|
||||
}
|
||||
|
||||
impl FollowStyle {
|
||||
/// From the CVar's number. Anything off the three stops reads as the registrar default —
|
||||
/// a hand-edited config or an addon's stray write gets the shipped behaviour, not a dead
|
||||
/// camera.
|
||||
/// From the CVar's number. `3` is the shipped-UI's Never (see the type doc); anything else off
|
||||
/// the ladder reads as the registrar default rather than as a dead camera.
|
||||
pub(crate) fn from_cvar(v: f32) -> Self {
|
||||
match v as i32 {
|
||||
0 => Self::Never,
|
||||
2 => Self::Always,
|
||||
// The 1.12 dropdown's own Never. Out of range for the engine's tables — accepted here
|
||||
// because a config or addon carrying it means Never, not "surprise me".
|
||||
3 => Self::Never,
|
||||
_ => Self::Smart,
|
||||
}
|
||||
@@ -240,47 +262,292 @@ impl FollowStyle {
|
||||
/// The CVar string this style is — the value the table and `config.toml` carry.
|
||||
pub(crate) fn cvar(self) -> &'static str {
|
||||
match self {
|
||||
Self::Never => "0",
|
||||
Self::Smart => "1",
|
||||
Self::Always => "2",
|
||||
Self::Never => "3",
|
||||
}
|
||||
}
|
||||
|
||||
/// Should the camera chase the character's facing this frame? `moving` is the character's own
|
||||
/// translation intent (Smart's gate — the reference's "except when your character is moving").
|
||||
fn chases(self, moving: bool) -> bool {
|
||||
/// The `cameraSmooth<Style><State>{Delay,Factor}` row — family A at `[0xbe0e70]`, dumped at
|
||||
/// its defaults in wow-re `camera-smooth-style.md` §3. Factor `0` means *cancel*: the armed
|
||||
/// transition is dropped and the camera keeps the offset it has.
|
||||
///
|
||||
/// Family B (`cameraSmoothViewData<Style>Yaw{Delay,Factor}`) multiplies in: its Yaw factor is
|
||||
/// `1.0` under Smart and Always and `0.0` under Never, and its delay is `0.0` at every style —
|
||||
/// so for the yaw channel the composition is the identity and the table below is the answer.
|
||||
fn row(self, state: FollowState) -> (f32, f32) {
|
||||
match self {
|
||||
Self::Always => true,
|
||||
Self::Smart => moving,
|
||||
Self::Never => false,
|
||||
// Every row is 0/0 — and family B's Never Yaw factor is 0.0 as well, twice over.
|
||||
Self::Never => (0.0, 0.0),
|
||||
Self::Smart => match state {
|
||||
// The two states that make Smart *smart*: nothing returns while you stand or stop.
|
||||
FollowState::Idle | FollowState::Stop => (0.0, 0.0),
|
||||
// Driven from outside (a taxi, a spline, a fear): a lazy return — 0.4 s of delay
|
||||
// and factor 10, which is 18 °/s of average rate before the 2 s cap bites.
|
||||
FollowState::Track | FollowState::Fear => (0.4, 10.0),
|
||||
FollowState::Move | FollowState::Strafe | FollowState::Turn => (0.0, 1.0),
|
||||
},
|
||||
// Always is factor 1.0 in every state, Idle and Stop included.
|
||||
Self::Always => (0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`seat_camera`] needs to run the auto-follow this frame — the style, where "behind" is,
|
||||
/// and whether the character is moving. Bundled because the three are one question, and
|
||||
/// The seven arming states of 1.12's auto-return classifier (`0x510960`, wow-re
|
||||
/// `camera-smooth-style.md` §6.2), **in the reference's own priority order — highest first**. The
|
||||
/// winner is the highest-priority bit set, and the states are read off the *camera's* input
|
||||
/// command word, not off the character's velocity: right-mouse alone is a `Turn`, a turn key
|
||||
/// under right-mouse is a `Strafe`, and both mouse buttons are a `Move`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum FollowState {
|
||||
/// Driven by something that is not us and is not a spline — the reference's external-control
|
||||
/// flag `[cam+0x90] & 0x1000`.
|
||||
Fear,
|
||||
Turn,
|
||||
Strafe,
|
||||
Move,
|
||||
/// Externally-driven movement (taxi / server spline) — `[cam+0x90] & 0x100`.
|
||||
Track,
|
||||
/// This edge released a movement input (the reference's `stopping` argument). Carries the same
|
||||
/// `(0, 0)` / `(0, 1)` rows as [`FollowState::Idle`] at every shipped style, so the two are
|
||||
/// currently indistinguishable in behaviour — kept apart because the *matrix* keeps them apart.
|
||||
Stop,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// The camera's input command word — 1.12's `[InputControl+0x4]` bit for bit (wow-re
|
||||
/// `camera-smooth-style.md` §6.1), because the state classifier and the *edge* that arms a
|
||||
/// transition are both defined on it. benilla has no PitchUp/PitchDown bindings, so those two bits
|
||||
/// are simply never set.
|
||||
pub(super) mod follow_cmd {
|
||||
/// `TurnOrAction` — right mouse / mouselook.
|
||||
pub(in crate::player) const RIGHT_MOUSE: u32 = 0x1;
|
||||
/// `CameraOrSelectOrMove` — left mouse.
|
||||
pub(in crate::player) const LEFT_MOUSE: u32 = 0x2;
|
||||
pub(in crate::player) const FORWARD: u32 = 0x10;
|
||||
pub(in crate::player) const BACKWARD: u32 = 0x20;
|
||||
pub(in crate::player) const STRAFE_LEFT: u32 = 0x40;
|
||||
pub(in crate::player) const STRAFE_RIGHT: u32 = 0x80;
|
||||
pub(in crate::player) const TURN_LEFT: u32 = 0x100;
|
||||
pub(in crate::player) const TURN_RIGHT: u32 = 0x200;
|
||||
pub(in crate::player) const AUTORUN: u32 = 0x1000;
|
||||
/// The union of bits 20/21/23, which the reference folds into the camera's `Track` flag —
|
||||
/// externally-driven movement.
|
||||
pub(in crate::player) const TRACK: u32 = 0x100000;
|
||||
/// External control (the reference's own `[cam+0x90] & 0x1000`, not an InputControl bit —
|
||||
/// carried here so one word carries every edge the arming function reacts to).
|
||||
pub(in crate::player) const FEAR: u32 = 0x2000_0000;
|
||||
|
||||
/// The bits `Move` reads: forward, backward, autorun.
|
||||
pub(in crate::player) const MOVE_BITS: u32 = FORWARD | BACKWARD | AUTORUN;
|
||||
pub(in crate::player) const STRAFE_BITS: u32 = STRAFE_LEFT | STRAFE_RIGHT;
|
||||
pub(in crate::player) const TURN_BITS: u32 = TURN_LEFT | TURN_RIGHT;
|
||||
}
|
||||
|
||||
/// The knobs behind the auto-follow (decision 1502) — the style, the style the *externally-driven*
|
||||
/// states use instead, and the rate. All three are 1.12 CVars with the reference's own defaults.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct FollowConfig {
|
||||
/// `cameraSmoothStyle`.
|
||||
pub(crate) style: FollowStyle,
|
||||
/// `cameraSmoothTrackingStyle` — the selector the reference swaps in when the state mask so
|
||||
/// much as *contains* `Track` or `Fear` (`0x510a51 test bl,0x44`, before the priority scan),
|
||||
/// indexing the very same matrices. Registrar default `"1"` = Smart, like its sibling.
|
||||
pub(crate) tracking_style: FollowStyle,
|
||||
/// `cameraYawSmoothSpeed`, °/s — see [`FOLLOW_SPEED_DEFAULT`].
|
||||
pub(crate) yaw_speed: f32,
|
||||
}
|
||||
|
||||
impl Default for FollowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
style: FollowStyle::default(),
|
||||
tracking_style: FollowStyle::default(),
|
||||
yaw_speed: FOLLOW_SPEED_DEFAULT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`seat_camera`] needs to run the auto-follow this frame: the knobs, where "behind" is, and
|
||||
/// the input word whose *edges* arm a return. Bundled because they are one question, and
|
||||
/// `seat_camera` is already at its argument ceiling.
|
||||
pub(super) struct FollowInput {
|
||||
pub(super) style: FollowStyle,
|
||||
pub(super) cfg: FollowConfig,
|
||||
/// The character's own facing (same yaw convention as [`FlyCam::yaw`] — a right-drag couples
|
||||
/// the two directly).
|
||||
/// the two directly), so the camera's *offset* is `cam.yaw − face_yaw`.
|
||||
pub(super) face_yaw: f32,
|
||||
/// Is the character translating this frame? (Turn-in-place is not movement here: a keyboard
|
||||
/// turn already carries the camera rigidly, so there is no offset left for the chase.)
|
||||
pub(super) moving: bool,
|
||||
/// This frame's [`follow_cmd`] word.
|
||||
pub(super) command: u32,
|
||||
}
|
||||
|
||||
impl FollowInput {
|
||||
/// The yaw this frame's chase adds — the shortest arc to the character's facing, capped at
|
||||
/// [`FOLLOW_RATE`]·dt so the camera *slews* rather than snapping, and exactly zero on a style
|
||||
/// (or a stillness) that isn't chasing. The cap stops **at** the target: the clamp can never
|
||||
/// overshoot, so a settled camera writes nothing and the no-op gate downstream stays quiet.
|
||||
fn step(&self, cam_yaw: f32, dt: f32) -> f32 {
|
||||
if !self.style.chases(self.moving) {
|
||||
return 0.0;
|
||||
/// The winning state — the reference's descending priority scan, highest bit first.
|
||||
fn state(&self, stopping: bool) -> FollowState {
|
||||
let mf = self.command;
|
||||
let held = |bits: u32| mf & bits != 0;
|
||||
if held(follow_cmd::FEAR) {
|
||||
FollowState::Fear
|
||||
} else if held(follow_cmd::TURN_BITS) || held(follow_cmd::RIGHT_MOUSE) {
|
||||
FollowState::Turn
|
||||
} else if held(follow_cmd::STRAFE_BITS)
|
||||
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::TURN_BITS))
|
||||
{
|
||||
FollowState::Strafe
|
||||
} else if held(follow_cmd::MOVE_BITS)
|
||||
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::LEFT_MOUSE))
|
||||
{
|
||||
FollowState::Move
|
||||
} else if held(follow_cmd::TRACK) {
|
||||
FollowState::Track
|
||||
} else if stopping {
|
||||
FollowState::Stop
|
||||
} else {
|
||||
FollowState::Idle
|
||||
}
|
||||
let step = FOLLOW_RATE * dt;
|
||||
wrap_pi(self.face_yaw - cam_yaw).clamp(-step, step)
|
||||
}
|
||||
|
||||
/// Which style picks the row: the *tracking* style whenever the mask so much as contains
|
||||
/// `Track` or `Fear`, even when a higher-priority state supplies the row.
|
||||
fn style(&self) -> FollowStyle {
|
||||
if self.command & (follow_cmd::TRACK | follow_cmd::FEAR) != 0 {
|
||||
self.cfg.tracking_style
|
||||
} else {
|
||||
self.cfg.style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The armed yaw transition — the reference's descriptor `[+0x208 startMs, +0x20c dur,
|
||||
/// +0x210 target, +0x214 start]`, in seconds and in *offset* space.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FollowArm {
|
||||
/// The offset the transition started from, radians (camera yaw minus character facing).
|
||||
from: f32,
|
||||
/// Where it is going. `0.0` — directly behind — at the shipped defaults, because
|
||||
/// `cameraYawSmoothMin`/`Max` are both `0.0` and the reference *substitutes* the crossed bound
|
||||
/// for the saved view yaw whenever the live offset is outside the band.
|
||||
to: f32,
|
||||
/// Seconds the move takes, already clamped to `[FOLLOW_TIME_MIN, FOLLOW_TIME_MAX]`.
|
||||
dur: f32,
|
||||
/// Seconds of dead time before it starts (`Track`/`Fear` under Smart: 0.4).
|
||||
delay: f32,
|
||||
/// Seconds since it was armed.
|
||||
elapsed: f32,
|
||||
/// What it was armed with — the reference's re-arm memo (`[+0x218, +0x21c]`), so a repeated
|
||||
/// edge asking for the same transition is a no-op instead of restarting the swing.
|
||||
armed_with: (f32, f32),
|
||||
}
|
||||
|
||||
/// The auto-follow's own state on the rig: the input word we last saw (edges are what arm a
|
||||
/// return) and the transition in flight, if any.
|
||||
#[derive(Default)]
|
||||
pub(super) struct FollowRig {
|
||||
last_command: Option<u32>,
|
||||
arm: Option<FollowArm>,
|
||||
}
|
||||
|
||||
impl FollowRig {
|
||||
/// Run the auto-follow for a frame and return the camera yaw it wants, if it wants one.
|
||||
///
|
||||
/// The shape is the reference's, and the shape is the point (wow-re `camera-smooth-style.md`
|
||||
/// §6/§8): a transition is **armed on an input edge**, from a snapshot taken at that instant,
|
||||
/// and then plays out unattended — it is *not* a per-frame chase of a moving target. That is
|
||||
/// why "drag the camera aside, then press W" swings you back over one smooth arc, while
|
||||
/// holding W changes nothing at all.
|
||||
fn advance(
|
||||
&mut self,
|
||||
input: &FollowInput,
|
||||
cam_yaw: f32,
|
||||
dt: f32,
|
||||
look_held: bool,
|
||||
) -> Option<f32> {
|
||||
let word = input.command;
|
||||
let previous = self.last_command.replace(word);
|
||||
// A held drag owns the camera: the yaw channel is frozen (`0x50f623`), arming is gated
|
||||
// (`0x510850`'s `!([cam+0x90] & 1)`), and **entering** mouse-look cancels whatever was in
|
||||
// flight outright (`0x50fe30` zeroes the descriptors). So the return does not resume when
|
||||
// the button comes up — it begins at the next input edge after the release, which is
|
||||
// usually the release itself (the mouse bits are part of the word).
|
||||
if look_held {
|
||||
self.arm = None;
|
||||
return None;
|
||||
}
|
||||
// The EDGE: any movement/camera binding changing state re-evaluates the transition. The
|
||||
// reference re-evaluates on the binding call itself, not on a change of the classified
|
||||
// state, which is why releasing the drag while standing still is an Idle *arming* under
|
||||
// Always and an Idle *cancel* under Smart.
|
||||
if let Some(p) = previous.filter(|p| *p != word) {
|
||||
// `stopping` — the reference's second argument, set by the Stop half of a movement
|
||||
// binding. Our equivalent is the edge itself: a movement bit went away.
|
||||
let stopping = (p & !word)
|
||||
& (follow_cmd::MOVE_BITS | follow_cmd::STRAFE_BITS | follow_cmd::TURN_BITS)
|
||||
!= 0;
|
||||
self.arm(input, cam_yaw, stopping);
|
||||
}
|
||||
let arm = self.arm.as_mut()?;
|
||||
arm.elapsed += dt;
|
||||
let t = arm.elapsed - arm.delay;
|
||||
if t < 0.0 {
|
||||
return None; // still inside the delay window
|
||||
}
|
||||
let s = t / arm.dur;
|
||||
let offset = if s >= 1.0 {
|
||||
let to = arm.to;
|
||||
self.arm = None;
|
||||
to
|
||||
} else {
|
||||
// The reference's kernel `0x5b7bb0` — a **cosine** smoothstep, eased at both ends:
|
||||
// `a + (b − a)·(1 − cos(πs))/2`.
|
||||
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
|
||||
arm.from + (arm.to - arm.from) * e
|
||||
};
|
||||
Some(wrap_pi(input.face_yaw + offset))
|
||||
}
|
||||
|
||||
/// What the transition is doing, for `WOW_CAM_DUMP` — `None` when nothing is armed, else
|
||||
/// `(elapsed, delay, duration)` in seconds. The auto-follow is the one camera behaviour with
|
||||
/// no headless retest (nothing synthesizes a mouse drag), so the trace line is how a run says
|
||||
/// what it armed and when.
|
||||
fn probe(&self) -> Option<(f32, f32, f32)> {
|
||||
self.arm.map(|a| (a.elapsed, a.delay, a.dur))
|
||||
}
|
||||
|
||||
/// The arming half (`0x510960` → `0x512c70`): pick the row, then either cancel outright or
|
||||
/// snapshot a transition to the target offset.
|
||||
fn arm(&mut self, input: &FollowInput, cam_yaw: f32, stopping: bool) {
|
||||
let (delay, factor) = input.style().row(input.state(stopping));
|
||||
if factor == 0.0 {
|
||||
// Cancel: the target becomes the live yaw and the channel disarms — the camera simply
|
||||
// keeps the offset it has. This is Smart standing still, and it is all of Never.
|
||||
self.arm = None;
|
||||
return;
|
||||
}
|
||||
// The target: directly behind, at the shipped defaults (see [`FollowArm::to`]).
|
||||
let to = 0.0;
|
||||
let from = wrap_pi(cam_yaw - input.face_yaw);
|
||||
let gap = (to - from).abs();
|
||||
if gap < FOLLOW_EPS {
|
||||
return; // already there — the reference returns without arming
|
||||
}
|
||||
// The re-arm memo: an edge asking for the transition already in flight is a no-op, so a
|
||||
// second keypress mid-swing does not restart it from the current angle.
|
||||
if self.arm.is_some_and(|a| {
|
||||
a.to == to
|
||||
&& (a.armed_with.0 - delay).abs() < FOLLOW_EPS
|
||||
&& (a.armed_with.1 - factor).abs() < FOLLOW_EPS
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let rate = input.cfg.yaw_speed.to_radians().max(FOLLOW_EPS);
|
||||
let dur = (gap / rate * factor).clamp(FOLLOW_TIME_MIN, FOLLOW_TIME_MAX);
|
||||
self.arm = Some(FollowArm {
|
||||
from,
|
||||
to,
|
||||
dur,
|
||||
delay,
|
||||
elapsed: 0.0,
|
||||
armed_with: (delay, factor),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +622,10 @@ pub(crate) struct CameraControl {
|
||||
/// the camera zooms into the head (first-person). `control` computes it (it owns the pivot + camera
|
||||
/// pose); [`apply_self_model_fade`] applies it to the body parts. Starts opaque.
|
||||
pub(super) self_fade_alpha: f32,
|
||||
/// The auto-follow's own state (decision 1502): the input word we last saw, and the armed
|
||||
/// return in flight. It lives on the rig rather than beside the knob because it is *pose*, not
|
||||
/// setting — a transition survives the frame, not the session.
|
||||
pub(super) follow: FollowRig,
|
||||
}
|
||||
|
||||
impl CameraControl {
|
||||
@@ -646,14 +917,19 @@ pub(super) fn seat_camera(
|
||||
// — no INPUT-turn carry against the user's hand. (A transport deck's turn is not an input and
|
||||
// never arrives here — the ride block applies it to `cam.yaw` directly, drag or no drag.)
|
||||
//
|
||||
// **The auto-follow** (decision 1493, 1.12's `cameraSmoothStyle`) rides the same gate for the
|
||||
// same reason — a held drag owns the camera, hand on it: swing the camera back behind the
|
||||
// character, along the shortest arc, at [`FOLLOW_RATE`]. Which frames it runs on is the
|
||||
// player's setting — Always every frame, Smart only while the character is translating, Never
|
||||
// not at all (the behaviour benilla shipped unconditionally until now).
|
||||
if rig.look.is_none() {
|
||||
// **The auto-follow** (1.12's `cameraSmoothStyle`, decisions 1493/1502) rides the same gate
|
||||
// for the same reason — a held drag owns the camera, hand on it. It is NOT a per-frame chase:
|
||||
// an input edge arms a cosine-smoothstep return to directly-behind and that transition then
|
||||
// plays out unattended ([`FollowRig::advance`]). It writes an absolute yaw because our camera
|
||||
// stores one; the reference stores the *offset* and re-adds the facing at render time, which
|
||||
// is the same picture and a different mechanism (wow-re `camera-smooth-style.md` §10 — and
|
||||
// the reason Never must not, and here does not, touch the rigid carry above).
|
||||
let look_held = rig.look.is_some();
|
||||
if !look_held {
|
||||
cam.yaw += turn_delta;
|
||||
cam.yaw += follow.step(cam.yaw, dt);
|
||||
}
|
||||
if let Some(yaw) = rig.follow.advance(follow, cam.yaw, dt, look_held) {
|
||||
cam.yaw = yaw;
|
||||
}
|
||||
// Orient the camera, then orbit it behind the avatar's torso. The framing **pivot** is
|
||||
// `feet + cam_pivot_height` (model-derived, ~neck height — [`CameraPivot`]); the camera looks at
|
||||
@@ -722,9 +998,15 @@ pub(super) fn seat_camera(
|
||||
// measured. `open` is printed beside the eased arm so a hit/miss alternation in the CAST is
|
||||
// visible even on a frame where the ease has not yet moved the camera far enough to see.
|
||||
if std::env::var_os("WOW_CAM_DUMP").is_some() {
|
||||
// `follow=` is the auto-follow's own reading (1502): the offset the return is animating,
|
||||
// the state the input word classifies to, and — once armed — how far through the
|
||||
// transition this frame is. `off` moving while `arm` reads `-` means something other than
|
||||
// the follow moved the camera.
|
||||
let (elapsed, delay, dur) = rig.follow.probe().unwrap_or((-1.0, -1.0, -1.0));
|
||||
eprintln!(
|
||||
"[cam] yaw {:.6} pitch {:.6} dist {:.6} open {:.6} coll {:.6} frac {:.6} \
|
||||
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}]",
|
||||
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}] \
|
||||
follow off {:.6} state {:?} word {:06x} arm {:.3}/{:.3}+{:.3}",
|
||||
cam.yaw,
|
||||
cam.pitch,
|
||||
rig.distance,
|
||||
@@ -737,6 +1019,12 @@ pub(super) fn seat_camera(
|
||||
translation.x.to_bits(),
|
||||
translation.y.to_bits(),
|
||||
translation.z.to_bits(),
|
||||
wrap_pi(cam.yaw - follow.face_yaw),
|
||||
follow.state(false),
|
||||
follow.command,
|
||||
elapsed,
|
||||
dur,
|
||||
delay,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1110,47 +1398,243 @@ mod tests {
|
||||
assert_eq!(slow.rate(), LOOK_SENSITIVITY * 0.5);
|
||||
}
|
||||
|
||||
/// **The auto-follow** (decision 1493) — 1.12's `cameraSmoothStyle`, the setting benilla spent
|
||||
/// its whole life behaving as "Never". Four properties, and the last two are the ones a
|
||||
/// re-derivation gets wrong: the style gate (Smart is *movement*-gated, Always is not, Never is
|
||||
/// never), the rate cap, the fact that the chase takes the SHORT way round the ±π seam, and
|
||||
/// that it stops exactly ON the facing rather than oscillating across it.
|
||||
/// **The auto-follow** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, the setting benilla
|
||||
/// spent its whole life behaving as "Never". The properties that a re-derivation gets wrong,
|
||||
/// and that the byte-verified mechanism (wow-re `camera-smooth-style.md`) turns on:
|
||||
/// it is armed by an input **edge** and then plays out unattended (not a per-frame chase of a
|
||||
/// moving target), the profile is a **cosine** smoothstep, the duration is `|Δ| / rate ×
|
||||
/// factor` **clamped to [0.1 s, 2.0 s]**, and Smart's `Idle`/`Stop` rows are a *cancel* — which
|
||||
/// is what "stays where you put it, except while you're moving" actually is.
|
||||
#[test]
|
||||
fn the_auto_follow_chases_on_the_styles_that_chase_and_never_overshoots() {
|
||||
const DT: f32 = 1.0 / 60.0;
|
||||
let step = |style, moving, face_yaw, cam_yaw| {
|
||||
FollowInput {
|
||||
fn the_auto_follow_is_armed_by_an_input_edge_and_eases_home() {
|
||||
const DT: f32 = 1.0 / 120.0;
|
||||
// A quarter turn of orbit offset, left there by a drag.
|
||||
const OFFSET: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
fn cfg(style: FollowStyle) -> FollowConfig {
|
||||
FollowConfig {
|
||||
style,
|
||||
face_yaw,
|
||||
moving,
|
||||
tracking_style: style,
|
||||
yaw_speed: FOLLOW_SPEED_DEFAULT,
|
||||
}
|
||||
.step(cam_yaw, DT)
|
||||
};
|
||||
}
|
||||
/// Run `secs` of frames at a fixed input word; returns the camera yaw it ends on.
|
||||
fn run(rig: &mut FollowRig, cfg: FollowConfig, word: u32, cam_yaw: f32, secs: f32) -> f32 {
|
||||
let mut yaw = cam_yaw;
|
||||
for _ in 0..((secs / DT).round() as i32).max(0) {
|
||||
let input = FollowInput {
|
||||
cfg,
|
||||
face_yaw: 0.0,
|
||||
command: word,
|
||||
};
|
||||
if let Some(y) = rig.advance(&input, yaw, DT, false) {
|
||||
yaw = y;
|
||||
}
|
||||
}
|
||||
yaw
|
||||
}
|
||||
|
||||
// Never is inert — the camera stays exactly where the hand left it, moving or not.
|
||||
assert_eq!(step(FollowStyle::Never, true, 1.0, 0.0), 0.0);
|
||||
// Smart is gated on the character actually translating ("except when your character is
|
||||
// moving"); Always is not.
|
||||
assert_eq!(step(FollowStyle::Smart, false, 1.0, 0.0), 0.0);
|
||||
assert!(step(FollowStyle::Smart, true, 1.0, 0.0) > 0.0);
|
||||
assert!(step(FollowStyle::Always, false, 1.0, 0.0) > 0.0);
|
||||
|
||||
// A big gap slews at the rate, not in one frame.
|
||||
let capped = step(FollowStyle::Always, true, 3.0, 0.0);
|
||||
assert!((capped - FOLLOW_RATE * DT).abs() < 1.0e-6, "{capped}");
|
||||
// A gap smaller than one frame's slew lands ON the facing — no overshoot, and the next
|
||||
// frame writes exactly nothing (which is what keeps the no-op transform gate quiet).
|
||||
let small = 0.5 * FOLLOW_RATE * DT;
|
||||
let landed = step(FollowStyle::Always, true, small, 0.0);
|
||||
assert!((landed - small).abs() < 1.0e-6, "{landed} vs {small}");
|
||||
assert_eq!(step(FollowStyle::Always, true, 0.0, 0.0), 0.0);
|
||||
|
||||
// Across the ±π seam the chase takes the short way: a facing at −3.1 from a camera at 3.1
|
||||
// is 0.08 rad AHEAD, not 6.2 rad behind.
|
||||
assert!(
|
||||
step(FollowStyle::Always, true, -3.1, 3.1) > 0.0,
|
||||
"the shortest arc, not the long way round"
|
||||
// ── Smart: standing still with the camera dragged aside, nothing happens; the moment the
|
||||
// W edge lands, one armed transition brings it home over |Δ|/180°/s = 0.5 s.
|
||||
let mut rig = FollowRig::default();
|
||||
let c = cfg(FollowStyle::Smart);
|
||||
let parked = run(&mut rig, c, 0, OFFSET, 1.0);
|
||||
assert_eq!(
|
||||
parked, OFFSET,
|
||||
"Smart standing still leaves the camera alone"
|
||||
);
|
||||
let half = run(&mut rig, c, follow_cmd::FORWARD, parked, 0.25);
|
||||
assert!(
|
||||
half < OFFSET * 0.75 && half > OFFSET * 0.25,
|
||||
"mid-swing, eased: {half}"
|
||||
);
|
||||
let home = run(&mut rig, c, follow_cmd::FORWARD, half, 0.3);
|
||||
assert!(home.abs() < 1.0e-4, "arrived behind the character: {home}");
|
||||
// And holding W changes nothing further — the transition is spent, not a standing chase.
|
||||
let still_home = run(&mut rig, c, follow_cmd::FORWARD, home + 0.4, 1.0);
|
||||
assert_eq!(
|
||||
still_home,
|
||||
home + 0.4,
|
||||
"a HELD key re-arms nothing: only edges arm"
|
||||
);
|
||||
|
||||
// ── Smart: releasing W (an edge into Stop) cancels rather than arming, so a camera nudged
|
||||
// while stopping stays nudged.
|
||||
let mut rig = FollowRig::default();
|
||||
let held = run(&mut rig, c, follow_cmd::FORWARD, 0.0, 0.1);
|
||||
let released = run(&mut rig, c, 0, held + OFFSET, 0.5);
|
||||
assert_eq!(released, held + OFFSET, "Stop is a cancel under Smart");
|
||||
|
||||
// ── Always arms on that very same Idle edge — the one row where the two styles differ.
|
||||
let mut rig = FollowRig::default();
|
||||
let a = cfg(FollowStyle::Always);
|
||||
let held = run(&mut rig, a, follow_cmd::FORWARD, 0.0, 0.1);
|
||||
let returned = run(&mut rig, a, 0, held + OFFSET, 1.0);
|
||||
assert!(
|
||||
returned.abs() < 1.0e-4,
|
||||
"Always returns even from a standstill: {returned}"
|
||||
);
|
||||
|
||||
// ── Never is inert, edge or no edge.
|
||||
let mut rig = FollowRig::default();
|
||||
let n = cfg(FollowStyle::Never);
|
||||
let _ = run(&mut rig, n, 0, OFFSET, 0.1);
|
||||
assert_eq!(
|
||||
run(&mut rig, n, follow_cmd::FORWARD, OFFSET, 2.0),
|
||||
OFFSET,
|
||||
"Never never arms"
|
||||
);
|
||||
|
||||
// ── The duration floor: a 5° correction is 0.028 s of travel at 180 °/s, and the
|
||||
// reference's 0.1 s minimum overrides it — so it is NOT finished after 0.05 s.
|
||||
let mut rig = FollowRig::default();
|
||||
let small = 5.0_f32.to_radians();
|
||||
let _ = run(&mut rig, c, 0, small, DT);
|
||||
let mid = run(&mut rig, c, follow_cmd::FORWARD, small, 0.05);
|
||||
assert!(
|
||||
mid.abs() > 1.0e-4,
|
||||
"the 0.1 s floor is doing the work: {mid}"
|
||||
);
|
||||
assert!(run(&mut rig, c, follow_cmd::FORWARD, mid, 0.06).abs() < 1.0e-4);
|
||||
|
||||
// ── Track (a taxi, a spline): Smart takes it lazily — 0.4 s of dead time first, then a
|
||||
// factor-10 return the 2 s ceiling caps.
|
||||
let mut rig = FollowRig::default();
|
||||
let _ = run(&mut rig, c, 0, OFFSET, DT);
|
||||
let delayed = run(&mut rig, c, follow_cmd::TRACK, OFFSET, 0.3);
|
||||
assert_eq!(delayed, OFFSET, "nothing moves inside the 0.4 s delay");
|
||||
let crawling = run(&mut rig, c, follow_cmd::TRACK, delayed, 0.6);
|
||||
assert!(
|
||||
crawling > OFFSET * 0.5,
|
||||
"a factor-10 return is a crawl, not a swing: {crawling}"
|
||||
);
|
||||
assert!(
|
||||
run(&mut rig, c, follow_cmd::TRACK, crawling, 2.0).abs() < 1.0e-4,
|
||||
"and it does arrive, inside the 2 s cap"
|
||||
);
|
||||
|
||||
// ── A held drag freezes the channel outright: the hand owns the camera.
|
||||
let mut rig = FollowRig::default();
|
||||
let input = FollowInput {
|
||||
cfg: c,
|
||||
face_yaw: 0.0,
|
||||
command: follow_cmd::FORWARD,
|
||||
};
|
||||
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
|
||||
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
|
||||
|
||||
// ── …and entering the drag **cancels** what was in flight (`0x50fe30` zeroes the
|
||||
// descriptors), so the return does not simply resume when the button comes up. Grab the
|
||||
// camera mid-swing and it stays where the hand left it until the next input edge.
|
||||
let mut rig = FollowRig::default();
|
||||
let _ = run(&mut rig, c, 0, OFFSET, DT);
|
||||
let mid = run(&mut rig, c, follow_cmd::FORWARD, OFFSET, 0.1);
|
||||
assert!(mid < OFFSET && mid > 0.0, "mid-swing: {mid}");
|
||||
let dragging = FollowInput {
|
||||
cfg: c,
|
||||
face_yaw: 0.0,
|
||||
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
|
||||
};
|
||||
assert!(rig.advance(&dragging, mid, DT, true).is_none());
|
||||
// The word is unchanged from the drag frame's, so nothing re-arms on its own…
|
||||
let parked = {
|
||||
let mut yaw = mid;
|
||||
for _ in 0..120 {
|
||||
let input = FollowInput {
|
||||
cfg: c,
|
||||
face_yaw: 0.0,
|
||||
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
|
||||
};
|
||||
if let Some(y) = rig.advance(&input, yaw, DT, false) {
|
||||
yaw = y;
|
||||
}
|
||||
}
|
||||
yaw
|
||||
};
|
||||
assert_eq!(parked, mid, "the cancelled transition does not resume");
|
||||
// …and the release's own edge (the mouse bit leaving the word) is what starts a new one.
|
||||
assert!(
|
||||
run(&mut rig, c, follow_cmd::FORWARD, parked, 1.0).abs() < 1.0e-4,
|
||||
"the release edge arms a fresh return"
|
||||
);
|
||||
}
|
||||
|
||||
/// The state classifier's three vanilla input rules (wow-re `camera-smooth-style.md` §6.2) —
|
||||
/// the ones a client that keys off character *velocity* cannot reproduce, because they are
|
||||
/// read off the camera's own command word: right-mouse alone is a `Turn`, a turn key held
|
||||
/// **under** right-mouse is a `Strafe`, and both mouse buttons together are a `Move`. Plus the
|
||||
/// priority order, which is what decides the row when several are true at once.
|
||||
#[test]
|
||||
fn the_follow_state_reads_the_camera_input_word_not_the_character() {
|
||||
let state = |command: u32, stopping: bool| {
|
||||
FollowInput {
|
||||
cfg: FollowConfig::default(),
|
||||
face_yaw: 0.0,
|
||||
command,
|
||||
}
|
||||
.state(stopping)
|
||||
};
|
||||
assert_eq!(state(0, false), FollowState::Idle);
|
||||
assert_eq!(state(0, true), FollowState::Stop);
|
||||
assert_eq!(state(follow_cmd::RIGHT_MOUSE, false), FollowState::Turn);
|
||||
assert_eq!(
|
||||
state(follow_cmd::RIGHT_MOUSE | follow_cmd::TURN_LEFT, false),
|
||||
FollowState::Turn,
|
||||
"Turn outranks the Strafe its own condition also satisfies"
|
||||
);
|
||||
assert_eq!(state(follow_cmd::STRAFE_LEFT, false), FollowState::Strafe);
|
||||
assert_eq!(
|
||||
state(follow_cmd::RIGHT_MOUSE | follow_cmd::LEFT_MOUSE, false),
|
||||
FollowState::Turn,
|
||||
"both buttons satisfy Move, but Turn outranks it"
|
||||
);
|
||||
assert_eq!(state(follow_cmd::LEFT_MOUSE, false), FollowState::Idle);
|
||||
assert_eq!(state(follow_cmd::AUTORUN, false), FollowState::Move);
|
||||
assert_eq!(state(follow_cmd::TRACK, false), FollowState::Track);
|
||||
assert_eq!(
|
||||
state(follow_cmd::TRACK | follow_cmd::FORWARD, false),
|
||||
FollowState::Move,
|
||||
"Move outranks Track"
|
||||
);
|
||||
assert_eq!(
|
||||
state(follow_cmd::FEAR | follow_cmd::FORWARD, false),
|
||||
FollowState::Fear,
|
||||
"Fear outranks everything"
|
||||
);
|
||||
// …and the tracking style is the selector the moment Track or Fear is merely PRESENT,
|
||||
// even when a higher-priority state supplies the row.
|
||||
let mixed = FollowInput {
|
||||
cfg: FollowConfig {
|
||||
style: FollowStyle::Never,
|
||||
tracking_style: FollowStyle::Always,
|
||||
yaw_speed: FOLLOW_SPEED_DEFAULT,
|
||||
},
|
||||
face_yaw: 0.0,
|
||||
command: follow_cmd::TRACK | follow_cmd::FORWARD,
|
||||
};
|
||||
assert_eq!(mixed.style(), FollowStyle::Always);
|
||||
assert_eq!(mixed.state(false), FollowState::Move);
|
||||
}
|
||||
|
||||
/// The enum is the ENGINE's (0/1/2), not the 1/2/3 the reference's own dropdown writes — and
|
||||
/// that stray `3` still has to mean Never, because that is what whoever wrote it meant.
|
||||
#[test]
|
||||
fn the_follow_style_enum_is_the_engines_and_tolerates_the_dropdowns_stray() {
|
||||
assert_eq!(FollowStyle::from_cvar(0.0), FollowStyle::Never);
|
||||
assert_eq!(FollowStyle::from_cvar(1.0), FollowStyle::Smart);
|
||||
assert_eq!(FollowStyle::from_cvar(2.0), FollowStyle::Always);
|
||||
assert_eq!(FollowStyle::from_cvar(3.0), FollowStyle::Never);
|
||||
// Off the ladder entirely: the registrar default, not a dead camera.
|
||||
assert_eq!(FollowStyle::from_cvar(-1.0), FollowStyle::Smart);
|
||||
assert_eq!(FollowStyle::from_cvar(9.0), FollowStyle::Smart);
|
||||
for style in [FollowStyle::Never, FollowStyle::Smart, FollowStyle::Always] {
|
||||
assert_eq!(
|
||||
FollowStyle::from_cvar(style.cvar().parse::<f32>().unwrap()),
|
||||
style,
|
||||
"the string round-trips"
|
||||
);
|
||||
}
|
||||
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
|
||||
}
|
||||
|
||||
/// The second arm: between 200 and 800 world the travel gate applies, per axis and independently
|
||||
|
||||
@@ -105,7 +105,7 @@ pub(super) fn release_post_snap_hold(
|
||||
mut player: ResMut<Player>,
|
||||
progress: Option<Res<WorldLoadProgress>>,
|
||||
time: Res<Time>,
|
||||
mut last_counters: Local<Option<[usize; 5]>>,
|
||||
mut last_counters: Local<Option<[usize; 6]>>,
|
||||
net_cmds: Option<Res<crate::net::NetCommands>>,
|
||||
) {
|
||||
let Some(p) = progress else { return };
|
||||
@@ -127,7 +127,8 @@ pub(super) fn release_post_snap_hold(
|
||||
player.world_stale = false;
|
||||
}
|
||||
// Did the stream move since last frame? Any counter changing — a tile spawned, a placement
|
||||
// up, a collider queued or attached — is the destination still arriving. Tracked every frame
|
||||
// up, a collider queued or attached, a retained region baked — is the destination still
|
||||
// arriving. Tracked every frame
|
||||
// (not just while settling) so the first settling frame compares against a real baseline.
|
||||
let counters = [
|
||||
p.ready,
|
||||
@@ -135,6 +136,7 @@ pub(super) fn release_post_snap_hold(
|
||||
p.colliders_pending,
|
||||
p.merge_pending,
|
||||
p.placements_pending,
|
||||
p.gx_pending,
|
||||
];
|
||||
let progressed = last_counters.replace(counters) != Some(counters);
|
||||
if !player.settling {
|
||||
@@ -318,6 +320,27 @@ mod tests {
|
||||
assert!(!settling(&mut app), "presentable world, hold still on");
|
||||
}
|
||||
|
||||
/// Decision 1498: geometry that has SPAWNED but not yet baked is a hole in the world exactly
|
||||
/// like an unattached collider, so it holds the post-snap freeze too. The retained pass took
|
||||
/// the static world off the entity path (1429), and from then on "the placement spawned" and
|
||||
/// "the building is on screen" stopped being the same fact — the hold, like the reveal, keys
|
||||
/// on the second.
|
||||
#[test]
|
||||
fn an_unbaked_region_holds_the_settle() {
|
||||
let mut app = app();
|
||||
app.world_mut().resource_mut::<Player>().world_stale = false;
|
||||
step(&mut app, 1.0, |p| {
|
||||
p.total = 2000;
|
||||
p.ready = 2000;
|
||||
p.colliders_pending = 0;
|
||||
p.scene_ready = true;
|
||||
p.gx_pending = 4;
|
||||
});
|
||||
assert!(settling(&mut app), "spawned but undrawn is not presentable");
|
||||
step(&mut app, 0.1, |p| p.gx_pending = 0);
|
||||
assert!(!settling(&mut app), "baked — the world is really there");
|
||||
}
|
||||
|
||||
/// B263 round 3 (decision 1336): residency published for ANOTHER tile never releases the hold
|
||||
/// and never clears the stale flag — the live defect was the focus publish racing the teleport
|
||||
/// snap, so on the snap frame the streamer described the DEPARTURE city as fully resident and
|
||||
|
||||
@@ -274,6 +274,39 @@ pub(crate) struct UiQuads {
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
/// The **append lane's own z bands** — the world-anchored overlays' slice of the same `z_key`
|
||||
/// total order the scripted UI packs its `(stratum, level, layer, …)` tuple into
|
||||
/// ([`benilla_ui::order::ZKey`]).
|
||||
///
|
||||
/// The lane's three producers are three *different* client systems that all draw over the world,
|
||||
/// and their relative order is a fidelity fact each one owns (the floating combat numbers are a
|
||||
/// world-scene draw under all UI; the plates and the bubbles are frames). What is NOT a fidelity
|
||||
/// fact is the arithmetic that keeps them apart — and while each producer picked its own small
|
||||
/// integer, that arithmetic was three private conventions with nothing naming the shared law:
|
||||
/// combat text at 0, the bubble's four pieces at 0..=3 *on top of it*, the plates at 4..=8. The
|
||||
/// bands below make the split explicit and, more to the point, give the middle band **room** —
|
||||
/// a chat bubble is one frame per speaker with its own frame level (decision 1504), which a
|
||||
/// four-key allocation cannot express. Everything here stays far below the scripted UI's keys
|
||||
/// (a `ZKey` region carries its is-region bit at `1 << 20`), so the whole lane still paints
|
||||
/// under the player UI.
|
||||
pub(crate) mod overlay_z {
|
||||
/// Floating combat/XP/honor numbers — the client draws them in the world scene, beneath
|
||||
/// every UI frame ([`crate::combat_text`]).
|
||||
pub(crate) const WORLD_TEXT: u64 = 0;
|
||||
/// The chat bubbles' band ([`crate::chat_bubble`]): one **frame level** per live bubble,
|
||||
/// [`BUBBLE_STRIDE`] keys wide, ordered farthest-camera-distance first.
|
||||
pub(crate) const BUBBLE: u64 = 1 << 8;
|
||||
/// Keys per bubble level — the frame's own four pieces (bg, edge, tail, text).
|
||||
pub(crate) const BUBBLE_STRIDE: u64 = 4;
|
||||
/// The V-plates ([`crate::vplates`]), above every bubble. Same-unit overlap cannot happen
|
||||
/// (the plate/bubble mutual exclusion), so this only orders cross-unit stacking.
|
||||
pub(crate) const VPLATE: u64 = 1 << 16;
|
||||
/// The highest bubble level the band holds before it would reach [`VPLATE`]. Far past any
|
||||
/// real population (a bubble needs a speaker within 20 yd), so the clamp is a guard rail,
|
||||
/// not a policy.
|
||||
pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1;
|
||||
}
|
||||
|
||||
/// Clear the append lane at the top of the [`UiQuadAppend`] window — every appender re-emits its
|
||||
/// frame's quads after this; [`rebuild_ui_mesh`] then diffs the lane to decide if anything changed.
|
||||
fn clear_ui_overlays(mut quads: ResMut<UiQuads>) {
|
||||
|
||||
@@ -853,6 +853,11 @@ mod shipped_xml_tests;
|
||||
#[cfg(test)]
|
||||
mod bagnon_render_tests;
|
||||
|
||||
/// Bug B267 end to end: a hunter's Quiver publishes its global functions (see the file header for
|
||||
/// the three walls that stopped it).
|
||||
#[cfg(test)]
|
||||
mod quiver_tests;
|
||||
|
||||
/// The UI's per-world-entry lifecycle: teardown at the character screen, a genuine second load at
|
||||
/// the next login (decision 1290).
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2803,16 +2803,16 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Camera Following Style** (decision 1493) — 1.12's `cameraSmoothStyle`, worn as a Controls-page
|
||||
/// dropdown, and the setting that decides whether the camera swings back behind the character at
|
||||
/// all. Three things pinned: the reference's own values *and* their order (Smart `"1"`, Always
|
||||
/// `"2"`, Never `"3"` — Never is **3**, not 0, which is exactly what a tidy-up would get wrong);
|
||||
/// the registered default landing on Smart, where benilla behaved as Never for its whole life
|
||||
/// before this row; and the plate following the SELECTION rather than the row — the reference's own
|
||||
/// dropdown repaints its tooltip per entry (`OPTION_TOOLTIP_CAMERA1/2/3`) and this is the first row
|
||||
/// here whose description is not a property of the row alone.
|
||||
/// **Camera Following Style** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, worn as a
|
||||
/// Controls-page dropdown, and the setting that decides whether the camera returns to behind the
|
||||
/// character at all. What is pinned here is the trap: the reference's own dropdown writes `1/2/3`,
|
||||
/// but the ENGINE's tables are indexed `0 = Never · 1 = Smart · 2 = Always`, and `3` is not a style
|
||||
/// — the validator accepts it while the terrain-tilt consumer indexes off the end of its table
|
||||
/// (wow-re `camera-smooth-style.md` §2/§4). So our entries carry the engine's numbers in the
|
||||
/// reference's display order, a stray `3` still reads as Never rather than as the numerically
|
||||
/// nearest "Always", and the plate follows the SELECTION the way that dropdown's own does.
|
||||
#[test]
|
||||
fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate() {
|
||||
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowControls:Click()")
|
||||
@@ -2843,8 +2843,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
|
||||
"reading the table on select must not write it back"
|
||||
);
|
||||
|
||||
// The list is the reference dropdown's, entry for entry: Smart, Always, Never — with the
|
||||
// stored value checked.
|
||||
// The list is the reference dropdown's, entry for entry and in its order.
|
||||
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -2863,12 +2862,12 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
|
||||
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// Never stores "3" — the value the reference writes, not the 0 a fresh enum would invent —
|
||||
// Never stores "0" — the engine's own index, not the "3" the reference's dropdown writes —
|
||||
// and the row's plate becomes Never's own description.
|
||||
s.run("DropDownList1Button3:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("cameraSmoothStyle".to_string(), "3".to_string())]
|
||||
vec![("cameraSmoothStyle".to_string(), "0".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
@@ -2884,8 +2883,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
|
||||
"the plate follows the selection, like the reference dropdown's own"
|
||||
);
|
||||
|
||||
// Always is the middle entry and stores "2" (the pairing that goes wrong if the order is read
|
||||
// off the alphabet instead of off the reference).
|
||||
// Always is the middle entry and stores "2".
|
||||
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
|
||||
.unwrap();
|
||||
s.run("DropDownList1Button2:Click()").unwrap();
|
||||
@@ -2894,6 +2892,24 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
|
||||
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
|
||||
);
|
||||
|
||||
// A config written by a REAL 1.12 client says "3" for Never. It must not display as the
|
||||
// numerically nearest stop — which is Always, the opposite of what it means.
|
||||
s.set_cvar_host("cameraSmoothStyle", "3");
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click(); OptionsFrameCategoryListRowControls:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Never",
|
||||
"the reference dropdown's own stray value still means Never"
|
||||
);
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"displaying a stray value must not write it back"
|
||||
);
|
||||
|
||||
// Defaults walks it back to Smart.
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! **"`Quiver.CastPetAction` is nil when the addon's own macro runs"** — bug B267, reproduced end
|
||||
//! to end and then closed.
|
||||
//!
|
||||
//! ## Why this file exists
|
||||
//!
|
||||
//! The report is a `/run Quiver.CastPetAction("Furious Howl")` macro raising
|
||||
//! `attempt to call field 'CastPetAction' (a nil value)`. The field is published on the addon's
|
||||
//! **last** line of `VARIABLES_LOADED`:
|
||||
//!
|
||||
//! ```lua
|
||||
//! if event == "VARIABLES_LOADED" then
|
||||
//! LoadLocale() Migrations() savedVariablesRestore()
|
||||
//! initSlashCommandsAndModules() -- builds the whole config UI, HUNTERS ONLY
|
||||
//! RegisterGlobalFunctions() -- <- Quiver.CastPetAction = … lives here
|
||||
//! ```
|
||||
//!
|
||||
//! so anything that raises in that handler takes every global function with it. Three separate
|
||||
//! walls did, one behind the other, and each is a real gap of ours:
|
||||
//!
|
||||
//! | | what it does | who found it |
|
||||
//! |---|---|---|
|
||||
//! | the Region method map was two implementations, not one | `Api._Height = WorldFrame.GetHeight` applied to a Texture raised `stale or invalid frame handle` | this bug |
|
||||
//! | `Button:SetFontString` missing | every dropdown option row died on its first line | behind wall 1 |
|
||||
//! | `Frame:SetMinResize`/`SetMaxResize` missing | `SideEffectMakeMoveable` died on every module frame | behind wall 2 |
|
||||
//!
|
||||
//! **The addon survey scored Quiver `loaded, session=ok, probe=ok` through all three.** It seats a
|
||||
//! WARRIOR, and every one of these is behind `if cl == "HUNTER"` — the same blind spot shape as
|
||||
//! Bagnon's (`ui_script::bagnon_render_tests`), one axis over: there the columns asked "did it
|
||||
//! raise" and never "did it draw"; here they ask both, of a session the addon declines to run in.
|
||||
//! So the fixture below seats a **hunter**, and that is the load-bearing part of it.
|
||||
//!
|
||||
//! Nothing from the corpus is committed, and the test skips cleanly on a machine without it — the
|
||||
//! `ui_chat::ace_gate_tests` rule.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
|
||||
use benilla_ui::toc::Toc;
|
||||
|
||||
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
|
||||
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
|
||||
fn corpus_candidates() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
|
||||
out.push(PathBuf::from(over));
|
||||
}
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
for up in [2usize, 3, 4] {
|
||||
if let Some(root) = manifest.ancestors().nth(up) {
|
||||
out.push(root.join("wow-addons-vanilla"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
|
||||
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
|
||||
/// a machine that wants this test builds it into the corpus once.
|
||||
fn quiver_root() -> Option<PathBuf> {
|
||||
corpus_candidates()
|
||||
.into_iter()
|
||||
.find(|c| c.join("Quiver").join("Quiver.toc").is_file())
|
||||
}
|
||||
|
||||
macro_rules! quiver_or_skip {
|
||||
() => {
|
||||
match quiver_root() {
|
||||
Some(root) => root,
|
||||
None => {
|
||||
eprintln!(
|
||||
"skipping: no Quiver in the addon corpus — looked in {:?} \
|
||||
(set $BENILLA_ADDON_CORPUS; the folder needs Quiver.toc + Quiver.bundle.lua)",
|
||||
corpus_candidates()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn read_toc(root: &Path, name: &str) -> Toc {
|
||||
let path = root.join(name).join(format!("{name}.toc"));
|
||||
Toc::parse(&benilla_ui::source::decode(
|
||||
&std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())),
|
||||
))
|
||||
}
|
||||
|
||||
/// One addon's `.toc` files through the same two arms the real loader uses (decision 1186).
|
||||
fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
|
||||
let toc = read_toc(root, name);
|
||||
let provider = |req: &str| -> Option<Vec<u8>> { std::fs::read(root.join(req)).ok() };
|
||||
let mut errors = Vec::new();
|
||||
for file in &toc.files {
|
||||
let path = benilla_ui::loader::join_ref(name, file);
|
||||
let Ok(bytes) = std::fs::read(root.join(&path)) else {
|
||||
errors.push(format!("{file}: not found"));
|
||||
continue;
|
||||
};
|
||||
if file.to_ascii_lowercase().ends_with(".lua") {
|
||||
if let Err(e) =
|
||||
script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file))
|
||||
{
|
||||
errors.push(format!("{file}: {e}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
|
||||
Ok(doc) => {
|
||||
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
|
||||
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
|
||||
}
|
||||
Err(e) => errors.push(format!("{file}: {e}")),
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// A VM shaped like the reporter's session: our whole interface, and a **hunter** at the keyboard.
|
||||
///
|
||||
/// The class is the point (see the module doc). Everything else is the addon-survey seat's own
|
||||
/// minimum — a named player on a named realm, with a faction group, because a character in a real
|
||||
/// session always has all three and a nil one is a failure mode no player can produce.
|
||||
fn seat_a_hunter(root: &Path) -> UiScript {
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.set_realm_name("Harness");
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
name: Some("Harness".into()),
|
||||
health: 100,
|
||||
max_health: 100,
|
||||
level: 60,
|
||||
power_type: 0,
|
||||
power: 100,
|
||||
max_power: 100,
|
||||
race: Some("Night Elf".into()),
|
||||
race_file: Some("NightElf".into()),
|
||||
class: Some("Hunter".into()),
|
||||
class_file: Some("HUNTER".into()),
|
||||
sex: 2,
|
||||
is_player: true,
|
||||
faction_group: Some("Alliance".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// A HUNTER'S SPELLBOOK. Quiver's `Api.Spell.FindSpellIndex` opens
|
||||
// `GetSpellTabInfo(GetNumSpellTabs())` and adds `offset + numSpells`, so an EMPTY book is
|
||||
// `attempt to perform arithmetic on local 'tabOffset'` every tick — the reference answers nil
|
||||
// there too, so that is the addon's own defect on a spell-less character and not a gap of
|
||||
// ours. Seating a book is what lets this test TICK, which is where the module OnUpdates live.
|
||||
// Two tabs and four spells is the addon-survey fixture's own shape, with a hunter's names.
|
||||
{
|
||||
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
|
||||
let slot = |spell_id: u32, name: &str, rank: Option<&str>| SpellSlotView {
|
||||
spell_id,
|
||||
name: name.to_string(),
|
||||
rank: rank.map(str::to_string),
|
||||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||||
..Default::default()
|
||||
};
|
||||
s.set_spellbook(SpellBookState {
|
||||
tabs: vec![
|
||||
SpellTabView {
|
||||
name: "General".into(),
|
||||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||||
offset: 0,
|
||||
num_spells: 2,
|
||||
},
|
||||
SpellTabView {
|
||||
name: "Marksmanship".into(),
|
||||
texture: Some("Interface\\Icons\\Ability_Marksmanship".into()),
|
||||
offset: 2,
|
||||
num_spells: 2,
|
||||
},
|
||||
],
|
||||
slots: vec![
|
||||
slot(6603, "Attack", None),
|
||||
slot(75, "Auto Shot", None),
|
||||
slot(2973, "Raptor Strike", Some("Rank 1")),
|
||||
slot(1978, "Serpent Sting", Some("Rank 1")),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
|
||||
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"our own FrameXML failed to load: {failures:#?}"
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// **The reported symptom.** Load Quiver into a hunter's session, drive the session start the
|
||||
/// client drives, and ask the question the macro asked.
|
||||
///
|
||||
/// The assertion is deliberately the addon's own field and not "no errors": B267 was filed as a
|
||||
/// nil field, and a future wall inside `initSlashCommandsAndModules` would take this field out
|
||||
/// again even if it left a different error behind.
|
||||
#[test]
|
||||
fn quiver_publishes_its_global_functions_for_a_hunter() {
|
||||
let root = quiver_or_skip!();
|
||||
let mut s = seat_a_hunter(&root);
|
||||
|
||||
let load_errors = load_addon_files(&s, &root, "Quiver");
|
||||
assert!(load_errors.is_empty(), "Quiver's files: {load_errors:#?}");
|
||||
|
||||
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
|
||||
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
|
||||
s.fire_event(e, Vec::new());
|
||||
}
|
||||
|
||||
// The macro the report ran: `/run Quiver.CastPetAction("Furious Howl"); …`
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(Quiver.CastPetAction)")
|
||||
.unwrap(),
|
||||
"function",
|
||||
"B267: the addon's own field is nil because its VARIABLES_LOADED handler died \
|
||||
before publishing it — errors so far: {:#?}",
|
||||
s.errors()
|
||||
);
|
||||
// The whole of `RegisterGlobalFunctions`, not just the one the report named — they publish
|
||||
// together, so any of them missing means the same handler died at the same place.
|
||||
for name in [
|
||||
"CastNoClip",
|
||||
"CastPetAction",
|
||||
"FdPrepareTrap",
|
||||
"GetSecondsRemainingReload",
|
||||
"GetSecondsRemainingShoot",
|
||||
"PredMidShot",
|
||||
"TrinketSwap1",
|
||||
"TrinketSwap2",
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(Quiver.{name})"))
|
||||
.unwrap(),
|
||||
"function",
|
||||
"Quiver.{name} was never published"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session start itself raises **nothing** — the three walls, stated as the thing they were.
|
||||
///
|
||||
/// Separate from the field assertion above because it fails differently and more usefully: this is
|
||||
/// the test that names a *new* wall the moment one appears, instead of reporting a nil field.
|
||||
#[test]
|
||||
fn quiver_survives_a_hunter_session_start_without_raising() {
|
||||
let root = quiver_or_skip!();
|
||||
let mut s = seat_a_hunter(&root);
|
||||
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
|
||||
|
||||
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
|
||||
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
|
||||
s.fire_event(e, Vec::new());
|
||||
}
|
||||
// …and then a second of frames, because Quiver's modules are OnUpdate-driven (the auto-shot
|
||||
// timer, the range indicator, the aspect tracker) and a handler that only raises once it is
|
||||
// *running* is invisible to the event burst alone.
|
||||
for _ in 0..10 {
|
||||
s.tick(0.1);
|
||||
}
|
||||
let raised = s.take_errors();
|
||||
assert!(
|
||||
raised.is_empty(),
|
||||
"Quiver raised at session start: {raised:#?}"
|
||||
);
|
||||
}
|
||||
@@ -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, Factions, Hovered, Selection, TargetUpdate};
|
||||
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||||
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
|
||||
use benilla_assets::{AssetSet, WorldAssets};
|
||||
use benilla_world::view::WorldCamera;
|
||||
@@ -172,13 +172,14 @@ const LIT_BOOST: f32 = 255.0 / 215.0;
|
||||
/// The plate quads' z keys — back→front: bar fill, then the border OVER it (the border art's
|
||||
/// rounded inner bevels cap the fill's square ends and crop the gradient's soft edges — the
|
||||
/// reference look, director-pinned from a ref crop 2026-07-07; corrects the §7 transcription's
|
||||
/// border-first order), then texts → skull; all above the floating combat text (z 0, a
|
||||
/// world-scene draw) and far below every scripted-UI packed key.
|
||||
const Z_FILL: u64 = 4;
|
||||
const Z_BORDER: u64 = 5;
|
||||
const Z_TEXT: u64 = 6;
|
||||
const Z_RAID: u64 = 7;
|
||||
const Z_SKULL: u64 = 8;
|
||||
/// border-first order), then texts → skull; all above the floating combat text and the chat
|
||||
/// bubbles (the append lane's lower bands, [`crate::ui_pass::overlay_z`]) and far below every
|
||||
/// scripted-UI packed key.
|
||||
const Z_FILL: u64 = overlay_z::VPLATE;
|
||||
const Z_BORDER: u64 = overlay_z::VPLATE + 1;
|
||||
const Z_TEXT: u64 = overlay_z::VPLATE + 2;
|
||||
const Z_RAID: u64 = overlay_z::VPLATE + 3;
|
||||
const Z_SKULL: u64 = overlay_z::VPLATE + 4;
|
||||
|
||||
/// The bar-fill palette — the byte-confirmed dwords (`0xcf60d0/e8/c8/dc`): pure
|
||||
/// red/blue/yellow/green (NOT the ring's pale player-blue). Client-space sRGB, the [`UiQuads`]
|
||||
|
||||
@@ -256,7 +256,16 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
|
||||
/// alternative was ordering a game system against an engine system, which crosses this same line
|
||||
/// *and* publishes a schedule point to do it.
|
||||
const CEILING: usize = 159;
|
||||
/// And 159 → 160: `WorldLoadProgress::gx_pending`, a PUBLISH beside the two residency terms
|
||||
/// already through this door (`colliders_pending`, `merge_pending`). The retained pass (1429)
|
||||
/// moved the static world off the entity path, so a placement that has SPAWNED still draws
|
||||
/// nothing until its region bakes — and the game reads residency for two decisions the engine
|
||||
/// does not own: when the loading cover may lift, and when the post-snap physics hold may
|
||||
/// release (decision 0737's split). Both were answering "is the world there" with a fact that
|
||||
/// had stopped meaning it (decision 1498). The alternative was the game reaching into
|
||||
/// `StaticGx` itself, which is a whole engine subsystem through the door instead of one
|
||||
/// `usize` on the residency struct that exists to be read from outside.
|
||||
const CEILING: usize = 160;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -296,6 +296,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
|
||||
)?;
|
||||
|
||||
// SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label
|
||||
// (`0x780a60`; the pointer it writes is the Button's `+0x338`, the field
|
||||
// `GetFontString`/`SetText`/`GetTextWidth` all read — wow-re `widget-api-batch-benilla.md` Q8).
|
||||
//
|
||||
// The idiom it exists for: build the label yourself so you can style and place it, then hand it
|
||||
// to the button so `SetText` and the per-state font machinery drive it.
|
||||
//
|
||||
// local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
|
||||
// btn:SetFontString(fs)
|
||||
//
|
||||
// Quiver's `Component/Select.wow.lua` builds every dropdown option row that way, and with the
|
||||
// method nil the row died on its first line — the second of B267's three walls.
|
||||
//
|
||||
// Adoption only: no region is created, and the argument keeps its own parent and its own
|
||||
// anchors (callers anchor it after the call as often as before). A non-region argument leaves
|
||||
// the button unchanged rather than raising — the reference marshals it and writes a pointer,
|
||||
// and what it does with a bad one is not byte-read.
|
||||
m.set(
|
||||
"SetFontString",
|
||||
lua.create_function(|lua, (this, fs): (Table, Value)| {
|
||||
let Value::Table(fs) = fs else { return Ok(()) };
|
||||
let Ok(rh) = super::region::region_handle_of(lua, &fs) else {
|
||||
return Ok(());
|
||||
};
|
||||
with_button(lua, &this, |bs| bs.text = Some(rh))?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// The label is a measured region, and its new role feeds the button's text extents.
|
||||
model.touch_measure(rh);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTextWidth / GetTextHeight — the Button's OWN text-extent readers (`0x782290` / `0x782390`,
|
||||
// wow-re `system/ui/scratch/item9-firing34-merge.md` l.36 and the Button method carve in
|
||||
// `widget-api-batch-benilla.md` Q8, which lists both present on Button and `GetStringWidth`
|
||||
|
||||
@@ -99,6 +99,9 @@ mod pvp;
|
||||
mod quest;
|
||||
mod quest_log;
|
||||
mod region;
|
||||
/// The 19 Region-map methods, shared by frames and regions the way the reference shares them
|
||||
/// (decision 1501) — its header is the byte-verified chain and the bug that found the split.
|
||||
mod region_map;
|
||||
mod reputation;
|
||||
mod saved;
|
||||
mod scrollframe;
|
||||
|
||||
@@ -224,6 +224,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
super::statusbar::install(lua)?;
|
||||
super::button::install(lua)?;
|
||||
super::editbox::install(lua)?;
|
||||
// AFTER every table above exists: collapse the 19 Region-map names to one shared
|
||||
// implementation each, so a method pulled off a frame works on a texture (decision 1501).
|
||||
super::region_map::install(lua)?;
|
||||
|
||||
// Shared frame metatable: __index is a Rust dispatcher over the frame method table (RF-0023),
|
||||
// checking the frame's *kind-specific* method table first. Per-kind resolution matters beyond
|
||||
|
||||
@@ -78,6 +78,21 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
// GetNumPoints() → how many anchors this frame carries. On the Region map (`0x87c9b8`), so
|
||||
// every widget answers it — the region twin shipped first and noted this side was missing;
|
||||
// collapsing the map to one implementation each (decision 1501) is what made the gap fatal
|
||||
// rather than merely absent, and this is the arm it was missing.
|
||||
m.set(
|
||||
"GetNumPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.layout_inputs
|
||||
.get(&h)
|
||||
.map_or(0, |i| i.anchors.len() as i64))
|
||||
})?,
|
||||
)?;
|
||||
// SetAllPoints([relativeTo]) — pin TOPLEFT+BOTTOMRIGHT to the target (default: the parent),
|
||||
// the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path).
|
||||
m.set(
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
//! for both halves in the reference too — already covers the stop side of a resize that does
|
||||
//! not exist yet.
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::script::Model;
|
||||
use crate::widget::FrameHandle;
|
||||
@@ -155,6 +155,93 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
"IsResizable",
|
||||
lua.create_function(|lua, this: Table| get_flag(lua, &this, Flag::Resizable))?,
|
||||
)?;
|
||||
// ── The resize-bounds quad — `Set/GetMinResize`, `Set/GetMaxResize` ────────────────────────
|
||||
//
|
||||
// Frame method table `0x878ec0`: `GetMinResize 0x775f20` · `SetMinResize 0x776020` ·
|
||||
// `GetMaxResize 0x7761a0` · `SetMaxResize 0x7762a0`. The setters' `Usage:` strings are in the
|
||||
// image verbatim — `0x8797b8 "Usage: %s:SetMinResize(minWidth, minHeight)"` and
|
||||
// `0x8797e4 "Usage: %s:SetMaxResize(maxWidth, maxHeight)"` — so a bad argument RAISES, and
|
||||
// that is what is reproduced here.
|
||||
//
|
||||
// **Who asks.** They are the second and third lines of every resizable-window kit, right after
|
||||
// `SetResizable(true)`; Quiver's `SideEffectMakeMoveable` calls `SetMinResize` on every module
|
||||
// frame it builds, and with the method nil its `OnEnable` died before the addon's
|
||||
// `VARIABLES_LOADED` handler could publish anything (bug B267's third wall).
|
||||
//
|
||||
// **Two halves are out with wow-re and fold into ONE place** ([`clamp_resize`]): whether the
|
||||
// client's "unbounded" is a `0` sentinel the getters hand back (ours answers `nil`), and
|
||||
// whether the clamp binds only the drag pump (what we do) or every size write. Neither changes
|
||||
// the surface below.
|
||||
for (name, upper) in [("MinResize", false), ("MaxResize", true)] {
|
||||
m.set(
|
||||
format!("Set{name}"),
|
||||
lua.create_function(move |lua, args: MultiValue| {
|
||||
let mut it = args.into_iter();
|
||||
let this = match it.next() {
|
||||
Some(Value::Table(t)) => t,
|
||||
_ => return Err(mlua::Error::runtime("expected a frame")),
|
||||
};
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// The pair is required. `lua_isnumber` accepts a numeric STRING too, which is why
|
||||
// this reads the values through the same coercion the rest of the surface uses
|
||||
// rather than demanding `Value::Number`.
|
||||
let pair = (it.next(), it.next());
|
||||
let num = |v: &Option<Value>| match v {
|
||||
Some(Value::Number(n)) => Some(*n as f32),
|
||||
Some(Value::Integer(i)) => Some(*i as f32),
|
||||
Some(Value::String(s)) => s.to_str().ok()?.parse::<f32>().ok(),
|
||||
_ => None,
|
||||
};
|
||||
let (Some(w), Some(hgt)) = (num(&pair.0), num(&pair.1)) else {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let who = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| "<unnamed>".to_string());
|
||||
let verb = if upper {
|
||||
"SetMaxResize"
|
||||
} else {
|
||||
"SetMinResize"
|
||||
};
|
||||
let (a, b) = if upper {
|
||||
("maxWidth", "maxHeight")
|
||||
} else {
|
||||
("minWidth", "minHeight")
|
||||
};
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"Usage: {who}:{verb}({a}, {b})"
|
||||
)));
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if let Some(f) = model.arena.frame_mut(h) {
|
||||
if upper {
|
||||
f.max_resize = Some((w, hgt));
|
||||
} else {
|
||||
f.min_resize = Some((w, hgt));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
format!("Get{name}"),
|
||||
lua.create_function(move |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let bound =
|
||||
model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| if upper { f.max_resize } else { f.min_resize });
|
||||
Ok(match bound {
|
||||
Some((w, hgt)) => (Value::Number(f64::from(w)), Value::Number(f64::from(hgt))),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// SetUserPlaced(flag) / IsUserPlaced() — bit 0x1000 (`0x776a50`/`0x776b40`), the client's "the
|
||||
// user placed this frame; persist its position across sessions" bit, and the one setter of the
|
||||
// three that is GUARDED: `776adb: test ah,0x3` refuses unless the frame is movable OR
|
||||
@@ -381,15 +468,31 @@ pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
|
||||
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
|
||||
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
|
||||
/// crosses no boundary at all, so a move parked behind that return would only advance when the
|
||||
/// cursor happened to leave the frame).
|
||||
/// Apply a frame's `SetMinResize`/`SetMaxResize` bounds to a proposed size.
|
||||
///
|
||||
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
|
||||
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
|
||||
/// writing to a dead handle.
|
||||
/// **The one place the two open halves of the resize-bounds RE fold into.** Today: an unset bound
|
||||
/// is unbounded, the floor with no `SetMinResize` is the `1.0` this function inherited from the
|
||||
/// drag pump it was extracted from, and the *only* caller is [`advance_size`] — so a programmatic
|
||||
/// `SetWidth` past a bound is currently not clamped. wow-re is deriving whether the client clamps
|
||||
/// there too, and what its no-bound floor really is; both answers land here and nowhere else.
|
||||
///
|
||||
/// A `min` above a `max` resolves in `min`'s favour (the clamp order below), which is the only
|
||||
/// self-consistent reading of a contradiction and is also out with wow-re.
|
||||
fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) {
|
||||
let (min, max) = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.map_or((None, None), |f| (f.min_resize, f.max_resize));
|
||||
let mut w = w;
|
||||
let mut hgt = hgt;
|
||||
if let Some((mw, mh)) = max {
|
||||
w = w.min(mw);
|
||||
hgt = hgt.min(mh);
|
||||
}
|
||||
let (fw, fh) = min.unwrap_or((1.0, 1.0));
|
||||
(w.max(fw), hgt.max(fh))
|
||||
}
|
||||
|
||||
/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others.
|
||||
///
|
||||
/// The mirror of [`advance_move`] — same sample-and-recentre, same dead-frame bail, same
|
||||
@@ -409,9 +512,6 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
}
|
||||
let inv = 1.0 / eff_scale(model, sz.frame);
|
||||
let (dx, dy) = (dx * inv, dy * inv);
|
||||
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
|
||||
return;
|
||||
};
|
||||
// Width grows when the RIGHT grip goes right, or the LEFT grip goes left. Height likewise with
|
||||
// y-up: the TOP grip going up grows it, the BOTTOM grip going down grows it.
|
||||
let dw = if sz.right {
|
||||
@@ -428,9 +528,21 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (w0, h0) = (input.width, input.height);
|
||||
input.width = (input.width + dw).max(1.0);
|
||||
input.height = (input.height + dh).max(1.0);
|
||||
let Some((w0, h0)) = model
|
||||
.layout_inputs
|
||||
.get(&sz.frame)
|
||||
.map(|i| (i.width, i.height))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The frame's own `SetMinResize`/`SetMaxResize` bounds — read before the mutable borrow, and
|
||||
// the only place they bind today (see `clamp_resize`).
|
||||
let (new_w, new_h) = clamp_resize(model, sz.frame, w0 + dw, h0 + dh);
|
||||
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
|
||||
return;
|
||||
};
|
||||
input.width = new_w;
|
||||
input.height = new_h;
|
||||
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
|
||||
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
|
||||
// or the resize would push the right edge instead. Only the gripped axis shifts.
|
||||
@@ -460,6 +572,15 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
model.sizing = Some(FrameSizing { sample: pos, ..sz });
|
||||
}
|
||||
|
||||
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
|
||||
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
|
||||
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
|
||||
/// crosses no boundary at all, so a move parked behind that return would only advance when the
|
||||
/// cursor happened to leave the frame).
|
||||
///
|
||||
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
|
||||
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
|
||||
/// writing to a dead handle.
|
||||
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
|
||||
let Some(mv) = model.moving else { return };
|
||||
if model.arena.frame(mv.frame).is_none() {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! **The Region method map, reached by frames and regions alike** — the 19 names of the client's
|
||||
//! `0x87c9b8` table, each ONE callable that works on a Frame, a Texture or a FontString.
|
||||
//!
|
||||
//! ## The fact this exists for (byte-verified in `WoW.exe`, decision 1501)
|
||||
//!
|
||||
//! 1.12.1's widget inheritance is not a Lua metatable chain: every class owns a flat
|
||||
//! `{name, lua_CFunction}` `.data` table, and its lookup probes that table and, on a miss,
|
||||
//! **tail-calls exactly one base class's lookup** (wow-re
|
||||
//! `system/ui/scratch/widget-api-batch-benilla.md`):
|
||||
//!
|
||||
//! ```text
|
||||
//! Region 0x87c9b8 (19) lookup 0x7a2ea0 → TERMINAL (root)
|
||||
//! ├─ Frame 0x878ec0 (68) lookup 0x778590 → 0x7a2ea0
|
||||
//! ├─ Texture 0x87c128 (22) lookup 0x79c620 → 0x7a2ea0
|
||||
//! └─ FontString 0x87c1d8 (32) lookup 0x79ee20 → 0x7a2ea0
|
||||
//! ```
|
||||
//!
|
||||
//! Parsing the two tables out of the image directly, **`Frame ∩ Region = ∅`**: not one of the 19
|
||||
//! is re-registered by Frame, so every Frame-derived class reaches the *same* function a Texture
|
||||
//! does. `WorldFrame.GetHeight` **is** `0x7a2030`, byte-for-byte the function
|
||||
//! `someTexture:GetHeight()` resolves to.
|
||||
//!
|
||||
//! That is why this is not a curiosity. The idiom
|
||||
//!
|
||||
//! ```lua
|
||||
//! local _Height = WorldFrame.GetHeight -- pull the method off ANY widget, once
|
||||
//! …
|
||||
//! local h = _Height(someTexture) -- and apply it to ANY other
|
||||
//! ```
|
||||
//!
|
||||
//! is ordinary, correct 1.12 Lua. Quiver's `Api/Index.wow.lua` is built on it
|
||||
//! (`_Height = WorldFrame.GetHeight`, `_Width = WorldFrame.GetWidth`), and benilla raised
|
||||
//! `stale or invalid frame handle` on it, from inside the addon's `VARIABLES_LOADED` handler,
|
||||
//! **before** the handler's last line got to publish `Quiver.CastPetAction` — which is the whole
|
||||
//! of bug B267. One split method surface, one nil field, one dead addon.
|
||||
//!
|
||||
//! ## Why it is a bridge rather than a merge
|
||||
//!
|
||||
//! Our two implementations of each name are not redundant: a frame's size reads `layout_inputs`
|
||||
//! and divides its edges by `GetEffectiveScale`, a region's reads `region_data` and does not; a
|
||||
//! frame's `GetParent` can answer nil, a region's never can; a region's `GetPoint` resolves a
|
||||
//! `relativeTo` that may be a sibling *region*. Those differences are correct, and they are the
|
||||
//! same shape the reference has: `Region:GetHeight 0x7a2030` reads the receiver's layout
|
||||
//! sub-object and calls `[vtable+0x20]`, which `CSimpleFontString` **overrides**. One name, one
|
||||
//! entry point, per-kind behaviour behind it.
|
||||
//!
|
||||
//! So each of the 19 becomes one function that resolves the receiver *first* and then hands the
|
||||
//! whole argument list to the arm that owns that kind — and that single function is written into
|
||||
//! every method table the reference's chain would reach: the frame table, the region table, the
|
||||
//! Texture and FontString leaf tables, and the title region's narrower copy. Identity holds the
|
||||
//! way the binary's does — `WorldFrame.GetHeight == someTexture.GetHeight` is true here too.
|
||||
//!
|
||||
//! **Exactly the 19, and no more.** `Show`/`Hide`/`IsShown`/`IsVisible`/`SetAlpha`/`GetAlpha` look
|
||||
//! like they belong and do not: Frame and Texture each register their *own*, at different
|
||||
//! addresses (`texture-fontstring-method-split.md` §3), so `WorldFrame.Show(someTexture)` fails on
|
||||
//! the real client and must keep failing here. `SetSize` is in neither table — it is 1.12-absent
|
||||
//! and ours, so it stays where it is. The map is the unit.
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::decode_id;
|
||||
use super::{
|
||||
Model, REGION_MAP_METHODS, REG_FONTSTRING_METHODS, REG_FRAME_METHODS, REG_REGION_METHODS,
|
||||
REG_TEXTURE_METHODS, REG_TITLE_METHODS,
|
||||
};
|
||||
|
||||
/// Which side of the object model a wrapper's `T[0]` id names. Ids come from one counter
|
||||
/// ([`Model::next_id`]), so a region id can never be mistaken for a frame's.
|
||||
enum Side {
|
||||
Frame,
|
||||
Region,
|
||||
}
|
||||
|
||||
/// `Err` when the table is not a widget wrapper at all (that error names the real problem and is
|
||||
/// worth keeping); `Ok(None)` when it *is* one whose widget no longer exists.
|
||||
fn side_of(lua: &Lua, this: &Table) -> mlua::Result<Option<Side>> {
|
||||
let id = decode_id(this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.id_to_frame.contains_key(&id) {
|
||||
Some(Side::Frame)
|
||||
} else if model.id_to_region.contains_key(&id) {
|
||||
Some(Side::Region)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace each of the 19 Region-map entries in every table the chain reaches with one shared
|
||||
/// function that dispatches on the receiver.
|
||||
///
|
||||
/// Runs **after** `region::install` (which is what builds the region, leaf and title tables) and
|
||||
/// after `install_frame_methods`; every table it names is already populated by then.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let frame: Table = lua.named_registry_value(REG_FRAME_METHODS)?;
|
||||
let region: Table = lua.named_registry_value(REG_REGION_METHODS)?;
|
||||
let leaves: Vec<Table> = [
|
||||
REG_TEXTURE_METHODS,
|
||||
REG_FONTSTRING_METHODS,
|
||||
REG_TITLE_METHODS,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|k| lua.named_registry_value::<Table>(k))
|
||||
.collect::<mlua::Result<_>>()?;
|
||||
|
||||
for name in REGION_MAP_METHODS {
|
||||
// Both arms must already exist. A missing one is not a thing to paper over with a
|
||||
// one-sided shared function: the reference gives every widget all 19, so a gap on either
|
||||
// side is a hole in the surface and says so here rather than at some addon's call site.
|
||||
// (`GetNumPoints` was exactly that hole on the frame side until this module went in.)
|
||||
let on_frame: Function = frame.get(name).map_err(|_| {
|
||||
mlua::Error::runtime(format!("Region map: the FRAME table has no {name}"))
|
||||
})?;
|
||||
let on_region: Function = region.get(name).map_err(|_| {
|
||||
mlua::Error::runtime(format!("Region map: the REGION table has no {name}"))
|
||||
})?;
|
||||
let shared = lua.create_function(move |lua, args: MultiValue| {
|
||||
// The receiver is argument 1 on every one of the 19 — a method call always passes it,
|
||||
// and a pulled-off `Api._Height(x)` call passes it as the only argument.
|
||||
let Some(Value::Table(this)) = args.iter().next().cloned() else {
|
||||
return Err(mlua::Error::runtime(
|
||||
"expected a frame or region as the first argument",
|
||||
));
|
||||
};
|
||||
match side_of(lua, &this)? {
|
||||
Some(Side::Frame) => on_frame.call::<MultiValue>(args),
|
||||
Some(Side::Region) => on_region.call::<MultiValue>(args),
|
||||
// A wrapper whose widget is gone. Deliberately ONE message for both sides — at
|
||||
// this point the receiver's kind is exactly what could not be established, and
|
||||
// each side's old wording claimed it.
|
||||
None => Err(mlua::Error::runtime("stale or invalid widget handle")),
|
||||
}
|
||||
})?;
|
||||
frame.set(name, shared.clone())?;
|
||||
region.set(name, shared.clone())?;
|
||||
for leaf in &leaves {
|
||||
leaf.set(name, shared.clone())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mod minimap;
|
||||
mod movable;
|
||||
mod object_model;
|
||||
mod reference_surface;
|
||||
mod region_map;
|
||||
mod regions;
|
||||
mod scrollframe;
|
||||
mod setparent;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! **The Region map is ONE function per name, reached by frames and regions alike** — the
|
||||
//! [`crate::script::region_map`] law, and the regression guard for bug B267.
|
||||
//!
|
||||
//! Its header carries the byte evidence (`Frame ∩ Region = ∅`, parsed out of the two `.data`
|
||||
//! method tables). What is pinned here is the two things a reader of the code could not otherwise
|
||||
//! check: that the sharing is real *by identity*, and that it stops exactly at the 19.
|
||||
//!
|
||||
//! The reported symptom this file exists for: Quiver's `Api/Index.wow.lua` opens
|
||||
//! `_Height = WorldFrame.GetHeight`, applies it to a Texture and a FontString, and benilla raised
|
||||
//! `stale or invalid frame handle` — inside the addon's `VARIABLES_LOADED` handler, four lines
|
||||
//! before it published `Quiver.CastPetAction`. The addon's own field was nil at macro time because
|
||||
//! the handler never finished, which is what got filed.
|
||||
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A VM with one frame, one texture and one fontstring, each reachable by a global name.
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
Host = CreateFrame("Frame", "Host")
|
||||
Host:SetWidth(200) Host:SetHeight(120)
|
||||
Host:SetPoint("CENTER", nil, "CENTER", 0, 0)
|
||||
Tex = Host:CreateTexture("HostTex", "ARTWORK")
|
||||
Tex:SetWidth(64) Tex:SetHeight(32)
|
||||
Tex:SetPoint("TOPLEFT", Host, "TOPLEFT", 0, 0)
|
||||
Str = Host:CreateFontString("HostStr", "OVERLAY")
|
||||
Str:SetWidth(50) Str:SetHeight(10)
|
||||
Str:SetPoint("BOTTOMRIGHT", Host, "BOTTOMRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Every one of the 19 is the **same Lua value** on a Frame, a Texture and a FontString.
|
||||
///
|
||||
/// Not a stand-in for the behaviour tests below: identity is what makes the pulled-off-method
|
||||
/// idiom work *for a method the test author never thought of*. In the client it holds because
|
||||
/// Frame's table does not carry any of the 19 and its lookup tail-calls Region's; here it holds
|
||||
/// because one function is written into every table that chain reaches.
|
||||
#[test]
|
||||
fn the_region_map_is_one_function_on_every_widget() {
|
||||
let s = vm();
|
||||
for m in crate::script::REGION_MAP_METHODS {
|
||||
let same: bool = s
|
||||
.eval(&format!(
|
||||
"return Host.{m} == Tex.{m} and Tex.{m} == Str.{m} and Host.{m} ~= nil"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
same,
|
||||
"{m} must be ONE function on frame, texture and string"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The bug, reduced to its four lines.** A method pulled off a frame and applied to a region.
|
||||
#[test]
|
||||
fn a_method_pulled_off_a_frame_works_on_a_texture_and_a_fontstring() {
|
||||
let s = vm();
|
||||
// Quiver's own shape: `Api._Height = WorldFrame.GetHeight`, applied to `r.Icon` (a Texture)
|
||||
// and `r.Label` (a FontString), from a table built at file scope.
|
||||
let (th, sh): (f32, f32) = s
|
||||
.eval(
|
||||
r#"
|
||||
local _Height = Host.GetHeight
|
||||
return _Height(Tex), _Height(Str)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((th, sh), (32.0, 10.0));
|
||||
|
||||
let (tw, sw): (f32, f32) = s
|
||||
.eval(
|
||||
r#"
|
||||
local _Width = Host.GetWidth
|
||||
return _Width(Tex), _Width(Str)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((tw, sw), (64.0, 50.0));
|
||||
|
||||
// …and the setter half, which is the same split and would have been the next wall.
|
||||
s.run("local _SetW = Host.SetWidth _SetW(Tex, 99)").unwrap();
|
||||
assert_eq!(s.eval::<f32>("return Tex:GetWidth()").unwrap(), 99.0);
|
||||
}
|
||||
|
||||
/// The other direction — a method pulled off a **region** and applied to a **frame**. Same one
|
||||
/// function, so this cannot be made to work by special-casing the reported direction.
|
||||
#[test]
|
||||
fn a_method_pulled_off_a_texture_works_on_a_frame() {
|
||||
let s = vm();
|
||||
let h: f32 = s.eval("local g = Tex.GetHeight return g(Host)").unwrap();
|
||||
assert_eq!(h, 120.0);
|
||||
let name: String = s.eval("local n = Str.GetName return n(Host)").unwrap();
|
||||
assert_eq!(name, "Host");
|
||||
let kind: String = s
|
||||
.eval("local t = Tex.GetObjectType return t(Host)")
|
||||
.unwrap();
|
||||
assert_eq!(kind, "Frame");
|
||||
}
|
||||
|
||||
/// Each receiver keeps its OWN behaviour behind the shared name — the per-kind arms are not
|
||||
/// collapsed into one. `GetObjectType` is the cleanest witness (three different answers), and
|
||||
/// `GetParent` is the one whose two arms genuinely differ (a region's owner is never nil).
|
||||
#[test]
|
||||
fn one_name_still_dispatches_per_kind() {
|
||||
let s = vm();
|
||||
let (f, t, g): (String, String, String) = s
|
||||
.eval("return Host:GetObjectType(), Tex:GetObjectType(), Str:GetObjectType()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(f.as_str(), t.as_str(), g.as_str()),
|
||||
("Frame", "Texture", "FontString")
|
||||
);
|
||||
// The region arm answers its OWNER; the frame arm answers nil for a parentless top frame.
|
||||
let owner: String = s.eval("return Tex:GetParent():GetName()").unwrap();
|
||||
assert_eq!(owner, "Host");
|
||||
assert!(s.eval::<bool>("return Host:GetParent() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `GetNumPoints` — on the Region map, so **every** widget answers it. The frame arm did not
|
||||
/// exist until the map was collapsed to one implementation; the region arm shipped alone.
|
||||
#[test]
|
||||
fn get_num_points_answers_on_a_frame_too() {
|
||||
let s = vm();
|
||||
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 1);
|
||||
assert_eq!(s.eval::<i64>("return Tex:GetNumPoints()").unwrap(), 1);
|
||||
s.run("Host:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 0, 0)")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 2);
|
||||
s.run("Host:ClearAllPoints()").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// **The carve's other edge, and the control that keeps it honest.** These six look like they
|
||||
/// belong to the Region map and do not: Frame, Texture and FontString each register their *own*
|
||||
/// at different addresses (`texture-fontstring-method-split.md` §3 — Texture `SetAlpha 0x79b580`
|
||||
/// vs FontString `0x79cb70`, and Frame's own `0x79b...` twin). So `WorldFrame.Show(someTexture)`
|
||||
/// fails on the real client, and hoisting them here to make more addon code work would be the
|
||||
/// superset decision 1189 had to take back out.
|
||||
#[test]
|
||||
fn the_six_look_alikes_are_not_shared() {
|
||||
let s = vm();
|
||||
for m in [
|
||||
"Show",
|
||||
"Hide",
|
||||
"IsShown",
|
||||
"IsVisible",
|
||||
"SetAlpha",
|
||||
"GetAlpha",
|
||||
] {
|
||||
let shared: bool = s.eval(&format!("return Host.{m} == Tex.{m}")).unwrap();
|
||||
assert!(
|
||||
!shared,
|
||||
"{m} is registered per class in 1.12 and must NOT be hoisted onto the Region map"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A receiver that is not a widget at all raises, and says *why* — the bridge resolves the
|
||||
/// receiver before either arm runs, so this error must not be flattened into a generic one.
|
||||
#[test]
|
||||
fn a_non_widget_receiver_raises_and_names_the_reason() {
|
||||
let s = vm();
|
||||
let err = s
|
||||
.eval::<f32>("return Host.GetHeight({})")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("T[0] identity"), "unexpected error: {err}");
|
||||
}
|
||||
@@ -283,6 +283,21 @@ pub struct Frame {
|
||||
/// `StartSizing` half of the same family. Default false. benilla stores and reports the flag;
|
||||
/// no resize *drag* is built (`StopMovingOrSizing` already covers the stop side of both).
|
||||
pub resizable: bool,
|
||||
/// `SetMinResize` / `SetMaxResize` — the interactive-resize bounds (`0x776020` / `0x7762a0`,
|
||||
/// with `GetMinResize 0x775f20` / `GetMaxResize 0x7761a0`; all four on the Frame method table
|
||||
/// `0x878ec0`). `None` = that bound was never set, i.e. unbounded on both axes.
|
||||
///
|
||||
/// A window kit's opening lines are `SetResizable(true)` + these two, and without them a
|
||||
/// `StartSizing` drag has nothing to stop it — Quiver's `SideEffectMakeMoveable` calls
|
||||
/// `SetMinResize` on every module frame it builds and died on the nil method (part of B267).
|
||||
///
|
||||
/// **The sentinel and the enforcement points are out with wow-re** (`0` may be the client's
|
||||
/// "unbounded", and whether the clamp binds only the drag pump or every size write is the
|
||||
/// load-bearing half). Until that lands, `None` is unbounded and only the drag pump clamps —
|
||||
/// `script::object::movable::clamp_resize` is the single place either answer folds into.
|
||||
pub min_resize: Option<(f32, f32)>,
|
||||
/// The upper twin of [`Frame::min_resize`] — see its doc.
|
||||
pub max_resize: Option<(f32, f32)>,
|
||||
/// `SetUserPlaced` — the client's "the user placed this frame; persist its position across
|
||||
/// sessions" bit. Default false. Stored and readable (`IsUserPlaced`); **nothing consumes it
|
||||
/// yet** — persisting a frame's position belongs with the layout cache, not with the drag that
|
||||
@@ -578,6 +593,10 @@ impl WidgetArena {
|
||||
// it, at the level it was created with.
|
||||
movable: false,
|
||||
resizable: false,
|
||||
// No frame is born with resize bounds: both are opt-in, and a frame nobody bounded
|
||||
// resizes freely (see the field docs — the client's own sentinel is out with wow-re).
|
||||
min_resize: None,
|
||||
max_resize: None,
|
||||
user_placed: false,
|
||||
toplevel: false,
|
||||
scale,
|
||||
|
||||
@@ -34,6 +34,9 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
gx.declined_logged = d;
|
||||
}
|
||||
}
|
||||
// The reveal gate's "publish what you have" (see [`StaticGx::flush_now`]): consumed here, so
|
||||
// one request bakes one flush's worth of dirty regions and the windows resume next frame.
|
||||
let now = std::mem::take(&mut gx.flush_now);
|
||||
let StaticGx {
|
||||
cells,
|
||||
wmos,
|
||||
@@ -42,7 +45,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
..
|
||||
} = &mut *gx;
|
||||
for (&cell, state) in cells.iter_mut() {
|
||||
if !bake_due(state, world.cells.contains_key(&cell), frame) {
|
||||
if !bake_due(state, world.cells.contains_key(&cell), frame, now) {
|
||||
continue;
|
||||
}
|
||||
state.dirty = false;
|
||||
@@ -68,7 +71,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
world.cells.insert(cell, std::sync::Arc::new(baked));
|
||||
}
|
||||
for (&instance, state) in wmos.iter_mut() {
|
||||
if !bake_due(state, world.wmos.contains_key(&instance), frame) {
|
||||
if !bake_due(state, world.wmos.contains_key(&instance), frame, now) {
|
||||
continue;
|
||||
}
|
||||
state.dirty = false;
|
||||
@@ -99,7 +102,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
// grain — the sort keeps runs set-homogeneous, and the published draw carries the
|
||||
// region's set list beside the per-set bounds `bake_cell` accumulated into `groups`.
|
||||
for (&instance, state) in props.iter_mut() {
|
||||
if !bake_due(state, world.props.contains_key(&instance), frame) {
|
||||
if !bake_due(state, world.props.contains_key(&instance), frame, now) {
|
||||
continue;
|
||||
}
|
||||
state.dirty = false;
|
||||
@@ -126,15 +129,21 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this region's bake due? A FIRST bake waits only the short window (fast appearance); a
|
||||
/// Is this region's bake due? `now` is the reveal gate's request ([`StaticGx::flush_now`]) and
|
||||
/// overrides every window — the arrival burst it batches is known to be over.
|
||||
///
|
||||
/// Otherwise: a FIRST bake waits only the short window (fast appearance); a
|
||||
/// re-bake of a published region waits for the LONG quiet window (B3, decision 1432 — the
|
||||
/// admission trickle arrives spaced wider than the short window, so B2 re-baked cells once
|
||||
/// per arrival for minutes; 1431's cost map priced it), with the age cap so a never-quiet
|
||||
/// region still consolidates.
|
||||
fn bake_due(state: &super::GxCell, published: bool, frame: u32) -> bool {
|
||||
fn bake_due(state: &super::GxCell, published: bool, frame: u32, now: bool) -> bool {
|
||||
if !state.dirty {
|
||||
return false;
|
||||
}
|
||||
if now {
|
||||
return true;
|
||||
}
|
||||
let window = if published {
|
||||
REBAKE_FRAMES
|
||||
} else {
|
||||
@@ -349,27 +358,49 @@ mod tests {
|
||||
#[test]
|
||||
fn the_bake_waits_short_first_and_long_after() {
|
||||
let mut state = super::super::GxCell::default();
|
||||
assert!(!bake_due(&state, false, 100), "clean cells are never due");
|
||||
assert!(
|
||||
!bake_due(&state, false, 100, false),
|
||||
"clean cells are never due"
|
||||
);
|
||||
state.dirty = true;
|
||||
state.dirty_since = 0;
|
||||
state.last_change = 0;
|
||||
assert!(!bake_due(&state, false, IDLE_FRAMES - 1));
|
||||
assert!(!bake_due(&state, false, IDLE_FRAMES - 1, false));
|
||||
assert!(
|
||||
bake_due(&state, false, IDLE_FRAMES),
|
||||
bake_due(&state, false, IDLE_FRAMES, false),
|
||||
"first bake: short window"
|
||||
);
|
||||
assert!(
|
||||
!bake_due(&state, true, IDLE_FRAMES),
|
||||
!bake_due(&state, true, IDLE_FRAMES, false),
|
||||
"published: short is not enough"
|
||||
);
|
||||
assert!(!bake_due(&state, true, REBAKE_FRAMES - 1));
|
||||
assert!(!bake_due(&state, true, REBAKE_FRAMES - 1, false));
|
||||
assert!(
|
||||
bake_due(&state, true, REBAKE_FRAMES),
|
||||
bake_due(&state, true, REBAKE_FRAMES, false),
|
||||
"published: long window"
|
||||
);
|
||||
// The reveal gate's request overrides every window: a load that has finished arriving
|
||||
// publishes what it holds instead of waiting out the quiet timer (decision 1498).
|
||||
state.last_change = IDLE_FRAMES;
|
||||
assert!(
|
||||
!bake_due(&state, false, IDLE_FRAMES, false),
|
||||
"still quiet-timing"
|
||||
);
|
||||
assert!(
|
||||
bake_due(&state, false, IDLE_FRAMES, true),
|
||||
"flush_now: due now"
|
||||
);
|
||||
assert!(bake_due(&state, true, IDLE_FRAMES, true), "…published too");
|
||||
state.dirty = false;
|
||||
assert!(
|
||||
!bake_due(&state, false, IDLE_FRAMES, true),
|
||||
"flush_now still bakes nothing that is not dirty"
|
||||
);
|
||||
state.dirty = true;
|
||||
state.last_change = 0;
|
||||
// A cell that keeps changing (never quiet) still consolidates at the age cap.
|
||||
state.last_change = MAX_DIRTY_FRAMES;
|
||||
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES));
|
||||
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES, false));
|
||||
}
|
||||
|
||||
/// A quiet cell bakes: one recentred mesh whose draws sort by (bucket, texture), each
|
||||
|
||||
@@ -55,10 +55,13 @@
|
||||
//! the aniso-8 samplers, and the two-sided normal law (the entity shader's `select` UNDOES a
|
||||
//! bevy negation this pipeline never had — copying it flipped every back-facing canopy card).
|
||||
//!
|
||||
//! **Known B1 gaps, deliberate** (B2+ owns them): no `presentable()` coupling — an unbaked
|
||||
//! cell can show a hole for a beat on a cold arrival where the merge publishes
|
||||
//! `merge_pending` into the reveal cover (1419); dev-instrument identity (inspector/WOW_PICK
|
||||
//! name nothing — the weld/merge trade, one side table if missed).
|
||||
//! **Known B1 gaps** (B2+ owns them): dev-instrument identity (inspector/WOW_PICK name nothing
|
||||
//! — the weld/merge trade, one side table if missed). The other one B1 left open — no
|
||||
//! `presentable()` coupling, so an unbaked region shows a hole for a beat on a cold arrival
|
||||
//! where the merge publishes `merge_pending` into the reveal cover (1419) — is **CLOSED by
|
||||
//! 1498**: [`StaticGx::undrawn_regions`] is that coupling, and [`StaticGx::flush_now`] is why
|
||||
//! it costs the load no extra frames. It was not a beat: it was the director's report of
|
||||
//! teleporting into a Stormwind with no Stormwind in it.
|
||||
//!
|
||||
//! **B3 (decision 1432): the churn 1431 priced is paid.** Texture arrays are ONE SHARED POOL
|
||||
//! across every cell and region (dedup by asset id; `pool.rs` owns the design note), layer
|
||||
@@ -438,6 +441,9 @@ pub struct StaticGx {
|
||||
/// Lifetime exile-event counters (spawns, re-admits, gone-despawns) — the census line's
|
||||
/// churn instrument.
|
||||
fade_events: [u32; 3],
|
||||
/// [`StaticGx::flush_now`]'s request: the next flush ignores every quiet window. Consumed
|
||||
/// (cleared) by that flush.
|
||||
flush_now: bool,
|
||||
}
|
||||
|
||||
/// The divert hook's per-call facts (everything is in scope at `assemble.rs`'s gate).
|
||||
@@ -544,6 +550,81 @@ pub struct GxWmoBatch {
|
||||
}
|
||||
|
||||
impl StaticGx {
|
||||
/// **Collected geometry that draws nothing yet** — regions holding items whose bake has not
|
||||
/// published (a first bake still inside its quiet window, [`IDLE_FRAMES`]).
|
||||
///
|
||||
/// A retained region is a *hole in the frame* between the divert and its first publish: the
|
||||
/// entity path no longer draws that geometry (slice 2 moved WMO group geometry here
|
||||
/// wholesale), so a spawned building whose region has not baked is a building that is not on
|
||||
/// screen. The reveal gate ([`crate::terrain_stream::WorldLoadProgress`]) reads this, which
|
||||
/// is why it is published as a fact rather than left an internal of the bake.
|
||||
///
|
||||
/// A *re-bake* of an already-published region is deliberately NOT counted: that region keeps
|
||||
/// drawing its previous bake meanwhile, so it is content on screen, not a hole.
|
||||
pub(crate) fn undrawn_regions(&self) -> usize {
|
||||
let world = &self.world;
|
||||
let cells = self
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.cells.contains_key(*k))
|
||||
.count();
|
||||
let wmos = self
|
||||
.wmos
|
||||
.iter()
|
||||
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.wmos.contains_key(*k))
|
||||
.count();
|
||||
let props = self
|
||||
.props
|
||||
.iter()
|
||||
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.props.contains_key(*k))
|
||||
.count();
|
||||
cells + wmos + props
|
||||
}
|
||||
|
||||
/// The WMO lane's headline numbers for an instrument: regions **collected**, regions
|
||||
/// **published** (baked, so drawable at all), and regions the cull **selected** this frame.
|
||||
/// Collected-but-unpublished is the reveal hole [`Self::undrawn_regions`] counts; published
|
||||
/// minus selected is ordinary culling.
|
||||
pub fn wmo_census(&self) -> (usize, usize, usize) {
|
||||
(
|
||||
self.wmos.len(),
|
||||
self.world.wmos.len(),
|
||||
self.world.visible_wmos.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What this frame's scene walk actually **selected to draw** — doodad/prop draw entries,
|
||||
/// WMO regions selected, and the admitted group bits summed across them. The companion to
|
||||
/// [`Self::wmo_census`]: that one says what geometry EXISTS and is drawable, this one says
|
||||
/// how much of it the cull let through. A frame where everything is published and almost
|
||||
/// nothing is selected is a visibility fault, not a residency one — the distinction the
|
||||
/// reveal audit could not make.
|
||||
pub fn draw_census(&self) -> (usize, usize, usize) {
|
||||
let groups = self
|
||||
.world
|
||||
.visible_wmos
|
||||
.iter()
|
||||
.map(|(_, bits)| bits.iter().filter(|b| **b).count())
|
||||
.sum();
|
||||
(
|
||||
self.world.visible.len(),
|
||||
self.world.visible_wmos.len(),
|
||||
groups,
|
||||
)
|
||||
}
|
||||
|
||||
/// **Bake every dirty region on the next flush, quiet window or not** — what the reveal gate
|
||||
/// asks for once nothing more is arriving.
|
||||
///
|
||||
/// The quiet windows ([`IDLE_FRAMES`]/[`REBAKE_FRAMES`]) exist to batch an *arrival trickle*
|
||||
/// during play. The end of a load is not a trickle: the streamer knows the burst is over
|
||||
/// (every wanted tile spawned, every focus placement up, the collider queue quiet), and
|
||||
/// waiting another 15 frames there is 15 frames of a city that has spawned and cannot draw.
|
||||
/// So the end of the burst says "publish what you have" instead of arming another timer.
|
||||
pub(crate) fn flush_now(&mut self) {
|
||||
self.flush_now = true;
|
||||
}
|
||||
|
||||
/// Take one eligible batch instead of spawning it (or merging it). Returns `false` — and
|
||||
/// tallies why — when the batch's facts fall outside the prototype's lane; the caller then
|
||||
/// falls through to the ordinary path, exactly like a refused merge divert.
|
||||
@@ -882,6 +963,27 @@ mod tests {
|
||||
use super::testkit::{batch, tri};
|
||||
use super::*;
|
||||
|
||||
/// A diverted batch is collected geometry that draws NOTHING until its region bakes — the
|
||||
/// reveal hole [`StaticGx::undrawn_regions`] exists to publish, and the fact the reveal gate
|
||||
/// keys on (decision 1498). Publishing the region clears it; a re-bake of a published region
|
||||
/// is not a hole, because the previous bake is still on screen.
|
||||
#[test]
|
||||
fn a_collected_region_is_undrawn_until_it_publishes() {
|
||||
let mut gx = StaticGx::default();
|
||||
let g = tri([0.0; 3]);
|
||||
assert_eq!(
|
||||
gx.undrawn_regions(),
|
||||
0,
|
||||
"nothing collected, nothing pending"
|
||||
);
|
||||
assert!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
|
||||
assert_eq!(gx.undrawn_regions(), 1, "diverted, unbaked — a hole");
|
||||
// What the bake does when it publishes a region: clears `dirty`.
|
||||
let cell = *gx.cells.keys().next().unwrap();
|
||||
gx.cells.get_mut(&cell).unwrap().dirty = false;
|
||||
assert_eq!(gx.undrawn_regions(), 0, "baked — on screen");
|
||||
}
|
||||
|
||||
/// The collector refuses exactly the recorded exclusion families — and says so in the
|
||||
/// census — while an eligible batch diverts (1429's no-silent-caps note).
|
||||
#[test]
|
||||
|
||||
@@ -99,6 +99,12 @@ pub struct TerrainStreamer {
|
||||
/// WMO-only map that one building is the whole world, so there is nothing else to stream.
|
||||
/// Cleared on map change, which also releases the placement.
|
||||
global_wmo: bool,
|
||||
/// [`ViewFocus::paced`] as of last frame — so the residency latch can re-arm on the EDGE into
|
||||
/// a load rather than on the level. A viewer with no avatar publishes `paced: false` forever
|
||||
/// (`ViewFocus::camera`), and re-arming on the level would leave that viewer walking its focus
|
||||
/// neighbourhood's every placement every frame for the rest of the run — the settled-work scan
|
||||
/// the latch exists to stop.
|
||||
was_paced: bool,
|
||||
}
|
||||
|
||||
/// The placement id the map-global WMO is registered under. A WMO-only map authors **no** ADT tiles
|
||||
@@ -234,6 +240,16 @@ pub struct WorldLoadProgress {
|
||||
pub merge_pending: usize,
|
||||
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
|
||||
pub placements_pending: usize,
|
||||
/// Retained-pass regions collected but not yet drawable (`StaticGx::undrawn_regions`) —
|
||||
/// geometry that has *spawned* and still draws nothing.
|
||||
///
|
||||
/// Since slice 2 (1429/1430) a WMO's group geometry leaves the entity path for the retained
|
||||
/// pass, so "the placement spawned" stopped meaning "the building is on screen": between the
|
||||
/// divert and its first bake the region is a hole. The reveal used to be gated on the spawn
|
||||
/// alone and therefore lifted up to `IDLE_FRAMES` before the buildings could draw — a city of
|
||||
/// bare ground for a quarter second. Same conservative semantics as the collider and merge
|
||||
/// backlogs beside it.
|
||||
pub gx_pending: usize,
|
||||
/// **Which tile these facts are about** — the focus tile the streamer computed this frame.
|
||||
/// `None` until the streamer first runs (and again after `release_world`). The settle release
|
||||
/// compares it against the tile under the avatar's own feet and refuses the resident release on
|
||||
@@ -271,7 +287,10 @@ impl WorldLoadProgress {
|
||||
/// The world at the focus is presentable — the scene term plus a quiet collider queue. What
|
||||
/// both consumers (the screen clear here, the settle release in the streamer) key on.
|
||||
pub fn presentable(&self) -> bool {
|
||||
self.scene_ready && self.colliders_pending == 0 && self.merge_pending == 0
|
||||
self.scene_ready
|
||||
&& self.colliders_pending == 0
|
||||
&& self.merge_pending == 0
|
||||
&& self.gx_pending == 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,6 +963,8 @@ fn stream_terrain(
|
||||
// "ready to reveal" means the buildings and trees around you exist, not just bare terrain. A
|
||||
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
|
||||
// for ground that isn't there.
|
||||
// The pacing edge, read and advanced once per frame (see [`TerrainStreamer::was_paced`]).
|
||||
let was_paced = std::mem::replace(&mut state.was_paced, focus.paced);
|
||||
if let Some(p) = load_progress.as_mut() {
|
||||
p.focus_tile = Some((cx, cy));
|
||||
// Resident = the focus tile is furnished — judged from the DESIRED set, not from the
|
||||
@@ -982,6 +1003,18 @@ fn stream_terrain(
|
||||
if !p.focus_resident {
|
||||
p.complete = false;
|
||||
}
|
||||
// A **snap** re-arms it too, even onto ground that is already resident. `paced` is false
|
||||
// exactly through entry, a teleport and a world swap (the game writes it from the settle
|
||||
// hold), and a near teleport — `.tele` across a city, a summon — lands on furnished ground
|
||||
// whose *buildings* may still be arriving. Without this the latch answers a fresh
|
||||
// destination with the departure's converged numbers: `scene_ready` reads true because it
|
||||
// was true where we left, and both consumers (the cover, the settle release) are told a
|
||||
// world is presentable that nobody has looked at yet.
|
||||
//
|
||||
// On the EDGE into the load, not on the level — see [`TerrainStreamer::was_paced`].
|
||||
if was_paced && !focus.paced {
|
||||
p.complete = false;
|
||||
}
|
||||
if !p.complete {
|
||||
// "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the
|
||||
// cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a
|
||||
@@ -1037,6 +1070,27 @@ fn stream_terrain(
|
||||
}
|
||||
p.complete = p.scene_ready && p.is_ready();
|
||||
}
|
||||
// The retained pass's own residency (1429's lane): geometry that has spawned and cannot
|
||||
// draw yet is a hole in the reveal exactly like an unattached collider is a hole in the
|
||||
// floor. Published every frame — one map walk, no per-item work — because both consumers
|
||||
// read `presentable()` and neither can see the bake.
|
||||
p.gx_pending = staticgx
|
||||
.as_deref()
|
||||
.map_or(0, crate::static_gx::StaticGx::undrawn_regions);
|
||||
// …and once nothing else is outstanding, the burst is over by definition: ask the pass to
|
||||
// publish what it holds instead of letting the quiet window add its own 15 frames to the
|
||||
// end of every load. Gated on the load itself (`!paced` — entry, a teleport, a world
|
||||
// swap), so steady-state play keeps the batching the windows exist for.
|
||||
if !focus.paced
|
||||
&& p.gx_pending > 0
|
||||
&& p.scene_ready
|
||||
&& p.colliders_pending == 0
|
||||
&& p.merge_pending == 0
|
||||
{
|
||||
if let Some(gx) = staticgx.as_deref_mut() {
|
||||
gx.flush_now();
|
||||
}
|
||||
}
|
||||
// Residency is *published* here and read by the mover's post-snap hold one stage later
|
||||
// (`player::release_post_snap_hold`). The streamer is still the only authority on which
|
||||
// map the colliders under the avatar belong to — it just states the fact now instead of
|
||||
|
||||
@@ -182,10 +182,140 @@ pub const CAM_NEAR: f32 = 1.0 / 9.0;
|
||||
/// [`crate::sun`]'s projection note); naming it here just stops the consumers drifting apart.
|
||||
pub const CAM_FOVY: f32 = std::f32::consts::FRAC_PI_4;
|
||||
|
||||
/// **The world camera's world pose, made current before anything in `Update` reads it** — the
|
||||
/// set every `Update`-stage viewer authority must order after.
|
||||
///
|
||||
/// Bevy propagates `GlobalTransform` in `PostUpdate`. For the whole of `Update`, therefore, a
|
||||
/// camera's `GlobalTransform` is the pose it had **last** frame, while its `Transform` — written
|
||||
/// by the controller in [`crate::schedule::WorldStage::Input`] — is this frame's. Walking, the two
|
||||
/// differ by centimetres and nothing shows. On the frame a teleport snaps they differ by the whole
|
||||
/// jump, and an authority that decides *what may draw* off the stale one gates a frame drawn from
|
||||
/// the new pose with a verdict about the old place (decision 1503).
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CameraPoseSet;
|
||||
|
||||
/// Copy the world camera's `Transform` into its `GlobalTransform` (see [`CameraPoseSet`]).
|
||||
///
|
||||
/// **Root cameras only.** The world camera is spawned unparented (both the fallback and the real
|
||||
/// one), and for a root the propagation Bevy will run in `PostUpdate` is exactly this copy — so
|
||||
/// this makes the same value available earlier and `PostUpdate` recomputes it identically. A
|
||||
/// camera that ever acquires a parent keeps Bevy's propagated value and its old one-frame lag,
|
||||
/// which is no worse than before and never silently wrong: the pose it reports is a real pose the
|
||||
/// camera had, just not this frame's.
|
||||
///
|
||||
/// Written through `set_if_neq` so a still camera does not flag `Changed<GlobalTransform>` every
|
||||
/// frame for the consumers that key on it.
|
||||
/// The root world cameras and their two transforms — see [`publish_camera_pose`].
|
||||
type RootCameraPose<'w, 's> =
|
||||
Query<'w, 's, (&'static Transform, &'static mut GlobalTransform), RootCamera>;
|
||||
/// A world camera Bevy will propagate as a root (no parent to inherit from).
|
||||
type RootCamera = (With<WorldCamera>, Without<ChildOf>);
|
||||
|
||||
pub(crate) fn publish_camera_pose(mut cam: RootCameraPose) {
|
||||
for (t, mut g) in &mut cam {
|
||||
g.set_if_neq(GlobalTransform::from(*t));
|
||||
}
|
||||
}
|
||||
|
||||
/// The view lane's plugin — today, [`publish_camera_pose`] and its ordering contract.
|
||||
pub(crate) struct ViewPlugin;
|
||||
|
||||
impl Plugin for ViewPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
plugin(app);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
publish_camera_pose
|
||||
.in_set(CameraPoseSet)
|
||||
// After the controller that writes the camera's `Transform`
|
||||
// ([`crate::schedule::WorldStage::Input`]) — the point of the whole system is to
|
||||
// publish the pose the controller just chose, not the one before it.
|
||||
.after(crate::schedule::WorldStage::Input),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// The contract of [`CameraPoseSet`]: a system ordered after it, in the same `Update`, reads
|
||||
/// the pose the controller wrote **this** frame — not the one Bevy will propagate at the end
|
||||
/// of it. Written as the frame it fixes: move the camera the way a teleport snap does, and
|
||||
/// assert the downstream authority sees the destination.
|
||||
#[test]
|
||||
fn a_reader_after_the_pose_set_sees_this_frames_camera() {
|
||||
#[derive(Resource, Default)]
|
||||
struct Seen(Vec3);
|
||||
|
||||
fn snap_the_camera(mut cam: Query<&mut Transform, With<WorldCamera>>) {
|
||||
for mut t in &mut cam {
|
||||
t.translation = Vec3::new(500.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
fn read_the_pose(cam: Query<&GlobalTransform, With<WorldCamera>>, mut seen: ResMut<Seen>) {
|
||||
for g in &cam {
|
||||
seen.0 = g.translation();
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Seen>();
|
||||
app.configure_sets(Update, crate::schedule::WorldStage::Input);
|
||||
app.add_systems(
|
||||
Update,
|
||||
snap_the_camera.in_set(crate::schedule::WorldStage::Input),
|
||||
);
|
||||
plugin(&mut app);
|
||||
app.add_systems(Update, read_the_pose.after(CameraPoseSet));
|
||||
app.world_mut().spawn((
|
||||
WorldCamera,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
));
|
||||
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<Seen>().0,
|
||||
Vec3::new(500.0, 0.0, 0.0),
|
||||
"an Update-stage viewer authority must see the snapped pose, not the frame-old one"
|
||||
);
|
||||
}
|
||||
|
||||
/// A **parented** camera is left to Bevy's propagation: a plain copy of a child's local
|
||||
/// `Transform` would be a wrong pose, which is worse than a frame-old right one.
|
||||
#[test]
|
||||
fn a_parented_camera_is_left_alone() {
|
||||
let mut app = App::new();
|
||||
plugin(&mut app);
|
||||
let parent = app.world_mut().spawn(Transform::default()).id();
|
||||
let child = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
WorldCamera,
|
||||
Transform::from_xyz(7.0, 0.0, 0.0),
|
||||
GlobalTransform::default(),
|
||||
))
|
||||
.id();
|
||||
app.world_mut().entity_mut(parent).add_child(child);
|
||||
|
||||
app.world_mut()
|
||||
.run_system_once(publish_camera_pose)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.entity(child)
|
||||
.get::<GlobalTransform>()
|
||||
.unwrap()
|
||||
.translation(),
|
||||
Vec3::ZERO,
|
||||
"the child's local transform is not a world pose"
|
||||
);
|
||||
}
|
||||
/// The wall is planar depth along camera-forward, measured to the sphere's nearest point.
|
||||
#[test]
|
||||
fn planar_depth_of_the_nearest_point() {
|
||||
|
||||
@@ -293,7 +293,13 @@ impl Plugin for WmoPortalPlugin {
|
||||
track_area_interior,
|
||||
track_unit_interiors,
|
||||
)
|
||||
.in_set(WmoPvsSet),
|
||||
.in_set(WmoPvsSet)
|
||||
// **On this frame's camera pose, not last frame's** (decision 1503). The flood,
|
||||
// the interior claim and the exterior windows all key on the eye, and every one of
|
||||
// them decides what the frame is allowed to draw. Unordered, this set could even
|
||||
// run before the controller had moved the camera at all; ordered here it answers
|
||||
// about the eye the frame will be rendered from.
|
||||
.after(crate::view::CameraPoseSet),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -323,6 +329,8 @@ fn compute_wmo_pvs(
|
||||
};
|
||||
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
|
||||
let eye_world = cam_t.translation();
|
||||
// Publish the pose this authority ran on (see [`WmoCullProbe::eye`]).
|
||||
probe.eye = eye_world;
|
||||
// The terrain leg of the seed's down-ray, sampled once for the camera's column: the client casts the
|
||||
// same segment at the ground and drops the WMO hit when the ground is nearer (`FUN_006821f0`).
|
||||
let terrain = terrain_height_under(&streamer, &adt_tiles, eye_world);
|
||||
|
||||
@@ -18,6 +18,11 @@ use super::{
|
||||
pub struct WmoCullProbe {
|
||||
/// Set by the panel's dump button; the next compute writes the trace file and clears it.
|
||||
pub dump_requested: bool,
|
||||
/// **The eye the PVS, the interior claim and the exterior windows were computed from** —
|
||||
/// the visibility authority's own pose, recorded so an instrument can compare it against the
|
||||
/// pose the frame actually draws from. Ordinary movement makes the two identical to within a
|
||||
/// centimetre; a snap is where they can disagree by the whole teleport.
|
||||
pub eye: Vec3,
|
||||
}
|
||||
|
||||
/// Where the dump button writes its trace — under `target/` so it never lands in the repo.
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct WorldCensus<'w, 's> {
|
||||
claim: Option<Res<'w, CameraInteriorClaim>>,
|
||||
windows: Option<Res<'w, ExteriorWindows>>,
|
||||
verdict: Option<Res<'w, ExteriorCullVerdict>>,
|
||||
/// The visibility authority's own pose (see [`CensusReport::pvs_eye`]).
|
||||
cull_probe: Option<Res<'w, crate::wmo_portal::WmoCullProbe>>,
|
||||
/// Which backdrop is drawing — the gradient dome, or a building's own MOSB sky
|
||||
/// ([`crate::wmo_sky`]). Optional for the same reason as the portal terms above.
|
||||
skybox: Option<Res<'w, crate::wmo_sky::CameraWmoSkybox>>,
|
||||
@@ -103,6 +105,12 @@ pub struct CensusReport {
|
||||
pub room: Option<String>,
|
||||
/// `"unrestricted"`, or the number of window sub-frusta. `None` with [`CensusReport::room`].
|
||||
pub windows: Option<String>,
|
||||
/// **The eye the portal authority computed [`Self::room`], [`Self::windows`] and every group's
|
||||
/// PVS from.** Not necessarily the eye this frame draws from: the authority runs in `Update`
|
||||
/// off the camera's propagated transform, so it is answering about wherever the camera was
|
||||
/// when that transform was last written. Walking makes the difference a centimetre; a
|
||||
/// teleport makes it the whole jump, and this is the column that says so.
|
||||
pub pvs_eye: Option<Vec3>,
|
||||
/// The backdrop actually drawing: `"dome"` for the `Light.dbc` gradient, else the WMO skybox
|
||||
/// model a building's PVS asked for. `None` when the WMO-sky lane is not installed.
|
||||
///
|
||||
@@ -263,6 +271,7 @@ impl WorldCensus<'_, '_> {
|
||||
particles,
|
||||
room,
|
||||
windows,
|
||||
pvs_eye: self.cull_probe.as_deref().map(|p| p.eye),
|
||||
sky,
|
||||
ribbons: self.ribbons.as_deref().map(|r| (r.trails, r.drawn)),
|
||||
cull: self.verdict.as_deref().map(|v| CullTerms {
|
||||
|
||||
@@ -177,6 +177,10 @@ impl PluginGroup for WorldPlugins {
|
||||
// streamer fills its volume registry).
|
||||
.add(crate::interior::InteriorPlugin)
|
||||
.add(crate::entity_shade::EntityShadePlugin)
|
||||
// The world camera's pose, published before the `Update`-stage viewer authorities
|
||||
// below read it (decision 1503) — without it they answer about where the camera was
|
||||
// last frame, which on a teleport frame is the place we just left.
|
||||
.add(crate::view::ViewPlugin)
|
||||
// WMO portal visibility: per-frame, decides which of a building's groups are reachable
|
||||
// through portals from the camera's group, so the Stormwind cathedral culls from the
|
||||
// Trade District. Only computes the PVS; the `Visibility` authority
|
||||
|
||||
Reference in New Issue
Block a user