copy and paste works everywhere, and the sun keeps its colour
- One text-input law for every field. Login, character create and the delete dialog each improvised their own tiny keyboard handling — no caret movement, no selection, no clipboard, and Ctrl/Cmd+V typed a literal "v" into the password box. All of them now run the same byte-verified edit-box law as chat: clipboard, select-all, word moves, shift-selection, the real 0.5 s caret blink. The password box masks what you copy off it, like the original. - Copy/paste on Linux had two independent causes, one per direction: our window is Wayland but the clipboard backend spoke X11 only, and a copy relinquished the X11 selection the moment it returned (an X11 clipboard lives only as long as its owning connection). Both fixed — Wayland windows get a real Wayland clipboard, X11 keeps a held handle. AltGr is not Ctrl anymore either, so Polish and German layouts can type ą, ć and ź in chat instead of triggering select-all. Compile-checked on Linux; wants a real-session retest. - The exterior sun keeps its colour. A per-channel clamp saturated the warm continental sun to pale yellow-white on every sunlit fence, post and doodad; the clamp now happens once, at the sum, so the fiery orange survives — the reported Silverpine fence and Blackrock steps match the reference's own arithmetic. - A pool has a floor. Undercity's upper slime channels claimed the dry room a storey below them — 115 yards down still read as submerged. A pool now stops at its own room's floor; a sweep of every building in the shipped world found 51 such stacked sites across 6 buildings, all closed. - Ctrl/Cmd+Z hides the whole UI layer for clean screenshots, and instrumented debug runs open their window in the background instead of stealing focus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+99
-6
@@ -591,6 +591,8 @@ dependencies = [
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"rand 0.8.6",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2293,18 +2295,43 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"polling",
|
||||
"rustix 1.1.4",
|
||||
"slab",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.4",
|
||||
"rustix 1.1.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -6085,7 +6112,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"log",
|
||||
"memmap2",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
@@ -6268,8 +6295,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -6286,6 +6313,44 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"calloop 0.14.4",
|
||||
"calloop-wayland-source 0.4.1",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.4",
|
||||
"thiserror 2.0.18",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-experimental",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-clipboard"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -6734,6 +6799,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -7206,6 +7272,32 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-experimental"
|
||||
version = "20250721.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-misc"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
@@ -7251,6 +7343,7 @@ checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -7957,7 +8050,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7979,7 +8072,7 @@ dependencies = [
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Where liquid **scoping** can still get the wrong answer, read off the shipped files rather than
|
||||
//! argued: `cargo run -p benilla-formats --example wmo_liquid_scope`.
|
||||
//!
|
||||
//! The "swim in air" family (0634 → 0635 → 0696 → 0701) is one defect met at successively finer
|
||||
//! grain: a liquid footprint has no bound of its own, so each round found a wider set of positions
|
||||
//! it was still claiming. This is the falsifier that says which bounds are actually load-bearing on
|
||||
//! shipped content, and where — an argument about WMO liquid is otherwise unfalsifiable from inside
|
||||
//! the game, where you can only stand in one room at a time.
|
||||
//!
|
||||
//! * **STOREY** — a placement holding a pool in one group and a **floor below it** in another.
|
||||
//! Placement scoping alone claims the lower room (submerged on dry stone, the B60 shape). This is
|
||||
//! what found decision 0701's Undercity site, and it now also reports whether 0701's floor rule
|
||||
//! closes each hit. Tested against the pool's real **wet cells**, not its bounding box: a liquid
|
||||
//! grid is sparse, so a bbox routinely spans dry ground it never touches (decision 0635).
|
||||
//! * **OVERHANG** — how far a pool's wet cells reach *outside* their own group's box. This is why
|
||||
//! 0701 bounds a pool by its group's Z **floor** and not by the reference's whole per-group AABB:
|
||||
//! on shipped content a pool overhangs its own room by up to 25 yd in XY, so an XY box test would
|
||||
//! newly reject pools that work today.
|
||||
//! * **DRY-GROUP** — a group whose MOGP `groupLiquid` (@0x34) is not the `0xf` no-liquid sentinel
|
||||
//! but that carries **no MLIQ chunk**. The reference's `0x6b9f10` is reported to answer such a
|
||||
//! group with an unconditional hit at height `FLT_MAX`; we answer *nothing*, and since 0634/0696
|
||||
//! suppress the ADT leg indoors, the room reads DRY. **Still open** — three placed groups.
|
||||
//!
|
||||
//! Only placements found in a shipped ADT are reported — an unplaced WMO cannot be stood in — and
|
||||
//! each hit prints a `.go` for the lower room's floor so it can be looked at.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use benilla_formats::LiquidMesh;
|
||||
|
||||
/// MOGP `groupLiquid` @ 0x34: the value meaning "this group holds no liquid".
|
||||
const NO_LIQUID: u32 = 0xf;
|
||||
|
||||
/// Half the 34133⅓-yard map (32 tiles × 533⅓) — the MODF→world origin shift.
|
||||
const MAP_CENTER: f32 = 17066.666;
|
||||
|
||||
/// What one group file says about liquid.
|
||||
struct Group {
|
||||
liquid_type: u32,
|
||||
/// The group's MLIQ surface in WMO model space, if it has one.
|
||||
liquid: Option<LiquidMesh>,
|
||||
/// MOGP bounding box (model space), @0x0c.
|
||||
lo: [f32; 3],
|
||||
hi: [f32; 3],
|
||||
}
|
||||
|
||||
/// One placement of a model in the world.
|
||||
struct Site {
|
||||
map: String,
|
||||
/// Placement origin in WoW world coords.
|
||||
pos: [f32; 3],
|
||||
rot: [f32; 3],
|
||||
unique_id: u32,
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
|
||||
b.get(i..i + 4)
|
||||
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
fn f32_at(b: &[u8], i: usize) -> Option<f32> {
|
||||
u32_at(b, i).map(f32::from_bits)
|
||||
}
|
||||
|
||||
/// Walk top-level chunks for one whose reversed magic matches.
|
||||
fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
let mut off = 0usize;
|
||||
while off + 8 <= bytes.len() {
|
||||
let size = u32_at(bytes, off + 4)? as usize;
|
||||
let body = bytes.get(off + 8..off + 8 + size)?;
|
||||
if &bytes[off..off + 4] == magic {
|
||||
return Some(body);
|
||||
}
|
||||
off += 8 + size;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_group(bytes: &[u8]) -> Option<Group> {
|
||||
let mogp = chunk(bytes, b"PGOM")?; // MOGP
|
||||
if mogp.len() < 0x44 {
|
||||
return None;
|
||||
}
|
||||
let corner = |i: usize| Some([f32_at(mogp, i)?, f32_at(mogp, i + 4)?, f32_at(mogp, i + 8)?]);
|
||||
Some(Group {
|
||||
liquid_type: u32_at(mogp, 0x34)?,
|
||||
liquid: benilla_formats::wmo_group_liquid_mesh(bytes),
|
||||
lo: corner(0x0c)?,
|
||||
hi: corner(0x18)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does this group file carry an MLIQ sub-chunk at all? (The MOGP payload is a 68-byte header
|
||||
/// followed by its own sub-chunks.) Distinct from [`Group::liquid`], which is `None` for an
|
||||
/// all-holes grid too.
|
||||
fn has_mliq(bytes: &[u8]) -> bool {
|
||||
chunk(bytes, b"PGOM").is_some_and(|m| m.len() > 0x44 && chunk(&m[0x44..], b"QILM").is_some())
|
||||
}
|
||||
|
||||
/// The model→world rotation for a MODF placement, in the **WoW** frame (Z up) — the `in_wow` half
|
||||
/// of `benilla_assets::coords::placement_rotation`, as a plain 3×3 so this example needs no math
|
||||
/// dependency. `Rx(90°)·Ry(ry−180°)·Rz(−rx)·Rx(rz−90°)`, applied to a model-space point.
|
||||
fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (rx, ry, rz) = (
|
||||
rot_deg[0].to_radians(),
|
||||
rot_deg[1].to_radians(),
|
||||
rot_deg[2].to_radians(),
|
||||
);
|
||||
let rot_x = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
|
||||
};
|
||||
let rot_y = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
|
||||
};
|
||||
let rot_z = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
|
||||
};
|
||||
// Right-to-left, as the quaternion product composes.
|
||||
let p = rot_x(p, rz - std::f32::consts::FRAC_PI_2);
|
||||
let p = rot_z(p, -rx);
|
||||
let p = rot_y(p, ry - std::f32::consts::PI);
|
||||
rot_x(p, std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
|
||||
fn go_at(site: &Site, model_pt: [f32; 3]) -> String {
|
||||
let r = model_to_world(site.rot, model_pt);
|
||||
format!(
|
||||
"{}: .go xyz {:.2} {:.2} {:.2}",
|
||||
site.map,
|
||||
site.pos[0] + r[0],
|
||||
site.pos[1] + r[1],
|
||||
site.pos[2] + r[2],
|
||||
)
|
||||
}
|
||||
|
||||
/// Every wet cell of a liquid mesh as `(x0, y0, x1, y1, height)` in model space.
|
||||
fn wet_cells(lq: &LiquidMesh) -> Vec<([f32; 4], f32)> {
|
||||
let (cols, rows) = (lq.grid[0] as usize, lq.grid[1] as usize);
|
||||
let mut out = Vec::new();
|
||||
for j in 0..rows.saturating_sub(1) {
|
||||
for i in 0..cols.saturating_sub(1) {
|
||||
if !lq.wet.get(j * (cols - 1) + i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let c = [
|
||||
lq.positions[j * cols + i],
|
||||
lq.positions[j * cols + i + 1],
|
||||
lq.positions[(j + 1) * cols + i],
|
||||
lq.positions[(j + 1) * cols + i + 1],
|
||||
];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||
let mut h = f32::MIN;
|
||||
for v in c {
|
||||
for a in 0..2 {
|
||||
lo[a] = lo[a].min(v[a]);
|
||||
hi[a] = hi[a].max(v[a]);
|
||||
}
|
||||
h = h.max(v[2]);
|
||||
}
|
||||
out.push(([lo[0], lo[1], hi[0], hi[1]], h));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
|
||||
// ---- pass 1: every WMO actually placed in the world, and where. ------------------------
|
||||
let mut placed: BTreeMap<String, Vec<Site>> = BTreeMap::new();
|
||||
let adts: Vec<String> = chain
|
||||
.list()?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.filter(|n| n.to_lowercase().ends_with(".adt"))
|
||||
.collect();
|
||||
eprintln!("scanning {} ADTs…", adts.len());
|
||||
for name in &adts {
|
||||
let Ok(bytes) = chain.read_file(name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(benilla_adt::ParsedAdt::Root(adt)) =
|
||||
benilla_adt::parse_adt(&mut Cursor::new(&*bytes))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let lower = name.to_lowercase();
|
||||
let map = lower.split(['\\', '/']).nth(2).unwrap_or("?").to_string();
|
||||
for p in &adt.wmo_placements {
|
||||
let Some(model) = adt.wmos.get(p.name_id as usize) else {
|
||||
continue;
|
||||
};
|
||||
let e = placed
|
||||
.entry(model.to_lowercase().replace('/', "\\"))
|
||||
.or_default();
|
||||
if !e.iter().any(|s| s.map == map) {
|
||||
e.push(Site {
|
||||
map: map.clone(),
|
||||
pos: [
|
||||
MAP_CENTER - p.position[2],
|
||||
MAP_CENTER - p.position[0],
|
||||
p.position[1],
|
||||
],
|
||||
rot: p.rotation,
|
||||
unique_id: p.unique_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{} distinct WMO models placed in the world", placed.len());
|
||||
|
||||
// ---- pass 2: read every placed model's groups. -----------------------------------------
|
||||
let mut dry_groups: Vec<String> = Vec::new();
|
||||
let mut storey: Vec<(f32, String)> = Vec::new();
|
||||
let mut overhang: Vec<(f32, String)> = Vec::new();
|
||||
let mut floor_misses = 0usize;
|
||||
// groupLiquid value -> (groups with an MLIQ chunk, groups without) — the sentinel sanity check:
|
||||
// `0xf` is only the "no liquid" marker if it is what the overwhelming majority of dry groups say.
|
||||
let mut type_census: BTreeMap<u32, (usize, usize)> = BTreeMap::new();
|
||||
|
||||
for (model, sites) in &placed {
|
||||
let stem = model.strip_suffix(".wmo").unwrap_or(model);
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for gi in 0.. {
|
||||
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
|
||||
break;
|
||||
};
|
||||
let Some(g) = read_group(&gb) else { break };
|
||||
let e = type_census.entry(g.liquid_type).or_default();
|
||||
if has_mliq(&gb) {
|
||||
e.0 += 1;
|
||||
} else {
|
||||
e.1 += 1;
|
||||
if g.liquid_type != NO_LIQUID {
|
||||
// The ROOM, not the building origin: a placement origin routinely sits
|
||||
// inside rock, so a `.go` to it is unusable for an eyeball check. Aim at the
|
||||
// group box's own centre, just off its floor.
|
||||
let mid = |a: usize| (g.lo[a] + g.hi[a]) * 0.5;
|
||||
dry_groups.push(format!(
|
||||
" {model} group {gi:03} liquidType {} box z [{:.1}..{:.1}]\n room {}\n origin{}",
|
||||
g.liquid_type,
|
||||
g.lo[2],
|
||||
g.hi[2],
|
||||
go_at(&sites[0], [mid(0), mid(1), g.lo[2] + 1.5]),
|
||||
go_at(&sites[0], [0.0, 0.0, 0.0]),
|
||||
));
|
||||
}
|
||||
}
|
||||
groups.push(g);
|
||||
}
|
||||
|
||||
// STOREY: for each pool, the lower groups its WET CELLS actually hang over.
|
||||
for (ai, a) in groups.iter().enumerate() {
|
||||
let Some(lq) = a.liquid.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let cells = wet_cells(lq);
|
||||
// OVERHANG: the farthest a wet cell reaches past its own group's XY box.
|
||||
let out = cells.iter().fold(0.0f32, |m, (xy, _)| {
|
||||
m.max(a.lo[0] - xy[0])
|
||||
.max(xy[2] - a.hi[0])
|
||||
.max(a.lo[1] - xy[1])
|
||||
.max(xy[3] - a.hi[1])
|
||||
});
|
||||
if out > 0.01 {
|
||||
overhang.push((out, format!("{model} pool g{ai:03}")));
|
||||
}
|
||||
for (bi, b) in groups.iter().enumerate() {
|
||||
if ai == bi {
|
||||
continue;
|
||||
}
|
||||
// The claim only fires where the query would: a point in B under the pool's height
|
||||
// and inside a wet cell's XY. Take B's own floor as the standing surface.
|
||||
let stand = b.lo[2] + 1.5;
|
||||
let mut best: Option<([f32; 4], f32)> = None;
|
||||
for (xy, h) in &cells {
|
||||
if *h <= stand {
|
||||
continue; // the pool is at or below this floor — no false submersion
|
||||
}
|
||||
let overlap =
|
||||
xy[0] < b.hi[0] && xy[2] > b.lo[0] && xy[1] < b.hi[1] && xy[3] > b.lo[1];
|
||||
// …and B must not simply BE the pool's own room: skip when B's box contains the
|
||||
// liquid height, i.e. the pool is inside B and the submersion is real.
|
||||
if overlap && *h > b.hi[2] && best.is_none_or(|(_, bh)| *h > bh) {
|
||||
best = Some((*xy, *h));
|
||||
}
|
||||
}
|
||||
if let Some((xy, h)) = best {
|
||||
let pt = [(xy[0] + xy[2]) * 0.5, (xy[1] + xy[3]) * 0.5, stand];
|
||||
// Would a FLOOR under the pool — "a pool cannot claim anything below its own
|
||||
// group's box" — close this one? The candidate fix, checked per hit rather
|
||||
// than assumed to generalize from the site that motivated it.
|
||||
let floored = stand < a.lo[2];
|
||||
if !floored {
|
||||
floor_misses += 1;
|
||||
}
|
||||
storey.push((
|
||||
h - stand,
|
||||
format!(
|
||||
" {:>7.1} yd {} {model} pool g{ai:03} over g{bi:03} (floor {:.1}, pool {:.1}, pool-group floor {:.1})\n {} [uid {}]",
|
||||
h - stand,
|
||||
if floored { "FLOOR-FIXES" } else { "floor-MISSES" },
|
||||
b.lo[2],
|
||||
h,
|
||||
a.lo[2],
|
||||
go_at(&sites[0], pt),
|
||||
sites[0].unique_id,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overhang.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
println!(
|
||||
"\n=== OVERHANG: how far a pool's WET CELLS reach outside its OWN group's box, in XY ===\n\
|
||||
(the risk in narrowing the scope from placement to GROUP: where a pool hangs over the\n\
|
||||
neighbouring group, a subject standing under it is in group B while the water is group A's,\n\
|
||||
and a strict group match would stop it swimming. 0 = the pool never leaves its own room.)"
|
||||
);
|
||||
for (d, line) in overhang.iter().take(15) {
|
||||
println!(" {d:>7.2} yd {line}");
|
||||
}
|
||||
|
||||
// ---- the ARCHIVE-WIDE census. -----------------------------------------------------------
|
||||
// The placed-model census below answers "what does shipped, reachable content use". This one
|
||||
// answers the prior question: is `groupLiquid` a field the 1.12 authors USED at all? If every
|
||||
// MLIQ-carrying group in the whole archive says `0xf`, then the type-override branch never
|
||||
// fires on real data and a stray non-`0xf` value on a group with no liquid is content noise,
|
||||
// not a feature we are failing to implement.
|
||||
let mut all_census: BTreeMap<u32, (usize, usize)> = BTreeMap::new();
|
||||
let mut odd: Vec<String> = Vec::new();
|
||||
for name in chain.list()?.into_iter().map(|e| e.name) {
|
||||
let lower = name.to_lowercase();
|
||||
// group files are `<stem>_NNN.wmo`
|
||||
if !lower.ends_with(".wmo") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = lower.strip_suffix(".wmo") else {
|
||||
continue;
|
||||
};
|
||||
if stem.len() < 4 || !stem[stem.len() - 3..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(g) = read_group(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let mliq = has_mliq(&bytes);
|
||||
let e = all_census.entry(g.liquid_type).or_default();
|
||||
if mliq {
|
||||
e.0 += 1;
|
||||
} else {
|
||||
e.1 += 1;
|
||||
}
|
||||
if g.liquid_type != NO_LIQUID {
|
||||
odd.push(format!(
|
||||
" {lower} liquidType {} {}",
|
||||
g.liquid_type,
|
||||
if mliq { "HAS MLIQ" } else { "no MLIQ" }
|
||||
));
|
||||
}
|
||||
}
|
||||
println!("\n=== groupLiquid census over the WHOLE ARCHIVE: value -> (with MLIQ, without) ===");
|
||||
for (ty, (with, without)) in &all_census {
|
||||
println!(" {ty:>5} -> {with:>5} with MLIQ, {without:>5} without");
|
||||
}
|
||||
println!("\n every non-0xf group in the archive ({}):", odd.len());
|
||||
for line in &odd {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
println!("\n=== groupLiquid census over placed models: value -> (with MLIQ, without) ===");
|
||||
for (ty, (with, without)) in &type_census {
|
||||
println!(" {ty:>5} -> {with:>5} with MLIQ, {without:>5} without");
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n=== DRY-GROUP: groupLiquid != 0xf but no MLIQ chunk ({}) ===",
|
||||
dry_groups.len()
|
||||
);
|
||||
for line in &dry_groups {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
storey.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
println!(
|
||||
"\n=== STOREY: a walkable floor under another group's WET CELLS ({}) ===",
|
||||
storey.len()
|
||||
);
|
||||
println!(" the FLOOR rule (a pool never claims below its own group box) misses {floor_misses} of {}", storey.len());
|
||||
for (_, line) in &storey {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -255,7 +255,9 @@ enum Command {
|
||||
},
|
||||
/// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a
|
||||
/// time of day — the band-semantics instrument (which row holds which colour at which hour;
|
||||
/// the celestial-diffuse band question, decision 0485).
|
||||
/// the celestial-diffuse band question, decision 0485). ⚠ This is ONE params record RAW —
|
||||
/// near a sphere's falloff edge the LIVE light is mostly the continent global and looks
|
||||
/// nothing like these rows; `lightblend` shows what actually wins at the position (0706).
|
||||
Lightbands {
|
||||
/// Map id (0 = Eastern Kingdoms, 1 = Kalimdor).
|
||||
map: u32,
|
||||
@@ -301,6 +303,26 @@ enum Command {
|
||||
/// Game minute-of-day (0..1440; 720 = noon).
|
||||
minute: u32,
|
||||
},
|
||||
/// Dump every light SPHERE covering a world position — distance, falloff, blend alpha, each
|
||||
/// sphere's clear ambient/sun — then the faithful area-blend result vs the single `pick_light`
|
||||
/// sample. The instrument for "which palette actually wins HERE": a position near a zone
|
||||
/// sphere's falloff edge resolves mostly the continent global, and the raw per-params band dump
|
||||
/// (`lightbands`) cannot show that.
|
||||
Lightblend {
|
||||
/// Map id (0 = Eastern Kingdoms, 1 = Kalimdor).
|
||||
map: u32,
|
||||
/// World-space X (raw WoW yards; may be negative).
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
x: f32,
|
||||
/// World-space Y.
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
y: f32,
|
||||
/// World-space Z.
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
z: f32,
|
||||
/// Game minute-of-day (0..1440; 720 = noon).
|
||||
minute: u32,
|
||||
},
|
||||
/// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a
|
||||
/// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that
|
||||
/// content animates (see `m2anim`) — the doodad-animation perf/scope instrument.
|
||||
@@ -499,6 +521,16 @@ fn main() -> Result<()> {
|
||||
let catalog = benilla_formats::LightCatalog::load(&mut chain)?;
|
||||
catalog.debug_slots(map, [x, y, z], minute * 2);
|
||||
}
|
||||
Command::Lightblend {
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
minute,
|
||||
} => {
|
||||
let catalog = benilla_formats::LightCatalog::load(&mut chain)?;
|
||||
catalog.debug_blend(map, [x, y, z], minute * 2);
|
||||
}
|
||||
Command::Wmodoodads {
|
||||
internal_path,
|
||||
filter,
|
||||
|
||||
@@ -79,13 +79,11 @@ pub(super) fn paste(lua: &Lua, text: &str) -> bool {
|
||||
let Some(h) = route(lua) else {
|
||||
return false;
|
||||
};
|
||||
let multi_line = with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false);
|
||||
let cleaned: String = text
|
||||
.chars()
|
||||
.filter(|&c| c as u32 >= 0x20 || (multi_line && c == '\n'))
|
||||
.collect();
|
||||
if !cleaned.is_empty() {
|
||||
insert(lua, h, &cleaned, false);
|
||||
// The sanitation + insert is the shared law ([`EditBoxState::paste`]); only the event fire is
|
||||
// this layer's.
|
||||
if with_eb(lua, h, |eb| eb.paste(text)).is_some_and(|o| o.text_changed) {
|
||||
sync_text_region(lua, h);
|
||||
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -261,29 +259,17 @@ fn clear_focus_handle(lua: &Lua, h: FrameHandle) {
|
||||
/// a non-digit; splice + advance; enforce caps; fire `OnTextChanged`, and (when `fire_space`) one
|
||||
/// `OnSpacePressed` per space in `ins`.
|
||||
fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
|
||||
let spaces = with_eb(lua, h, |eb| {
|
||||
if eb.numeric && !ins.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None; // numeric: any non-digit aborts the whole insert
|
||||
}
|
||||
eb.end_history_browse(); // a typed edit turns the recalled line into an ordinary draft
|
||||
delete_selection(eb);
|
||||
eb.text.insert_str(eb.cursor, ins);
|
||||
eb.cursor += ins.len();
|
||||
collapse(eb);
|
||||
enforce_caps(eb);
|
||||
eb.reset_blink();
|
||||
Some(if fire_space {
|
||||
ins.matches(' ').count()
|
||||
} else {
|
||||
0
|
||||
})
|
||||
});
|
||||
// Some(Some(n)) inserted (n spaces); Some(None) numeric-aborted; None not an EditBox.
|
||||
if let Some(Some(spaces)) = spaces {
|
||||
sync_text_region(lua, h);
|
||||
let id = frame_id_of(lua, h);
|
||||
fire_script(lua, id, "OnTextChanged");
|
||||
for _ in 0..spaces {
|
||||
let Some(out) = with_eb(lua, h, |eb| eb.insert(ins)) else {
|
||||
return; // not an EditBox
|
||||
};
|
||||
if !out.text_changed {
|
||||
return; // numeric-aborted
|
||||
}
|
||||
sync_text_region(lua, h);
|
||||
let id = frame_id_of(lua, h);
|
||||
fire_script(lua, id, "OnTextChanged");
|
||||
if fire_space {
|
||||
for _ in 0..out.spaces {
|
||||
fire_script(lua, id, "OnSpacePressed");
|
||||
}
|
||||
}
|
||||
@@ -292,17 +278,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
|
||||
/// `SetText` (`0x77be00`): short-circuit when unchanged (no events); else clear selection, replace,
|
||||
/// cursor to end, enforce caps, fire `OnTextSet` then `OnTextChanged`.
|
||||
fn set_text(lua: &Lua, h: FrameHandle, s: &str) {
|
||||
let changed = with_eb(lua, h, |eb| {
|
||||
if eb.text == s {
|
||||
return false;
|
||||
}
|
||||
eb.text = s.to_string();
|
||||
eb.cursor = eb.text.len();
|
||||
collapse(eb);
|
||||
enforce_caps(eb);
|
||||
eb.reset_blink();
|
||||
true
|
||||
});
|
||||
let changed = with_eb(lua, h, |eb| eb.set_text(s));
|
||||
if changed == Some(true) {
|
||||
sync_text_region(lua, h);
|
||||
let id = frame_id_of(lua, h);
|
||||
@@ -316,26 +292,8 @@ fn set_text(lua: &Lua, h: FrameHandle, s: &str) {
|
||||
/// `OnTextChanged` when anything was removed.
|
||||
fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState) -> usize) {
|
||||
let changed = with_eb(lua, h, |eb| {
|
||||
eb.end_history_browse(); // an edit ends any recall browse
|
||||
let did = if eb.sel_start != eb.sel_end {
|
||||
delete_selection(eb);
|
||||
true
|
||||
} else {
|
||||
let t = target_of(eb);
|
||||
let (a, b) = (t.min(eb.cursor), t.max(eb.cursor));
|
||||
if a == b {
|
||||
false
|
||||
} else {
|
||||
eb.text.replace_range(a..b, "");
|
||||
eb.cursor = a;
|
||||
collapse(eb);
|
||||
true
|
||||
}
|
||||
};
|
||||
if did {
|
||||
eb.reset_blink();
|
||||
}
|
||||
did
|
||||
let t = target_of(eb);
|
||||
eb.delete_to(t)
|
||||
});
|
||||
if changed == Some(true) {
|
||||
sync_text_region(lua, h);
|
||||
@@ -346,33 +304,7 @@ fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState)
|
||||
/// BACKSPACE / DELETE: delete the selection if non-empty, else the char before (`forward=false`) or
|
||||
/// after (`forward=true`) the cursor; fire `OnTextChanged` if anything was removed.
|
||||
fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) {
|
||||
let changed = with_eb(lua, h, |eb| {
|
||||
eb.end_history_browse(); // a typed edit ends any recall browse
|
||||
let did = 'del: {
|
||||
if eb.sel_start != eb.sel_end {
|
||||
delete_selection(eb);
|
||||
break 'del true;
|
||||
}
|
||||
if forward {
|
||||
if eb.cursor < eb.text.len() {
|
||||
let end = next_boundary(&eb.text, eb.cursor);
|
||||
eb.text.replace_range(eb.cursor..end, "");
|
||||
break 'del true;
|
||||
}
|
||||
} else if eb.cursor > 0 {
|
||||
let start = prev_boundary(&eb.text, eb.cursor);
|
||||
eb.text.replace_range(start..eb.cursor, "");
|
||||
eb.cursor = start;
|
||||
collapse(eb);
|
||||
break 'del true;
|
||||
}
|
||||
false
|
||||
};
|
||||
if did {
|
||||
eb.reset_blink();
|
||||
}
|
||||
did
|
||||
});
|
||||
let changed = with_eb(lua, h, |eb| eb.delete_dir(forward));
|
||||
if changed == Some(true) {
|
||||
sync_text_region(lua, h);
|
||||
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
|
||||
@@ -394,151 +326,19 @@ fn history_step_key(lua: &Lua, h: FrameHandle, older: bool) {
|
||||
/// collapses to the selection edge (if any) or moves one char. Fires no script (a cursor/selection
|
||||
/// move; `OnCursorChanged` geometry is out of scope).
|
||||
fn move_horizontal(lua: &Lua, h: FrameHandle, right: bool, shift: bool) {
|
||||
with_eb(lua, h, |eb| {
|
||||
if shift {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = if right {
|
||||
next_boundary(&eb.text, eb.cursor)
|
||||
} else {
|
||||
prev_boundary(&eb.text, eb.cursor)
|
||||
};
|
||||
set_span(eb, anchor, eb.cursor);
|
||||
} else if eb.sel_start != eb.sel_end {
|
||||
eb.cursor = if right { eb.sel_end } else { eb.sel_start };
|
||||
collapse(eb);
|
||||
} else {
|
||||
eb.cursor = if right {
|
||||
next_boundary(&eb.text, eb.cursor)
|
||||
} else {
|
||||
prev_boundary(&eb.text, eb.cursor)
|
||||
};
|
||||
collapse(eb);
|
||||
}
|
||||
eb.reset_blink();
|
||||
});
|
||||
with_eb(lua, h, |eb| eb.move_by_char(right, shift));
|
||||
}
|
||||
|
||||
/// HOME/END: move the caret to 0 / `len`; `shift` extends from the fixed anchor.
|
||||
fn move_to_edge(lua: &Lua, h: FrameHandle, end: bool, shift: bool) {
|
||||
with_eb(lua, h, |eb| {
|
||||
let target = if end { eb.text.len() } else { 0 };
|
||||
if shift {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = target;
|
||||
set_span(eb, anchor, target);
|
||||
} else {
|
||||
eb.cursor = target;
|
||||
collapse(eb);
|
||||
}
|
||||
eb.reset_blink();
|
||||
});
|
||||
with_eb(lua, h, |eb| eb.move_to_edge(end, shift));
|
||||
}
|
||||
|
||||
/// `HighlightText` (`0x77cca0`), the client's exact clamp: `start = clamp(start, 0..=len)`;
|
||||
/// `end = (end < 0 || end > len) ? len : end`; then `if end < start { end = len }` — so `(0, -1)`
|
||||
/// selects all. Offsets are bytes, snapped to char boundaries.
|
||||
fn highlight_text(lua: &Lua, h: FrameHandle, start: i64, end: i64) {
|
||||
with_eb(lua, h, |eb| {
|
||||
let len = eb.text.len() as i64;
|
||||
let s = start.clamp(0, len);
|
||||
let mut e = if end < 0 || end > len { len } else { end };
|
||||
if e < s {
|
||||
e = len;
|
||||
}
|
||||
let s = snap_down(&eb.text, s as usize);
|
||||
let e = snap_down(&eb.text, e as usize);
|
||||
eb.sel_start = s;
|
||||
eb.sel_end = e;
|
||||
eb.cursor = e;
|
||||
eb.reset_blink();
|
||||
});
|
||||
}
|
||||
|
||||
// ── state helpers (pure over EditBoxState) ───────────────────────────────────────────────────
|
||||
|
||||
/// Delete the current selection (if any): remove `[min, max)`, collapse the caret to the left edge.
|
||||
fn delete_selection(eb: &mut EditBoxState) {
|
||||
if eb.sel_start == eb.sel_end {
|
||||
return;
|
||||
}
|
||||
let (a, b) = (eb.sel_start.min(eb.sel_end), eb.sel_start.max(eb.sel_end));
|
||||
eb.text.replace_range(a..b, "");
|
||||
eb.cursor = a;
|
||||
collapse(eb);
|
||||
}
|
||||
|
||||
/// Collapse the selection onto the caret (no selection).
|
||||
fn collapse(eb: &mut EditBoxState) {
|
||||
eb.sel_start = eb.cursor;
|
||||
eb.sel_end = eb.cursor;
|
||||
}
|
||||
|
||||
/// The fixed end of the current selection while extending (the endpoint that is *not* the caret; the
|
||||
/// caret itself when there is no selection).
|
||||
fn selection_anchor(eb: &EditBoxState) -> usize {
|
||||
if eb.sel_start == eb.cursor {
|
||||
eb.sel_end
|
||||
} else {
|
||||
eb.sel_start
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the selection to span `[min(a,b), max(a,b)]`.
|
||||
fn set_span(eb: &mut EditBoxState, a: usize, b: usize) {
|
||||
eb.sel_start = a.min(b);
|
||||
eb.sel_end = a.max(b);
|
||||
}
|
||||
|
||||
/// Enforce the length caps after an edit (`0x77c02d`): trim whole chars from the end while over
|
||||
/// `maxBytes`, then while over `maxLetters` (char count); clamp the caret/selection into the buffer.
|
||||
fn enforce_caps(eb: &mut EditBoxState) {
|
||||
if let Some(mb) = eb.max_bytes {
|
||||
while eb.text.len() > mb {
|
||||
eb.text.pop();
|
||||
}
|
||||
}
|
||||
if eb.max_letters > 0 {
|
||||
while eb.text.chars().count() > eb.max_letters {
|
||||
eb.text.pop();
|
||||
}
|
||||
}
|
||||
let len = eb.text.len();
|
||||
eb.cursor = snap_down(&eb.text, eb.cursor.min(len));
|
||||
eb.sel_start = snap_down(&eb.text, eb.sel_start.min(len));
|
||||
eb.sel_end = snap_down(&eb.text, eb.sel_end.min(len));
|
||||
}
|
||||
|
||||
/// The byte offset of the char boundary at or below `i` (identity if `i` already sits on one).
|
||||
fn snap_down(s: &str, i: usize) -> usize {
|
||||
let mut i = i.min(s.len());
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The next char boundary strictly after `i` (clamped to `len`).
|
||||
fn next_boundary(s: &str, i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < s.len() && !s.is_char_boundary(j) {
|
||||
j += 1;
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
/// The char boundary strictly before `i` (clamped to `0`).
|
||||
fn prev_boundary(s: &str, i: usize) -> usize {
|
||||
if i == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut j = i - 1;
|
||||
while j > 0 && !s.is_char_boundary(j) {
|
||||
j -= 1;
|
||||
}
|
||||
j
|
||||
with_eb(lua, h, |eb| eb.highlight_text(start, end));
|
||||
}
|
||||
|
||||
// ── text-region sync (the '*' mask lives here, RF-0082 §3) ───────────────────────────────────
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::{
|
||||
collapse, delete_selection, fire_script, frame_id_of, selection_anchor, set_focus_handle,
|
||||
set_span, sync_text_region, with_eb,
|
||||
};
|
||||
use super::{fire_script, frame_id_of, set_focus_handle, sync_text_region, with_eb};
|
||||
use crate::script::Model;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState};
|
||||
|
||||
@@ -62,9 +59,7 @@ pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) {
|
||||
let Some(h) = editbox_of(lua, id) else { return };
|
||||
let idx = index_at_screen_pos(lua, h, x, y);
|
||||
with_eb(lua, h, |eb| {
|
||||
eb.reset_blink();
|
||||
eb.cursor = idx;
|
||||
collapse(eb);
|
||||
eb.move_caret_to(idx, false);
|
||||
eb.drag_active = true;
|
||||
});
|
||||
set_focus_handle(lua, h);
|
||||
@@ -80,10 +75,7 @@ pub(in crate::script) fn drag_update(lua: &Lua, x: f32, y: f32) {
|
||||
let idx = index_at_screen_pos(lua, h, x, y);
|
||||
with_eb(lua, h, |eb| {
|
||||
if idx != eb.cursor {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = idx;
|
||||
set_span(eb, anchor, idx);
|
||||
eb.reset_blink();
|
||||
eb.move_caret_to(idx, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -100,47 +92,21 @@ pub(in crate::script) fn drag_end(lua: &Lua) {
|
||||
/// per-byte class array, benilla's alnum-run approximation is INFERRED): `shift` extends the
|
||||
/// selection from the fixed anchor like the char move; else the caret collapses there.
|
||||
pub(in crate::script) fn move_word(lua: &Lua, h: FrameHandle, right: bool, shift: bool) {
|
||||
with_eb(lua, h, |eb| {
|
||||
let target = eb.word_boundary(right);
|
||||
if shift {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = target;
|
||||
set_span(eb, anchor, target);
|
||||
} else {
|
||||
eb.cursor = target;
|
||||
collapse(eb);
|
||||
}
|
||||
eb.reset_blink();
|
||||
});
|
||||
with_eb(lua, h, |eb| eb.move_by_word(right, shift));
|
||||
}
|
||||
|
||||
/// Ctrl+C (`0x77e1d0`): the focused box's selected substring, `None` with no selection. A
|
||||
/// password box yields the mask run — the client copies a fixed placeholder, NEVER the text.
|
||||
pub(in crate::script) fn copy_selection(lua: &Lua) -> Option<String> {
|
||||
let h = focused(lua)?;
|
||||
with_eb(lua, h, |eb| {
|
||||
if eb.sel_start == eb.sel_end {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (eb.sel_start.min(eb.sel_end), eb.sel_start.max(eb.sel_end));
|
||||
Some(if eb.password {
|
||||
"*".repeat(eb.text[a..b].chars().count())
|
||||
} else {
|
||||
eb.text[a..b].to_string()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
with_eb(lua, h, |eb| eb.selected_text()).flatten()
|
||||
}
|
||||
|
||||
/// Ctrl+X: copy, then delete the selection (an ordinary edit — the region syncs and
|
||||
/// `OnTextChanged` fires).
|
||||
pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
|
||||
let copied = copy_selection(lua)?;
|
||||
let h = focused(lua)?;
|
||||
with_eb(lua, h, |eb| {
|
||||
delete_selection(eb);
|
||||
eb.reset_blink();
|
||||
});
|
||||
let copied = with_eb(lua, h, |eb| eb.cut_selection()).flatten()?;
|
||||
sync_text_region(lua, h);
|
||||
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
|
||||
Some(copied)
|
||||
|
||||
@@ -138,8 +138,9 @@ pub use trainer::{
|
||||
TrainerState,
|
||||
};
|
||||
pub use types::{
|
||||
EditAction, EditBoxTextUi, EditUnit, ExtractedQuad, FontObject, FontShadow, JustifyH, JustifyV,
|
||||
LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue, TexCoords,
|
||||
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
|
||||
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
|
||||
TexCoords,
|
||||
};
|
||||
pub(crate) use types::{MeasuredText, RegionData};
|
||||
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
|
||||
|
||||
@@ -448,45 +448,11 @@ pub struct EditBoxAdvanceRequest {
|
||||
pub key: u64,
|
||||
}
|
||||
|
||||
/// The text span a cursor motion or deletion operates over.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditUnit {
|
||||
/// One character (the plain arrow / Backspace / Delete granularity).
|
||||
Char,
|
||||
/// One word run (`EditBoxState::word_boundary` — the Ctrl/Option-arrow granularity).
|
||||
Word,
|
||||
/// The line edge: text start going back, text end going forward (Home/End, Cmd+arrow).
|
||||
Edge,
|
||||
}
|
||||
|
||||
/// One semantic text-editing operation on the focused EditBox — fed to
|
||||
/// [`UiScript::editbox_action`](super::UiScript::editbox_action). The host's per-OS keymap
|
||||
/// (which physical chord means which action: Ctrl+Left on Windows, Option+Left on macOS, …)
|
||||
/// translates key events into these; the *effect* of each action is the engine's byte-verified
|
||||
/// box law (RF-0082: selection anchoring, selection-first deletes, word classes). Clipboard
|
||||
/// operations are deliberately absent: they need the OS pasteboard, so they stay host-side
|
||||
/// (`editbox_copy`/`editbox_cut`/`paste`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditAction {
|
||||
/// Move the caret one `unit` back/forward; `extend` drags the selection from its fixed
|
||||
/// anchor (the Shift family). `Char` moves honor `ignoreArrows` (consumed but inert — the
|
||||
/// ref guard `0x77b18e`; arrows are the only chord source of `Char` moves).
|
||||
Move {
|
||||
unit: EditUnit,
|
||||
back: bool,
|
||||
extend: bool,
|
||||
},
|
||||
/// Delete one `unit` back/forward from the caret — the selection first when one exists
|
||||
/// (every deletion gesture collapses to "delete the selection"). `Edge` going back is the
|
||||
/// macOS Cmd+Backspace "clear to start".
|
||||
Delete { unit: EditUnit, back: bool },
|
||||
/// Select the whole text, caret to the end (the ref's Ctrl+A, `HighlightText(0, -1)`).
|
||||
SelectAll,
|
||||
/// Recall the previous (older) submitted line into the box (`historyLines`).
|
||||
HistoryPrev,
|
||||
/// Step back toward the newest line; past it, restore the stashed draft.
|
||||
HistoryNext,
|
||||
}
|
||||
// `EditAction`/`EditUnit` are the *editing law's* vocabulary, so they live beside the state that
|
||||
// law mutates ([`crate::widget::EditBoxState`]) rather than here — `script` depends on `widget`,
|
||||
// never the other way round, and the glue screens drive the law with no Lua VM in sight
|
||||
// (decision 0704). Re-exported here because this is the path the host has always used.
|
||||
pub use crate::widget::{EditAction, EditOutcome, EditUnit};
|
||||
|
||||
/// The focused EditBox's per-frame text-UI geometry — returned by
|
||||
/// [`UiScript::focused_editbox_text_ui`](super::UiScript::focused_editbox_text_ui): everything
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
use super::RegionHandle;
|
||||
|
||||
/// The text span a cursor motion or deletion operates over.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditUnit {
|
||||
/// One character (the plain arrow / Backspace / Delete granularity).
|
||||
Char,
|
||||
/// One word run ([`EditBoxState::word_boundary`] — the Ctrl/Option-arrow granularity).
|
||||
Word,
|
||||
/// The line edge: text start going back, text end going forward (Home/End, Cmd+arrow).
|
||||
Edge,
|
||||
}
|
||||
|
||||
/// One semantic text-editing operation on an edit box — fed to [`EditBoxState::apply`]. The host's
|
||||
/// per-OS keymap (which physical chord means which action: Ctrl+Left on Windows, Option+Left on
|
||||
/// macOS, …) translates key events into these; the *effect* of each action is the byte-verified box
|
||||
/// law (RF-0082: selection anchoring, selection-first deletes, word classes). Clipboard operations
|
||||
/// are deliberately absent: they need the OS pasteboard, so they stay host-side.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditAction {
|
||||
/// Move the caret one `unit` back/forward; `extend` drags the selection from its fixed
|
||||
/// anchor (the Shift family). `Char` moves honor `ignoreArrows` (consumed but inert — the
|
||||
/// ref guard `0x77b18e`; arrows are the only chord source of `Char` moves).
|
||||
Move {
|
||||
unit: EditUnit,
|
||||
back: bool,
|
||||
extend: bool,
|
||||
},
|
||||
/// Delete one `unit` back/forward from the caret — the selection first when one exists
|
||||
/// (every deletion gesture collapses to "delete the selection"). `Edge` going back is the
|
||||
/// macOS Cmd+Backspace "clear to start".
|
||||
Delete { unit: EditUnit, back: bool },
|
||||
/// Select the whole text, caret to the end (the ref's Ctrl+A, `HighlightText(0, -1)`).
|
||||
SelectAll,
|
||||
/// Recall the previous (older) submitted line into the box (`historyLines`).
|
||||
HistoryPrev,
|
||||
/// Step back toward the newest line; past it, restore the stashed draft.
|
||||
HistoryNext,
|
||||
}
|
||||
|
||||
/// A `CSimpleEditBox`'s runtime state — the byte-verified text/cursor/selection/flags model of
|
||||
/// RF-0082 (`rf82-editbox-runtime.md`). Offsets below are the client's **E-base** (the CScriptObject
|
||||
/// `this`), the base the runtime input/text handlers address the object through.
|
||||
@@ -393,6 +431,363 @@ impl EditBoxState {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The editing law (RF-0082 §3/§4) — pure over the state, no Lua, no widget tree
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// These used to live in `script::editbox` welded to the Lua layer, which meant the *only* way to
|
||||
// get the byte-verified box law was to be a FrameXML EditBox. The glue screens (login, character
|
||||
// create, the delete dialog) therefore each grew their own three-case imitation of it — append a
|
||||
// char, Backspace, Tab — with no caret movement, no selection, and no clipboard (decision 0704).
|
||||
// The law lives here now, and `script::editbox` is a thin wrapper that calls these and fires the
|
||||
// Lua events an [`EditOutcome`] tells it to. Anything with a `&mut EditBoxState` gets the real
|
||||
// law, whether or not there is a Lua VM anywhere near it.
|
||||
|
||||
/// What a pure edit changed, so a caller with events to fire knows which to fire. The FrameXML
|
||||
/// wrapper turns this into `OnTextChanged`/`OnSpacePressed`; the glue screens ignore it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EditOutcome {
|
||||
/// The text buffer changed — the `OnTextChanged` trigger.
|
||||
pub text_changed: bool,
|
||||
/// Spaces this edit inserted — the `OnSpacePressed` fire count. Only an insert of typed text
|
||||
/// reports these; a paste deliberately does not (a paste is not a typed space).
|
||||
pub spaces: usize,
|
||||
}
|
||||
|
||||
impl EditOutcome {
|
||||
/// A change that fires `OnTextChanged` and nothing else.
|
||||
fn changed(text_changed: bool) -> Self {
|
||||
EditOutcome {
|
||||
text_changed,
|
||||
spaces: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditBoxState {
|
||||
/// Apply one semantic [`EditAction`] — the single entry point the host's per-OS chord table
|
||||
/// feeds (decision 0301: the host owns *which chord*, this owns *what it does*).
|
||||
pub fn apply(&mut self, action: EditAction) -> EditOutcome {
|
||||
match action {
|
||||
EditAction::Move { unit, back, extend } => {
|
||||
match unit {
|
||||
// `ignoreArrows`: consumed but inert (guard `0x77b18e`). Arrows are the only
|
||||
// chord source of a Char move, and the ref's Ctrl bypass maps to Word.
|
||||
EditUnit::Char => {
|
||||
if !self.ignore_arrows {
|
||||
self.move_by_char(!back, extend);
|
||||
}
|
||||
}
|
||||
EditUnit::Word => self.move_by_word(!back, extend),
|
||||
EditUnit::Edge => self.move_to_edge(!back, extend),
|
||||
}
|
||||
EditOutcome::default()
|
||||
}
|
||||
EditAction::Delete { unit, back } => EditOutcome::changed(match unit {
|
||||
EditUnit::Char => self.delete_dir(!back),
|
||||
EditUnit::Word => {
|
||||
let t = self.word_boundary(!back);
|
||||
self.delete_to(t)
|
||||
}
|
||||
EditUnit::Edge => {
|
||||
let t = if back { 0 } else { self.text.len() };
|
||||
self.delete_to(t)
|
||||
}
|
||||
}),
|
||||
EditAction::SelectAll => {
|
||||
self.highlight_text(0, -1);
|
||||
EditOutcome::default()
|
||||
}
|
||||
// History recall is the caller's: it routes through the SetText path so the FrameXML
|
||||
// box also fires `OnTextSet`. `history_step` is the state half.
|
||||
EditAction::HistoryPrev | EditAction::HistoryNext => EditOutcome::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `ins` at the cursor (`0x77bee0`): replace any selection first; `numeric` aborts the
|
||||
/// insert **wholesale** on any non-digit (not per-char filtering — RF-0082 §3); splice, advance
|
||||
/// the caret, enforce the caps.
|
||||
pub fn insert(&mut self, ins: &str) -> EditOutcome {
|
||||
if self.numeric && !ins.chars().all(|c| c.is_ascii_digit()) {
|
||||
return EditOutcome::default();
|
||||
}
|
||||
self.end_history_browse(); // a typed edit turns a recalled line into an ordinary draft
|
||||
self.delete_selection();
|
||||
self.text.insert_str(self.cursor, ins);
|
||||
self.cursor += ins.len();
|
||||
self.collapse();
|
||||
self.enforce_caps();
|
||||
self.reset_blink();
|
||||
EditOutcome {
|
||||
text_changed: true,
|
||||
spaces: ins.matches(' ').count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert OS-clipboard text: [`insert`](Self::insert) with the paste sanitation — every control
|
||||
/// character is dropped, except `\n` into a multiline box. Reports no spaces: a paste is not a
|
||||
/// typed space, so it fires no `OnSpacePressed`.
|
||||
pub fn paste(&mut self, text: &str) -> EditOutcome {
|
||||
let cleaned: String = text
|
||||
.chars()
|
||||
.filter(|&c| c as u32 >= 0x20 || (self.multi_line && c == '\n'))
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
return EditOutcome::default();
|
||||
}
|
||||
EditOutcome::changed(self.insert(&cleaned).text_changed)
|
||||
}
|
||||
|
||||
/// `SetText` (`0x77be00`): short-circuits when unchanged (the caller then fires nothing); else
|
||||
/// replaces, caret to the end, caps enforced.
|
||||
pub fn set_text(&mut self, s: &str) -> bool {
|
||||
if self.text == s {
|
||||
return false;
|
||||
}
|
||||
self.text = s.to_string();
|
||||
self.cursor = self.text.len();
|
||||
self.collapse();
|
||||
self.enforce_caps();
|
||||
self.reset_blink();
|
||||
true
|
||||
}
|
||||
|
||||
/// The selected substring, or `None` with no selection (Ctrl+C, `0x77e1d0`). A password box
|
||||
/// yields its **mask run**, never the real text — the client copies a placeholder.
|
||||
pub fn selected_text(&self) -> Option<String> {
|
||||
if self.sel_start == self.sel_end {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (
|
||||
self.sel_start.min(self.sel_end),
|
||||
self.sel_start.max(self.sel_end),
|
||||
);
|
||||
Some(if self.password {
|
||||
"*".repeat(self.text[a..b].chars().count())
|
||||
} else {
|
||||
self.text[a..b].to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl+X: [`selected_text`](Self::selected_text), then delete the selection.
|
||||
pub fn cut_selection(&mut self) -> Option<String> {
|
||||
let taken = self.selected_text()?;
|
||||
self.end_history_browse();
|
||||
self.delete_selection();
|
||||
self.reset_blink();
|
||||
Some(taken)
|
||||
}
|
||||
|
||||
/// `HighlightText` (`0x77cca0`), the client's exact clamp: `start = clamp(start, 0..=len)`;
|
||||
/// `end = (end < 0 || end > len) ? len : end`; then `if end < start { end = len }` — so
|
||||
/// `(0, -1)` selects all. Byte offsets, snapped to char boundaries.
|
||||
pub fn highlight_text(&mut self, start: i64, end: i64) {
|
||||
let len = self.text.len() as i64;
|
||||
let s = start.clamp(0, len);
|
||||
let mut e = if end < 0 || end > len { len } else { end };
|
||||
if e < s {
|
||||
e = len;
|
||||
}
|
||||
self.sel_start = snap_down(&self.text, s as usize);
|
||||
self.sel_end = snap_down(&self.text, e as usize);
|
||||
self.cursor = self.sel_end;
|
||||
self.reset_blink();
|
||||
}
|
||||
|
||||
/// BACKSPACE / DELETE: the selection when there is one, else the char before (`forward=false`)
|
||||
/// or after the caret. `true` when anything was removed.
|
||||
pub fn delete_dir(&mut self, forward: bool) -> bool {
|
||||
self.end_history_browse();
|
||||
let did = 'del: {
|
||||
if self.sel_start != self.sel_end {
|
||||
self.delete_selection();
|
||||
break 'del true;
|
||||
}
|
||||
if forward {
|
||||
if self.cursor < self.text.len() {
|
||||
let end = next_boundary(&self.text, self.cursor);
|
||||
self.text.replace_range(self.cursor..end, "");
|
||||
break 'del true;
|
||||
}
|
||||
} else if self.cursor > 0 {
|
||||
let start = prev_boundary(&self.text, self.cursor);
|
||||
self.text.replace_range(start..self.cursor, "");
|
||||
self.cursor = start;
|
||||
self.collapse();
|
||||
break 'del true;
|
||||
}
|
||||
false
|
||||
};
|
||||
if did {
|
||||
self.reset_blink();
|
||||
}
|
||||
did
|
||||
}
|
||||
|
||||
/// Word/edge delete: the selection first when one exists, else the span between the caret and
|
||||
/// `target`. `true` when anything was removed.
|
||||
pub fn delete_to(&mut self, target: usize) -> bool {
|
||||
self.end_history_browse();
|
||||
let did = if self.sel_start != self.sel_end {
|
||||
self.delete_selection();
|
||||
true
|
||||
} else {
|
||||
let (a, b) = (target.min(self.cursor), target.max(self.cursor));
|
||||
if a == b {
|
||||
false
|
||||
} else {
|
||||
self.text.replace_range(a..b, "");
|
||||
self.cursor = a;
|
||||
self.collapse();
|
||||
true
|
||||
}
|
||||
};
|
||||
if did {
|
||||
self.reset_blink();
|
||||
}
|
||||
did
|
||||
}
|
||||
|
||||
/// LEFT/RIGHT one char: `extend` drags the selection from its fixed anchor; otherwise the caret
|
||||
/// collapses onto the selection edge (when there is one) or steps one char.
|
||||
pub fn move_by_char(&mut self, right: bool, extend: bool) {
|
||||
let step = |s: &str, i: usize| {
|
||||
if right {
|
||||
next_boundary(s, i)
|
||||
} else {
|
||||
prev_boundary(s, i)
|
||||
}
|
||||
};
|
||||
if extend {
|
||||
let anchor = self.selection_anchor();
|
||||
self.cursor = step(&self.text, self.cursor);
|
||||
self.set_span(anchor, self.cursor);
|
||||
} else if self.sel_start != self.sel_end {
|
||||
self.cursor = if right { self.sel_end } else { self.sel_start };
|
||||
self.collapse();
|
||||
} else {
|
||||
self.cursor = step(&self.text, self.cursor);
|
||||
self.collapse();
|
||||
}
|
||||
self.reset_blink();
|
||||
}
|
||||
|
||||
/// Ctrl/Option+arrow: the caret to the next [`word_boundary`](Self::word_boundary).
|
||||
pub fn move_by_word(&mut self, right: bool, extend: bool) {
|
||||
let target = self.word_boundary(right);
|
||||
self.move_caret_to(target, extend);
|
||||
}
|
||||
|
||||
/// HOME/END (and Cmd+arrow): the caret to `0` / `len`.
|
||||
pub fn move_to_edge(&mut self, end: bool, extend: bool) {
|
||||
let target = if end { self.text.len() } else { 0 };
|
||||
self.move_caret_to(target, extend);
|
||||
}
|
||||
|
||||
/// Place the caret at `target`, extending the selection from its fixed anchor when `extend`.
|
||||
/// The shared tail of every non-char move (and of a mouse click/drag).
|
||||
pub fn move_caret_to(&mut self, target: usize, extend: bool) {
|
||||
let target = snap_down(&self.text, target.min(self.text.len()));
|
||||
if extend {
|
||||
let anchor = self.selection_anchor();
|
||||
self.cursor = target;
|
||||
self.set_span(anchor, target);
|
||||
} else {
|
||||
self.cursor = target;
|
||||
self.collapse();
|
||||
}
|
||||
self.reset_blink();
|
||||
}
|
||||
|
||||
/// Delete the current selection: remove `[min, max)` and collapse the caret to its left edge.
|
||||
fn delete_selection(&mut self) {
|
||||
if self.sel_start == self.sel_end {
|
||||
return;
|
||||
}
|
||||
let (a, b) = (
|
||||
self.sel_start.min(self.sel_end),
|
||||
self.sel_start.max(self.sel_end),
|
||||
);
|
||||
self.text.replace_range(a..b, "");
|
||||
self.cursor = a;
|
||||
self.collapse();
|
||||
}
|
||||
|
||||
/// Collapse the selection onto the caret.
|
||||
fn collapse(&mut self) {
|
||||
self.sel_start = self.cursor;
|
||||
self.sel_end = self.cursor;
|
||||
}
|
||||
|
||||
/// The fixed end of the selection while extending — the endpoint that is *not* the caret (the
|
||||
/// caret itself when nothing is selected).
|
||||
fn selection_anchor(&self) -> usize {
|
||||
if self.sel_start == self.cursor {
|
||||
self.sel_end
|
||||
} else {
|
||||
self.sel_start
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the selection to `[min(a,b), max(a,b)]`.
|
||||
fn set_span(&mut self, a: usize, b: usize) {
|
||||
self.sel_start = a.min(b);
|
||||
self.sel_end = a.max(b);
|
||||
}
|
||||
|
||||
/// Enforce the length caps after an edit (`0x77c02d`): trim whole chars from the end while over
|
||||
/// `maxBytes`, then while over `maxLetters`; clamp caret/selection back into the buffer.
|
||||
fn enforce_caps(&mut self) {
|
||||
if let Some(mb) = self.max_bytes {
|
||||
while self.text.len() > mb {
|
||||
self.text.pop();
|
||||
}
|
||||
}
|
||||
if self.max_letters > 0 {
|
||||
while self.text.chars().count() > self.max_letters {
|
||||
self.text.pop();
|
||||
}
|
||||
}
|
||||
let len = self.text.len();
|
||||
self.cursor = snap_down(&self.text, self.cursor.min(len));
|
||||
self.sel_start = snap_down(&self.text, self.sel_start.min(len));
|
||||
self.sel_end = snap_down(&self.text, self.sel_end.min(len));
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte offset of the char boundary at or below `i` (identity if `i` is already on one).
|
||||
fn snap_down(s: &str, i: usize) -> usize {
|
||||
let mut i = i.min(s.len());
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The next char boundary strictly after `i` (clamped to `len`).
|
||||
fn next_boundary(s: &str, i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < s.len() && !s.is_char_boundary(j) {
|
||||
j += 1;
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
/// The char boundary strictly before `i` (clamped to `0`).
|
||||
fn prev_boundary(s: &str, i: usize) -> usize {
|
||||
if i == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut j = i - 1;
|
||||
while j > 0 && !s.is_char_boundary(j) {
|
||||
j -= 1;
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod row_law_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -173,10 +173,11 @@ impl<T> Arena<T> {
|
||||
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
ButtonState, CooldownState, EditBoxState, FrameKind, KindState, MessageLine, MinimapState,
|
||||
RegionKind, ScrollFrameState, ScrollingMessageState, SliderState, StatusBarState, TooltipState,
|
||||
COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
ButtonState, CooldownState, EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind,
|
||||
KindState, MessageLine, MinimapState, RegionKind, ScrollFrameState, ScrollingMessageState,
|
||||
SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ZOOM,
|
||||
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
TOOLTIP_WRAP_WIDTH,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -488,6 +488,18 @@ pub fn crop(img: &RgbImage, rect: Rect) -> RgbImage {
|
||||
})
|
||||
}
|
||||
|
||||
/// Magnify by an integer factor, nearest-neighbour — every output pixel is a source pixel verbatim.
|
||||
/// This is the only scaling the `crop` subcommand offers on purpose: a resampler blends neighbours,
|
||||
/// and a blended edge has already minted a false finding once (a `sips` downscale's "magenta
|
||||
/// fringe" read as a render defect). Zoom may enlarge, never shrink: measurements happen at source
|
||||
/// resolution or not at all.
|
||||
pub fn zoom(img: &RgbImage, factor: u32) -> RgbImage {
|
||||
let s = factor.max(1);
|
||||
RgbImage::from_fn(img.width() * s, img.height() * s, |x, y| {
|
||||
*img.get_pixel(x / s, y / s)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lay tiles out left-to-right, top-to-bottom in a `cols`-wide grid on a mid-grey mat, so a burst's
|
||||
/// crops read as one contact sheet. Tiles of different sizes are placed at their own size.
|
||||
pub fn contact_strip(tiles: &[RgbImage], cols: u32, gap: u32) -> RgbImage {
|
||||
@@ -597,6 +609,22 @@ mod tests {
|
||||
assert!((m.mae - 2.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_pixel_verbatim() {
|
||||
// A 2x1 two-colour image zoomed ×3: every output pixel must be one of the two source
|
||||
// values, block-aligned — any third colour would mean a resampler blended an edge.
|
||||
let mut img = solid(2, 1, [10, 20, 30]);
|
||||
img.put_pixel(1, 0, Rgb([200, 100, 50]));
|
||||
let z = zoom(&img, 3);
|
||||
assert_eq!(z.dimensions(), (6, 3));
|
||||
for (x, _, p) in z.enumerate_pixels() {
|
||||
let want = if x < 3 { [10, 20, 30] } else { [200, 100, 50] };
|
||||
assert_eq!(p.0, want);
|
||||
}
|
||||
// Factor 0 clamps to 1 (identity), never a panic or an empty image.
|
||||
assert_eq!(zoom(&img, 0).dimensions(), img.dimensions());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_mismatch_errors() {
|
||||
let a = solid(4, 4, [0, 0, 0]);
|
||||
|
||||
@@ -19,8 +19,8 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use benilla_visual::{
|
||||
compare, compose_side_by_side, contact_strip, crop, diff_image, envelope, shape, toggles,
|
||||
Metrics, OVER_THRESHOLD,
|
||||
compare, compose_side_by_side, contact_strip, crop, diff_image, envelope, shape, toggles, zoom,
|
||||
Metrics, Rect, OVER_THRESHOLD,
|
||||
};
|
||||
|
||||
/// A frame-to-frame step must move a channel by this much to count as a direction change (override
|
||||
@@ -61,6 +61,8 @@ struct Opts {
|
||||
toggle_delta: u8,
|
||||
pad: u32,
|
||||
at: Option<String>,
|
||||
rect: Option<String>,
|
||||
scale: u32,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -73,6 +75,8 @@ fn main() -> Result<()> {
|
||||
toggle_delta: TOGGLE_MIN_DELTA,
|
||||
pad: DEFAULT_PAD,
|
||||
at: None,
|
||||
rect: None,
|
||||
scale: 1,
|
||||
};
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
@@ -101,6 +105,12 @@ fn main() -> Result<()> {
|
||||
.context("--pad not an integer")?
|
||||
}
|
||||
"--at" => opts.at = Some(next(&mut it, "--at")?),
|
||||
"--rect" => opts.rect = Some(next(&mut it, "--rect")?),
|
||||
"--scale" => {
|
||||
opts.scale = next(&mut it, "--scale")?
|
||||
.parse()
|
||||
.context("--scale not an integer")?
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
@@ -186,6 +196,15 @@ fn main() -> Result<()> {
|
||||
.context("compose-dir needs --out <dir>")?;
|
||||
compose_dir(Path::new(da), Path::new(db), out)?;
|
||||
}
|
||||
"crop" => {
|
||||
let [img] = one(rest, "crop")?;
|
||||
let rect = opts
|
||||
.rect
|
||||
.as_deref()
|
||||
.context("crop needs --rect <x>,<y>,<w>,<h> (source pixels)")?;
|
||||
let out = opts.out.as_deref().context("crop needs --out <crop.png>")?;
|
||||
crop_cmd(Path::new(img), rect, opts.at.as_deref(), opts.scale, out)?;
|
||||
}
|
||||
other => {
|
||||
print_usage();
|
||||
bail!("unknown subcommand {other:?}");
|
||||
@@ -238,6 +257,98 @@ fn series(dir: &Path, at: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cut a window out of a capture, magnify it, and report what was actually cut — the vetted
|
||||
/// replacement for the ad-hoc `sips`/`ffmpeg` crop pipelines that kept minting false findings
|
||||
/// (`sips --cropOffset` takes (y, x) and silently ignores what it can't parse; a guessed ffmpeg
|
||||
/// window read "no eyes" off a frame the model had left). Three honesty rules: a rect that misses
|
||||
/// the frame entirely is an ERROR, never an edge-clamped guess; a rect that only partially fits is
|
||||
/// clamped *out loud*; and every pixel sample prints in SOURCE coordinates at source resolution, so
|
||||
/// nothing downstream does coordinate math on a zoomed image.
|
||||
fn crop_cmd(path: &Path, rect: &str, at: Option<&str>, scale: u32, out: &Path) -> Result<()> {
|
||||
let img = load(path)?;
|
||||
let (w, h) = img.dimensions();
|
||||
let want = parse_rect(rect)?;
|
||||
if want.x0 >= w || want.y0 >= h {
|
||||
bail!(
|
||||
"--rect {rect} starts outside the {w}x{h} frame — re-aim; a silently moved window is \
|
||||
exactly the mis-crop trap this tool exists to close"
|
||||
);
|
||||
}
|
||||
let r = Rect {
|
||||
x0: want.x0,
|
||||
y0: want.y0,
|
||||
x1: want.x1.min(w),
|
||||
y1: want.y1.min(h),
|
||||
};
|
||||
let clamped = r != want;
|
||||
let cut = crop(&img, r);
|
||||
let zoomed = zoom(&cut, scale);
|
||||
zoomed
|
||||
.save(out)
|
||||
.with_context(|| format!("writing {}", out.display()))?;
|
||||
println!(
|
||||
"{}: {w}x{h} → rect [{}..{}, {}..{}] ({}x{}){} ×{} → {} ({}x{})",
|
||||
path.display(),
|
||||
r.x0,
|
||||
r.x1,
|
||||
r.y0,
|
||||
r.y1,
|
||||
r.width(),
|
||||
r.height(),
|
||||
if clamped { " CLAMPED to frame" } else { "" },
|
||||
scale.max(1),
|
||||
out.display(),
|
||||
zoomed.width(),
|
||||
zoomed.height(),
|
||||
);
|
||||
// Samples: the rect's centre always, plus any --at points — all in source coordinates.
|
||||
let mut samples = vec![((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)];
|
||||
if let Some(at) = at {
|
||||
samples.extend(parse_at(at)?);
|
||||
}
|
||||
for (x, y) in samples {
|
||||
if x >= w || y >= h {
|
||||
println!(" ({x}, {y}): outside the {w}x{h} frame");
|
||||
continue;
|
||||
}
|
||||
let p = img.get_pixel(x, y).0;
|
||||
let luma = 0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2]);
|
||||
let outside = if x < r.x0 || x >= r.x1 || y < r.y0 || y >= r.y1 {
|
||||
" (outside the crop)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(
|
||||
" ({x}, {y}): rgb({:3}, {:3}, {:3}) luma {luma:.2}{outside}",
|
||||
p[0], p[1], p[2]
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `"x,y,w,h"` → the rect it names. Strict for the same reason as [`parse_at`]: a half-parsed
|
||||
/// window silently crops the wrong place, and a wrong crop reads as a finding.
|
||||
fn parse_rect(spec: &str) -> Result<Rect> {
|
||||
let parts: Vec<&str> = spec.split(',').map(str::trim).collect();
|
||||
let [x, y, w, h] = parts.as_slice() else {
|
||||
bail!("--rect wants \"<x>,<y>,<w>,<h>\", got {spec:?}");
|
||||
};
|
||||
let parse = |v: &str, name: &str| -> Result<u32> {
|
||||
v.parse().with_context(|| format!("--rect {name} {v:?}"))
|
||||
};
|
||||
let (x, y) = (parse(x, "x")?, parse(y, "y")?);
|
||||
let (w, h) = (parse(w, "w")?, parse(h, "h")?);
|
||||
if w == 0 || h == 0 {
|
||||
bail!("--rect wants a non-empty window, got {w}x{h}");
|
||||
}
|
||||
Ok(Rect {
|
||||
x0: x,
|
||||
y0: y,
|
||||
x1: x + w,
|
||||
y1: y + h,
|
||||
})
|
||||
}
|
||||
|
||||
/// `"x,y;x,y"` → pixels. Strict: a typo here would silently report the wrong pixel's history.
|
||||
fn parse_at(spec: &str) -> Result<Vec<(u32, u32)>> {
|
||||
spec.split(';')
|
||||
@@ -637,7 +748,8 @@ fn print_usage() {
|
||||
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
|
||||
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
|
||||
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
|
||||
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n\
|
||||
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n \
|
||||
benilla-visual crop <img.png> --rect \"<x>,<y>,<w>,<h>\" --out <crop.png> [--scale <n>] [--at \"<x>,<y>;…\"]\n\
|
||||
\n\
|
||||
flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\
|
||||
the parked reading (envelope) and the moving-camera one (toggles).\n\
|
||||
@@ -645,6 +757,25 @@ fn print_usage() {
|
||||
surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\
|
||||
series drops the averaging: one named pixel's rgb/luma per frame, to line up against the\n\
|
||||
client's WOW_DEPTH log at the same coordinates.\n\
|
||||
crop cuts a window for close reading (nearest-neighbour zoom only, samples printed in\n\
|
||||
SOURCE coordinates) — use it instead of sips/ffmpeg pipelines, which have minted false\n\
|
||||
findings (sips --cropOffset is (y, x) and silently ignores what it can't parse).\n\
|
||||
Exits non-zero if any image's MAE exceeds --fail."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rect_spec_is_strict() {
|
||||
// "x,y,w,h" and nothing else — a half-parsed window crops the wrong place silently.
|
||||
let r = parse_rect("10, 20, 30, 40").unwrap();
|
||||
assert_eq!((r.x0, r.y0, r.x1, r.y1), (10, 20, 40, 60));
|
||||
assert!(parse_rect("10,20,30").is_err());
|
||||
assert!(parse_rect("10,20,30,40,50").is_err());
|
||||
assert!(parse_rect("10,20,0,40").is_err());
|
||||
assert!(parse_rect("a,20,30,40").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ tracy = ["bevy/trace_tracy"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# OS clipboard access for pasting into UI EditBoxes (chat input). Already in the tree via bevy_egui;
|
||||
# pinned to the same 3.x so there's one copy. Read on the main thread (the UI key feed is a NonSend
|
||||
# system) — required for the macOS NSPasteboard path.
|
||||
# OS clipboard access for the UI EditBox copy/cut/paste chords (chat input). Already in the tree via
|
||||
# bevy_egui; pinned to the same 3.x so there's one copy. Read on the main thread (the UI key feed is
|
||||
# a NonSend system) — required for the macOS NSPasteboard path. Covers macOS, Windows, and Linux/BSD
|
||||
# under X11; the Wayland backend is `smithay-clipboard` below (decision 0702).
|
||||
arboard = "3"
|
||||
benilla-ui = { path = "../benilla-ui" }
|
||||
bevy.workspace = true
|
||||
@@ -58,6 +59,18 @@ mint = "0.5"
|
||||
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
|
||||
rand = "0.8"
|
||||
|
||||
# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a
|
||||
# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its
|
||||
# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails.
|
||||
# smithay-clipboard is the right crate for a windowed app: it drives the *standard* `wl_data_device`
|
||||
# off our own `wl_display`/seat, which every compositor supports. arboard's own Wayland feature would
|
||||
# not do: it speaks `wlr-data-control`, a privileged manager protocol GNOME/Mutter deliberately
|
||||
# doesn't implement. `raw-window-handle` (pinned to the 0.6 already in the tree via bevy_window, so
|
||||
# this adds no crate) is only needed to read the `wl_display` out of bevy's `RawHandleWrapper`.
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
smithay-clipboard = "0.7"
|
||||
|
||||
# macOS: drive a native NSCursor for a crisp hardware cursor. winit drives the cursor via AppKit
|
||||
# cursor-rects, which our continuous Metal render loop doesn't honor on mouse-move (custom cursor /
|
||||
# hide revert to the arrow); we bypass it with NSCursor + NSWindow::disableCursorRects. See
|
||||
@@ -67,6 +80,8 @@ objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = [
|
||||
"NSApplication",
|
||||
"NSResponder",
|
||||
# NSApplicationActivationPolicy (bgwin's Accessory demotion) is declared in this header.
|
||||
"NSRunningApplication",
|
||||
"NSWindow",
|
||||
"NSView",
|
||||
"NSImage",
|
||||
|
||||
@@ -433,14 +433,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
|
||||
let n1 = vec4<f32>(n_lit, 1.0);
|
||||
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
|
||||
// Exterior world doodad/entity: the FFP directional matte, scaled by the per-instance
|
||||
// Exterior world doodad/entity: a hard-cutoff directional matte, scaled by the per-instance
|
||||
// INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on MCSH-shadowed
|
||||
// ground / 1.0 day-night (`0x69e4ad`, `0x69e280`). Decision 0410 moved this lane from the
|
||||
// `Model2.bls` SH lobe (0354/0358 — the mainstream VS-path curve) back to the FFP matte —
|
||||
// both are reference-real (the FFP leg is the fixed-function config, byte-verified in wow-re
|
||||
// unit-light-combine-storm.md); the SH wrap fed warm sun onto the anti-sun side, which the
|
||||
// director rejected: a sunlit character's shadow side must read the SAME as the skin standing
|
||||
// in shade, and under the hard `max(N·L, 0)` cutoff both sit exactly at the ambient level.
|
||||
// ground / 1.0 day-night (`0x69e4ad`, `0x69e280`). Lane history: the `Model2.bls` SH lobe
|
||||
// (0354/0358, the mainstream VS-path curve) → the FFP matte with a hard cutoff (the SH wrap
|
||||
// fed warm sun onto the anti-sun side; director's call: a sunlit character's shadow side
|
||||
// must read the SAME as the skin standing in shade, so both sit exactly at the ambient
|
||||
// level) → the same hard cutoff on an UNCLAMPED source (0706, below).
|
||||
//
|
||||
// The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 =
|
||||
// the lit-ground family (ADT doodads and every entity M2 — intensity 2.5, mixed toward 0.5 by
|
||||
@@ -453,21 +452,18 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
let shade_t = max(mat_shade, inst_shade);
|
||||
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
|
||||
let intensity = select(mix(2.5, 0.5, shade_t), 1.0, mid_band);
|
||||
// Decision 0410: the exterior doodad/entity lane lights with the FFP matte — a HARD sun
|
||||
// cutoff, `clamp01(ambient + clamp01(I·D)·max(N·L,0))` (the reference's own fixed-function
|
||||
// config leg, byte-verified in wow-re unit-light-combine-storm.md) — instead of the SH lobe.
|
||||
// The SH curve's authored wrap fed 0.059·C to the anti-sun side and dipped −0.037·C mid-back;
|
||||
// with the strong orange day diffuse at I=2.5 that made a sunlit character's shadow side both
|
||||
// tinted and brighter than the same skin standing in shade. Director's call: the shadow side
|
||||
// must read the SAME as standing in shade — under the hard cutoff both sit exactly at the
|
||||
// ambient level. (The SH rows stay packed; the interior-prop probe lane still uses the fold.)
|
||||
let d_ffp = clamp(
|
||||
wow_light.light_diffuse.rgb * intensity,
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
// Hard sun cutoff on an UNCLAMPED source: `clamp01(ambient + I·D·max(N·L,0))` — ONE
|
||||
// saturating clamp, at the sum (decision 0706, which keeps the earlier director ruling:
|
||||
// the anti-sun side sits exactly at ambient, no SH wrap). The old per-channel source
|
||||
// pre-clamp `clamp01(I·D)` was faithful to NEITHER reference leg — the FFP leg
|
||||
// peak-NORMALIZES (`(I·D)/max(1,maxch)`, hue-preserving, glue-model-lighting §4) and the
|
||||
// mainstream Model2.bls SH path consumes the moments unclamped — and it bleached the sun:
|
||||
// the EK dawn diffuse (254,123,51) at I=2.5 pre-clamped to (1,1,.5), which is bug B33/B97
|
||||
// (bone-white fences, pale-olive beams, where the reference renders both dark-warm). The
|
||||
// unclamped sum matches the SH fidelity target's colour response to ≤10% on the sun side
|
||||
// (equal at the pole and at μ≈0.2) while the hard cutoff stands in for the rejected wrap.
|
||||
let lit_doodad = clamp(
|
||||
wow_light.light_ambient.rgb + d_ffp * ndotl,
|
||||
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * intensity * ndotl,
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Background instrumented runs: probe/capture/regression windows that never fight the
|
||||
//! director's screen.
|
||||
//!
|
||||
//! Agent-driven runs (captures, live probes, the rig, FPS journals) open a real window for
|
||||
//! seconds to minutes on the same desktop the director is working on. Left alone, each launch
|
||||
//! stole their focus twice over:
|
||||
//!
|
||||
//! - **Window-side** — the primary window opened focused (`makeKeyAndOrderFront`), swallowing
|
||||
//! whatever they were typing (on 2026-07-19 a login-shot run typed their keystrokes into the
|
||||
//! account box). `Window::focused = false` fixed that for capture/login-shot runs only; every
|
||||
//! live probe still opened key.
|
||||
//! - **App-side (macOS)** — winit's AppKit backend unconditionally calls
|
||||
//! `activateIgnoringOtherApps` at `applicationDidFinishLaunching` and sets the Regular
|
||||
//! activation policy, so even an unfocused run activated the app: menu bar switched, a Dock
|
||||
//! icon appeared, and the window ordered in **front** of every other app's. bevy_winit 0.18
|
||||
//! exposes no `EventLoopBuilderExtMacOS` hook to turn either off, so it can't be prevented —
|
||||
//! only undone, immediately after launch (decision 0703).
|
||||
//!
|
||||
//! [`background_run`] detects an instrumented run from the env that drives it (no recipe or
|
||||
//! script changes anywhere), `main` opens the window unfocused **and**
|
||||
//! [`bevy::window::WindowLevel::AlwaysOnBottom`] — on screen (so surface rendering, readback
|
||||
//! and screenshots are untouched; it sits at `kCGNormalWindowLevel - 1`) but *under* every
|
||||
//! normal window — and [`BgWinPlugin`] demotes the macOS app to the Accessory activation
|
||||
//! policy (no Dock icon, no Cmd-Tab entry, no menu-bar takeover) and hands activation straight
|
||||
//! back to whatever the director was using.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Env prefixes that mark a run as agent-driven — the capture harness, shots/smokes, and the
|
||||
/// live-probe fleet. Deliberately only *run-driving* switches: a plain modifier the director
|
||||
/// might set on an attended run (`WOW_GM`, `WOW_FARCLIP`, traces…) must NOT push their own
|
||||
/// window to the bottom of the stack. A new probe env usually starts with `WOW_PROBE`/
|
||||
/// `WOW_CAPTURE` and is covered for free; one that doesn't shows up as "the new probe window
|
||||
/// opened focused" and gets its prefix added here.
|
||||
const BG_ENV_PREFIXES: &[&str] = &[
|
||||
"WOW_AUDIT_",
|
||||
"WOW_CAPTURE",
|
||||
"WOW_CHARCREATE_SHOT",
|
||||
"WOW_CHARSELECT_SHOT",
|
||||
"WOW_CREATE_TEST",
|
||||
"WOW_DEPTH",
|
||||
"WOW_FPS_",
|
||||
"WOW_GLUE_ROUNDTRIP",
|
||||
"WOW_LIVE_",
|
||||
"WOW_LOGIN_SHOT",
|
||||
"WOW_LOGIN_SMOKE",
|
||||
"WOW_LOGOUT_SMOKE",
|
||||
"WOW_MM_BLIP_PROBE",
|
||||
"WOW_MM_PROBE",
|
||||
"WOW_NODE_PROBE",
|
||||
"WOW_PARTICLE_CENSUS",
|
||||
"WOW_PHASE",
|
||||
"WOW_PICK",
|
||||
"WOW_PORTRAIT_TEST",
|
||||
"WOW_PROBE",
|
||||
"WOW_RIG",
|
||||
];
|
||||
|
||||
/// Is this an instrumented background run? `WOW_BG=1` forces yes on any run, `WOW_BG=0` forces
|
||||
/// no (the escape hatch when the director wants to *watch* a probe live); otherwise, auto-detect
|
||||
/// from the run-driving env ([`BG_ENV_PREFIXES`]).
|
||||
pub fn background_run() -> bool {
|
||||
match std::env::var("WOW_BG").as_deref() {
|
||||
Ok("0") => return false,
|
||||
Ok(_) => return true,
|
||||
Err(_) => {}
|
||||
}
|
||||
std::env::vars_os().any(|(name, _)| {
|
||||
name.to_str()
|
||||
.is_some_and(|name| BG_ENV_PREFIXES.iter().any(|p| name.starts_with(p)))
|
||||
})
|
||||
}
|
||||
|
||||
/// On a [`background_run`], undo winit's launch-time app activation (macOS; no-op elsewhere).
|
||||
/// The window-side half (unfocused + always-on-bottom) lives where the window is built, in
|
||||
/// `main` — this plugin owns only the app-side half.
|
||||
pub struct BgWinPlugin;
|
||||
|
||||
impl Plugin for BgWinPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
if !background_run() {
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"bgwin: instrumented run — window opens unfocused at the bottom of the stack, \
|
||||
app demoted from Dock/Cmd-Tab (WOW_BG=0 for a normal focused window)"
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
app.add_systems(PreStartup, macos::demote)
|
||||
.add_systems(Update, macos::hold_background);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = app;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use bevy::ecs::system::NonSendMarker;
|
||||
use bevy::prelude::*;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
||||
use objc2_foundation::MainThreadMarker;
|
||||
|
||||
/// How many frames [`hold_background`] keeps watching for a late-landing activation.
|
||||
/// winit's `activateIgnoringOtherApps` is a WindowServer round-trip: it can be *granted*
|
||||
/// after `PreStartup`'s [`demote`] already ran, which would leave the app active with no
|
||||
/// one to deactivate it. A couple of seconds of frames covers the grant comfortably.
|
||||
const HOLD_FRAMES: u32 = 120;
|
||||
|
||||
/// `PreStartup` (after winit's `applicationDidFinishLaunching` set Regular + activated):
|
||||
/// demote to Accessory — no Dock icon, no Cmd-Tab entry, no menu-bar takeover — and give
|
||||
/// activation back to the app the director was using. The `NonSendMarker` param pins the
|
||||
/// system to the main thread (AppKit's requirement).
|
||||
pub(super) fn demote(_main_thread: NonSendMarker) {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
// SAFETY: main thread (the `NonSendMarker` param + the marker check above).
|
||||
unsafe {
|
||||
if app.isActive() {
|
||||
app.deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first [`HOLD_FRAMES`] frames: if the launch-time activation lands *after*
|
||||
/// [`demote`], hand it back again. Self-quiets after the window; a no-op thereafter.
|
||||
pub(super) fn hold_background(_main_thread: NonSendMarker, mut frames: Local<u32>) {
|
||||
if *frames > HOLD_FRAMES {
|
||||
return;
|
||||
}
|
||||
*frames += 1;
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
// SAFETY: main thread (the `NonSendMarker` param + the marker check above).
|
||||
unsafe {
|
||||
if app.isActive() {
|
||||
app.deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The LIVE probe shot — one screenshot (or an adjacent-frame burst) of a NORMAL connected run,
|
||||
//! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost
|
||||
//! avatar renders the whole world through the death filter) and the subject gate
|
||||
//! (`WOW_SHOT_REQUIRE` — a frame that cannot contain the reported subject is not a measurement of
|
||||
//! it). Split from `probes.rs` when the gates outgrew it; the sibling probe one-shots stay there.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
|
||||
|
||||
use benilla_protocol::guid;
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
|
||||
use crate::player::WorldCamera;
|
||||
|
||||
/// How far the subject gate's WARN looks when listing what *is* nearby — wide enough to catch the
|
||||
/// "right room, wrong wing" mis-aim even when `WOW_SHOT_REQUIRE_DIST` is tight.
|
||||
const REQUIRE_NEARBY_RANGE: f32 = 100.0;
|
||||
|
||||
/// Seconds between subject-gate WARNs while waiting. Frequent enough to be read live, rare enough
|
||||
/// that a log grep isn't wading through it.
|
||||
const REQUIRE_REWARN_SECS: f32 = 5.0;
|
||||
|
||||
/// The LIVE probe shot (`WOW_LIVE_SHOT=<png>`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12):
|
||||
/// one screenshot of the primary window on a NORMAL connected run — the "what does the live scene
|
||||
/// actually look like" instrument (server GameObjects, event spawns, NPCs — everything the
|
||||
/// server-less harness deliberately excludes). The app keeps running after the save; pair with
|
||||
/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike
|
||||
/// [`super::CapturePlugin`], nothing is pinned — the shot shows the scene as a player would see it.
|
||||
///
|
||||
/// **`WOW_LIVE_SHOT_COUNT=<n>` makes it a burst** — `n` shots written as `<stem>-000.png`,
|
||||
/// `<stem>-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**,
|
||||
/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is
|
||||
/// unchanged.
|
||||
///
|
||||
/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one**
|
||||
/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had:
|
||||
/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7
|
||||
/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it
|
||||
/// into arithmetic: `benilla-visual flicker <dir>` collapses the burst to the envelope of what
|
||||
/// would not hold still, and the lit pixels are the defect.
|
||||
///
|
||||
/// **`WOW_SHOT_REQUIRE=<name>` is the SUBJECT gate** (substring, case-insensitive; range via
|
||||
/// `WOW_SHOT_REQUIRE_DIST`, default 60): no shot fires until a unit with that name is inside the
|
||||
/// viewport and range — the same contract as the death gate below, because a frame that cannot
|
||||
/// contain the reported subject is not a measurement of it either. Ten banshee runs were once read
|
||||
/// as "can't reproduce" with the banshee never in frame, and a voidwalker crop-negative was a model
|
||||
/// that had wandered out of a fixed window (decision 0705). While the subject is missing the gate
|
||||
/// WARNs with what *is* nearby — names and distances — so a mis-aimed run re-aims itself instead of
|
||||
/// ending as a silent blank; a run that exits with 0 shots written names its reason in the log.
|
||||
/// The unit's feet anchor decides "in frame", and in frame does not mean unoccluded — the gate
|
||||
/// closes the aim trap, not line-of-sight.
|
||||
pub(crate) struct LiveShotPlugin;
|
||||
|
||||
impl Plugin for LiveShotPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let out = std::env::var("WOW_LIVE_SHOT").unwrap_or_default();
|
||||
let at = std::env::var("WOW_LIVE_SHOT_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(12.0);
|
||||
let count = std::env::var("WOW_LIVE_SHOT_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1u32)
|
||||
.max(1);
|
||||
let every = std::env::var("WOW_LIVE_SHOT_EVERY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
let require = std::env::var("WOW_SHOT_REQUIRE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty());
|
||||
let require_dist = std::env::var("WOW_SHOT_REQUIRE_DIST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60.0);
|
||||
app.insert_resource(LiveShot {
|
||||
out,
|
||||
count,
|
||||
every,
|
||||
taken: 0,
|
||||
next_at: at,
|
||||
require,
|
||||
require_dist,
|
||||
})
|
||||
.add_systems(Update, fire_live_shot);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing
|
||||
/// (`0` = one per frame), how many have gone out, and when the next one may — seeded to
|
||||
/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock.
|
||||
#[derive(Resource)]
|
||||
struct LiveShot {
|
||||
out: String,
|
||||
count: u32,
|
||||
every: f32,
|
||||
taken: u32,
|
||||
next_at: f32,
|
||||
/// The subject gate: no shot until a unit whose name contains this is in frame and in range.
|
||||
require: Option<String>,
|
||||
require_dist: f32,
|
||||
}
|
||||
|
||||
/// `stem-007.png` for shot 7 of a burst — the bare path when the burst is one shot, so the
|
||||
/// single-shot filename (which existing probes and docs name literally) never moves.
|
||||
fn burst_path(out: &str, index: u32, count: u32) -> String {
|
||||
if count <= 1 {
|
||||
return out.to_string();
|
||||
}
|
||||
match out.rsplit_once('.') {
|
||||
Some((stem, ext)) => format!("{stem}-{index:03}.{ext}"),
|
||||
None => format!("{out}-{index:03}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
|
||||
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
|
||||
///
|
||||
/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a
|
||||
/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the
|
||||
/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated
|
||||
/// against it — pixel series, hotspot runs, depth readback, the phase census — silently describes the
|
||||
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
|
||||
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
|
||||
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
|
||||
fn fire_live_shot(
|
||||
mut shot: ResMut<LiveShot>,
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
subjects: Query<(&Guid, &Transform), (With<NetEntity>, Without<SelfPlayer>)>,
|
||||
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
mut refused: Local<bool>,
|
||||
mut next_require_warn: Local<f32>,
|
||||
) {
|
||||
if shot.taken >= shot.count || time.elapsed_secs() < shot.next_at {
|
||||
return;
|
||||
}
|
||||
if let Ok(store) = self_q.single() {
|
||||
let what = if store.0.unit_is_dead() {
|
||||
Some("DEAD")
|
||||
} else if store.0.player_is_ghost() {
|
||||
Some("A GHOST")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(what) = what {
|
||||
if !*refused {
|
||||
*refused = true;
|
||||
error!(
|
||||
"live-shot: REFUSING to capture — the character is {what}, so the world renders \
|
||||
through the death filter and every pixel of this burst would describe that \
|
||||
filter, not the scene. Send WOW_PROBE_CHAT=\".revive\" (probe accounts are \
|
||||
gmlevel 6) and run again."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The SUBJECT gate — same contract as the death gate above: a frame that cannot contain the
|
||||
// reported subject is not a measurement of it, and a "clean" burst of such frames is the
|
||||
// costliest false negative a live run produces (decision 0705). Every shot of a burst
|
||||
// re-passes the gate, so a subject that wanders off mid-burst pauses the burst instead of
|
||||
// padding it with blanks.
|
||||
if let Some(want) = shot.require.as_deref() {
|
||||
let Ok((cam, cam_pose)) = camera.single() else {
|
||||
return;
|
||||
};
|
||||
let cam_tf = GlobalTransform::from(*cam_pose);
|
||||
let viewport = cam.logical_viewport_size().unwrap_or(Vec2::ZERO);
|
||||
let want_lc = want.to_lowercase();
|
||||
// One pass: find the nearest in-frame match, and keep the neighbourhood for the WARN —
|
||||
// `resolve` sends the name query on a miss, so just scanning fills the cache within frames.
|
||||
let mut found: Option<(String, f32, Vec2)> = None;
|
||||
let mut nearby: Vec<(f32, String)> = Vec::new();
|
||||
for (guid, tf) in &subjects {
|
||||
// Only name-bearing families: a GameObject can never resolve (its name rides its own
|
||||
// unmodeled query), so it would sit in the listing as noise and never match the gate.
|
||||
if !guid::is_player(guid.0)
|
||||
&& !guid::is_creature_or_pet(guid.0)
|
||||
&& guid::pet_number(guid.0).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dist = cam_pose.translation.distance(tf.translation);
|
||||
let name = names
|
||||
.resolve(guid.0, &net_commands)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("<unresolved 0x{:x}>", guid.0));
|
||||
if dist <= shot.require_dist.max(REQUIRE_NEARBY_RANGE) {
|
||||
nearby.push((dist, name.clone()));
|
||||
}
|
||||
if !name.to_lowercase().contains(&want_lc) || dist > shot.require_dist {
|
||||
continue;
|
||||
}
|
||||
let Ok(screen) = cam.world_to_viewport(&cam_tf, tf.translation) else {
|
||||
continue; // behind the camera
|
||||
};
|
||||
if screen.x < 0.0 || screen.y < 0.0 || screen.x > viewport.x || screen.y > viewport.y {
|
||||
continue; // in front, but outside the frame
|
||||
}
|
||||
if found.as_ref().is_none_or(|(_, d, _)| dist < *d) {
|
||||
found = Some((name, dist, screen));
|
||||
}
|
||||
}
|
||||
match found {
|
||||
Some((name, dist, screen)) => {
|
||||
info!(
|
||||
"live-shot: subject gate — {name:?} in frame at {dist:.1} yd, \
|
||||
screen ({:.0}, {:.0})",
|
||||
screen.x, screen.y
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if time.elapsed_secs() >= *next_require_warn {
|
||||
*next_require_warn = time.elapsed_secs() + REQUIRE_REWARN_SECS;
|
||||
nearby.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
nearby.truncate(10);
|
||||
let listing = if nearby.is_empty() {
|
||||
"nothing within range at all".to_string()
|
||||
} else {
|
||||
nearby
|
||||
.iter()
|
||||
.map(|(d, n)| format!("{n:?} at {d:.1} yd"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
warn!(
|
||||
"live-shot: WAITING — no unit named like {want:?} is in frame within \
|
||||
{:.0} yd. Nearby: {listing}. Re-aim (`.go`, WOW_PROBE_CAM) or raise \
|
||||
WOW_SHOT_REQUIRE_DIST; a run that exits with 0 shots written failed \
|
||||
this gate.",
|
||||
shot.require_dist
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = burst_path(&shot.out, shot.taken, shot.count);
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(save_to_disk(path.clone()));
|
||||
shot.taken += 1;
|
||||
shot.next_at = time.elapsed_secs() + shot.every;
|
||||
if shot.count > 1 {
|
||||
info!("live-shot: writing {path} ({}/{})", shot.taken, shot.count);
|
||||
} else {
|
||||
info!("live-shot: writing {path}");
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ use crate::schedule::WorldStage;
|
||||
// screenshot, perf probe) is this one's concern.
|
||||
mod depth_probe;
|
||||
mod fixtures;
|
||||
mod live_shot;
|
||||
mod phase_probe;
|
||||
mod pick_probe;
|
||||
mod probe_bank;
|
||||
@@ -60,6 +61,7 @@ mod probes;
|
||||
mod scenarios;
|
||||
pub(crate) use depth_probe::DepthProbePlugin;
|
||||
use fixtures::seed_ui_fixture;
|
||||
pub(crate) use live_shot::LiveShotPlugin;
|
||||
pub(crate) use phase_probe::PhaseProbePlugin;
|
||||
pub(crate) use pick_probe::PickProbePlugin;
|
||||
pub(crate) use probe_bank::ProbeBankPlugin;
|
||||
@@ -72,8 +74,8 @@ pub(crate) use probe_partner::ProbePartnerPlugin;
|
||||
pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
|
||||
pub(crate) use probe_taxi::ProbeTaxiPlugin;
|
||||
pub(crate) use probes::{
|
||||
LiveFpsPlugin, LiveShotPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
|
||||
ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
|
||||
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeExitPlugin,
|
||||
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
|
||||
};
|
||||
use scenarios::{Scenario, UiFixture, GROUND_EYE, SCENARIOS};
|
||||
|
||||
|
||||
@@ -1,140 +1,14 @@
|
||||
//! The LIVE-run probe instruments — every plugin here rides a NORMAL connected session (unlike
|
||||
//! the parent's server-less [`super::CapturePlugin`] harness): one screenshot of the live scene
|
||||
//! ([`LiveShotPlugin`]), scripted chat sends ([`ProbeChatPlugin`]), synthetic key taps
|
||||
//! ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM ([`ProbeLuaPlugin`]), the bounded-lifetime
|
||||
//! self-exit ([`ProbeExitPlugin`]), and the live frame-time sampler ([`LiveFpsPlugin`]). Each is
|
||||
//! env-gated and registered by `main`; compose them for unattended "park, act, observe" probes.
|
||||
//! the parent's server-less [`super::CapturePlugin`] harness): scripted chat sends
|
||||
//! ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM
|
||||
//! ([`ProbeLuaPlugin`]), the bounded-lifetime self-exit ([`ProbeExitPlugin`]), and the live
|
||||
//! frame-time sampler ([`LiveFpsPlugin`]). The live screenshot and its validity gates live in the
|
||||
//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for
|
||||
//! unattended "park, act, observe" probes.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
|
||||
|
||||
use super::PROBE_WARMUP_FRAMES;
|
||||
use crate::net::{ObjectStore, SelfPlayer};
|
||||
|
||||
/// The LIVE probe shot (`WOW_LIVE_SHOT=<png>`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12):
|
||||
/// one screenshot of the primary window on a NORMAL connected run — the "what does the live scene
|
||||
/// actually look like" instrument (server GameObjects, event spawns, NPCs — everything the
|
||||
/// server-less harness deliberately excludes). The app keeps running after the save; pair with
|
||||
/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike
|
||||
/// [`super::CapturePlugin`], nothing is pinned — the shot shows the scene as a player would see it.
|
||||
///
|
||||
/// **`WOW_LIVE_SHOT_COUNT=<n>` makes it a burst** — `n` shots written as `<stem>-000.png`,
|
||||
/// `<stem>-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**,
|
||||
/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is
|
||||
/// unchanged.
|
||||
///
|
||||
/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one**
|
||||
/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had:
|
||||
/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7
|
||||
/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it
|
||||
/// into arithmetic: `benilla-visual flicker <dir>` collapses the burst to the envelope of what
|
||||
/// would not hold still, and the lit pixels are the defect.
|
||||
pub(crate) struct LiveShotPlugin;
|
||||
|
||||
impl Plugin for LiveShotPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let out = std::env::var("WOW_LIVE_SHOT").unwrap_or_default();
|
||||
let at = std::env::var("WOW_LIVE_SHOT_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(12.0);
|
||||
let count = std::env::var("WOW_LIVE_SHOT_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1u32)
|
||||
.max(1);
|
||||
let every = std::env::var("WOW_LIVE_SHOT_EVERY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
app.insert_resource(LiveShot {
|
||||
out,
|
||||
count,
|
||||
every,
|
||||
taken: 0,
|
||||
next_at: at,
|
||||
})
|
||||
.add_systems(Update, fire_live_shot);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing
|
||||
/// (`0` = one per frame), how many have gone out, and when the next one may — seeded to
|
||||
/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock.
|
||||
#[derive(Resource)]
|
||||
struct LiveShot {
|
||||
out: String,
|
||||
count: u32,
|
||||
every: f32,
|
||||
taken: u32,
|
||||
next_at: f32,
|
||||
}
|
||||
|
||||
/// `stem-007.png` for shot 7 of a burst — the bare path when the burst is one shot, so the
|
||||
/// single-shot filename (which existing probes and docs name literally) never moves.
|
||||
fn burst_path(out: &str, index: u32, count: u32) -> String {
|
||||
if count <= 1 {
|
||||
return out.to_string();
|
||||
}
|
||||
match out.rsplit_once('.') {
|
||||
Some((stem, ext)) => format!("{stem}-{index:03}.{ext}"),
|
||||
None => format!("{out}-{index:03}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
|
||||
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
|
||||
///
|
||||
/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a
|
||||
/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the
|
||||
/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated
|
||||
/// against it — pixel series, hotspot runs, depth readback, the phase census — silently describes the
|
||||
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
|
||||
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
|
||||
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
|
||||
fn fire_live_shot(
|
||||
mut shot: ResMut<LiveShot>,
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut refused: Local<bool>,
|
||||
) {
|
||||
if shot.taken >= shot.count || time.elapsed_secs() < shot.next_at {
|
||||
return;
|
||||
}
|
||||
if let Ok(store) = self_q.single() {
|
||||
let what = if store.0.unit_is_dead() {
|
||||
Some("DEAD")
|
||||
} else if store.0.player_is_ghost() {
|
||||
Some("A GHOST")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(what) = what {
|
||||
if !*refused {
|
||||
*refused = true;
|
||||
error!(
|
||||
"live-shot: REFUSING to capture — the character is {what}, so the world renders \
|
||||
through the death filter and every pixel of this burst would describe that \
|
||||
filter, not the scene. Send WOW_PROBE_CHAT=\".revive\" (probe accounts are \
|
||||
gmlevel 6) and run again."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let path = burst_path(&shot.out, shot.taken, shot.count);
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(save_to_disk(path.clone()));
|
||||
shot.taken += 1;
|
||||
shot.next_at = time.elapsed_secs() + shot.every;
|
||||
if shot.count > 1 {
|
||||
info!("live-shot: writing {path} ({}/{})", shot.taken, shot.count);
|
||||
} else {
|
||||
info!("live-shot: writing {path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
|
||||
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
|
||||
|
||||
@@ -23,7 +23,10 @@ mod refresh;
|
||||
mod screen;
|
||||
|
||||
use benilla_protocol::{CharAction, CharCreateReq};
|
||||
use bevy::input::keyboard::{Key, KeyboardInput};
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
|
||||
use crate::textinput::{self, HostClipboard};
|
||||
use bevy::input::mouse::AccumulatedMouseMotion;
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
@@ -54,7 +57,6 @@ pub(crate) struct CharCreatePlugin;
|
||||
impl Plugin for CharCreatePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<CreateSelection>()
|
||||
.init_resource::<NameCaretBlink>()
|
||||
.add_systems(OnEnter(ClientState::CharCreate), screen::enter_create)
|
||||
.add_systems(OnExit(ClientState::CharCreate), screen::exit_create)
|
||||
.add_systems(Update, debug_enter)
|
||||
@@ -63,12 +65,12 @@ impl Plugin for CharCreatePlugin {
|
||||
(
|
||||
screen::rescale_screen,
|
||||
create_input,
|
||||
refresh_name_caret,
|
||||
debug_auto_create,
|
||||
debug_pick,
|
||||
debug_shot,
|
||||
rotate_model,
|
||||
refresh::refresh_dynamic,
|
||||
refresh_name_box,
|
||||
refresh::refresh_hover,
|
||||
crate::glue::art_swaps,
|
||||
crate::glue::glue_button_visuals,
|
||||
@@ -108,11 +110,11 @@ fn debug_auto_create(
|
||||
if now - start < 6.0 {
|
||||
return; // let the model settle + the socket park
|
||||
}
|
||||
sel.name = name;
|
||||
sel.name.set_text(&name);
|
||||
sel.creating = true;
|
||||
let _ = pick.0.send(CharRequest::Create(sel.request()));
|
||||
*fired = true;
|
||||
info!("char create: auto-create fired for {:?}", sel.name);
|
||||
info!("char create: auto-create fired for {:?}", sel.name.text);
|
||||
}
|
||||
|
||||
/// The screen-shot instrument (`WOW_CHARCREATE_SHOT=1`, decision 0423): jump to the create screen a
|
||||
@@ -242,7 +244,10 @@ pub(crate) struct CreateSelection {
|
||||
sex: u8,
|
||||
class: u8,
|
||||
dials: [u8; 5],
|
||||
name: String,
|
||||
/// The typed name — a real [`EditBoxState`] (decision 0704), so it has the caret, selection,
|
||||
/// Ctrl+A and clipboard the chat box has. Letters-only and the 12-cap are enforced by the
|
||||
/// shared feed, on pasted text as well as typed.
|
||||
name: EditBoxState,
|
||||
/// A create is in flight (waiting on `SMSG_CHAR_CREATE`) — the Create button is disarmed and the
|
||||
/// status shows progress.
|
||||
creating: bool,
|
||||
@@ -266,7 +271,7 @@ impl CreateSelection {
|
||||
.and_then(|c| c.0.classes_for_race(1).first().copied())
|
||||
.unwrap_or(1);
|
||||
self.dials = [0; 5];
|
||||
self.name.clear();
|
||||
self.name.set_text("");
|
||||
self.creating = false;
|
||||
}
|
||||
|
||||
@@ -305,7 +310,7 @@ impl CreateSelection {
|
||||
/// The wire request for the current selection.
|
||||
fn request(&self) -> CharCreateReq {
|
||||
CharCreateReq {
|
||||
name: self.name.clone(),
|
||||
name: self.name.text.clone(),
|
||||
race: self.race,
|
||||
class: self.class,
|
||||
gender: self.sex,
|
||||
@@ -372,27 +377,6 @@ fn class_file(class: u8) -> &'static str {
|
||||
/// Its own resource rather than a [`CreateSelection`] field on purpose: ticking it there would trip
|
||||
/// that resource's change detection every frame and defeat `refresh_dynamic`'s `is_changed` gate,
|
||||
/// re-running the whole dial/panel/icon refresh 60× a second.
|
||||
#[derive(Resource, Default)]
|
||||
struct NameCaretBlink(f32);
|
||||
|
||||
/// Blink the name box's caret (0.5 s on / 0.5 s off). The create screen has one editable field and
|
||||
/// no focus model, so its caret shows whenever the screen is up.
|
||||
fn refresh_name_caret(
|
||||
blink: Res<NameCaretBlink>,
|
||||
mut carets: Query<&mut Visibility, With<parts::NameCaret>>,
|
||||
) {
|
||||
let want = if blink.0 % 1.0 < 0.5 {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut vis in &mut carets {
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_input(
|
||||
presses: Query<(&CreateAction, &Interaction), Changed<Interaction>>,
|
||||
@@ -400,7 +384,9 @@ fn create_input(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
catalog: Option<Res<CharCreate>>,
|
||||
mut sel: ResMut<CreateSelection>,
|
||||
mut blink: ResMut<NameCaretBlink>,
|
||||
// The host pasteboard + the window handle its Wayland backend needs (decision 0702).
|
||||
mut clipboard: NonSendMut<HostClipboard>,
|
||||
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
|
||||
time: Res<Time>,
|
||||
mut preview: ResMut<GluePreview>,
|
||||
pick: Res<CharPick>,
|
||||
@@ -408,7 +394,10 @@ fn create_input(
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
mut rng: Local<u64>,
|
||||
) {
|
||||
blink.0 += time.delta_secs();
|
||||
let mods = textinput::mods_now(&keys);
|
||||
let wl = textinput::wayland_display(raw_handle.iter().next());
|
||||
// The create screen has one field and no focus model, so it is always focused while up.
|
||||
textinput::tick_caret(&mut sel.name, true, time.delta_secs());
|
||||
let cat = catalog.as_deref();
|
||||
let mut changed_look = false;
|
||||
let mut do_create = false;
|
||||
@@ -474,20 +463,16 @@ fn create_input(
|
||||
if ev.state != ButtonState::Pressed {
|
||||
continue;
|
||||
}
|
||||
match &ev.logical_key {
|
||||
Key::Character(s) => {
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphabetic() && sel.name.len() < 12 {
|
||||
sel.name.push(ch);
|
||||
blink.0 = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Key::Backspace => {
|
||||
sel.name.pop();
|
||||
blink.0 = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
if textinput::feed_key(
|
||||
&mut sel.name,
|
||||
ev,
|
||||
mods,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Letters,
|
||||
) == textinput::FieldKey::Consumed
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
|
||||
@@ -529,6 +514,22 @@ fn randomize(sel: &mut CreateSelection, catalog: Option<&CharCreate>, rng: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the name box from its [`EditBoxState`] — the display segments, the selection highlight and
|
||||
/// the caret at the cursor — through the shared [`crate::glue::widgets::paint_glue_field`], so it
|
||||
/// draws exactly like the login boxes (decision 0704). Only the five name-box row items carry a
|
||||
/// `GlueFieldPart`, so requiring it is enough to pick them out of every other `DynText`.
|
||||
fn refresh_name_box(
|
||||
sel: Res<CreateSelection>,
|
||||
mut parts: Query<(
|
||||
&crate::glue::widgets::GlueFieldPart,
|
||||
Option<&mut Text>,
|
||||
&mut Visibility,
|
||||
)>,
|
||||
) {
|
||||
// One field, no focus model: it is focused whenever the screen is up.
|
||||
crate::glue::widgets::paint_glue_field(&sel.name, true, parts.iter_mut());
|
||||
}
|
||||
|
||||
/// Rotate the preview: drag on the model pane (the ref's full-frame mouse rotation: `facing +=
|
||||
/// Δcursor·CHARACTER_ROTATION_CONSTANT` — dragging right *increases* the facing, same sign as the
|
||||
/// right rotate button; the engine constant's magnitude is unverified, ours ≈0.6°/px), or hold a
|
||||
@@ -577,7 +578,7 @@ fn create_result(
|
||||
if msg.code == benilla_protocol::messages::CHAR_CREATE_SUCCESS {
|
||||
// The fresh roster already arrived (phase 1 emits it before the result); arm the new row
|
||||
// and return to select.
|
||||
roster.note_created(sel.name.clone());
|
||||
roster.note_created(sel.name.text.clone());
|
||||
next.set(ClientState::CharSelect);
|
||||
} else if let Ok(mut text) = status.single_mut() {
|
||||
text.0 = char_result_text(msg.code).to_string();
|
||||
|
||||
@@ -33,14 +33,8 @@ pub(super) enum DynIcon {
|
||||
Info(InfoKind),
|
||||
}
|
||||
|
||||
/// The name box's caret bar (the shared `glue_edit_box` chrome spawns it; `refresh_name_caret`
|
||||
/// blinks it). The create screen has one editable field and no focus model, so its box is always
|
||||
/// the focused one — unlike login, where the caret follows Tab.
|
||||
#[derive(Component)]
|
||||
pub(super) struct NameCaret;
|
||||
|
||||
/// A text whose content follows the selection.
|
||||
#[derive(Component)]
|
||||
#[derive(Component, Clone)]
|
||||
pub(super) enum DynText {
|
||||
DialLabel(u8),
|
||||
Name,
|
||||
|
||||
@@ -117,9 +117,9 @@ pub(super) fn refresh_dynamic(
|
||||
.text(&format!("FACIAL_HAIR_{facial_tok}"), "Facial Hair")
|
||||
.to_string(),
|
||||
},
|
||||
// The typed name alone — the caret is its own blinking bar now (`NameCaret`), not the
|
||||
// trailing `_` this used to fake.
|
||||
DynText::Name => sel.name.clone(),
|
||||
// The name box is five flex items (segments + carets), painted from its
|
||||
// `EditBoxState` by `refresh_name_box` — never a single string here (decision 0704).
|
||||
DynText::Name => continue,
|
||||
DynText::InfoTitle(InfoKind::Faction) => strings
|
||||
.text(
|
||||
if alliance { "ALLIANCE" } else { "HORDE" },
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::entities::CharCreate;
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
|
||||
|
||||
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, NameCaret, StatusLine};
|
||||
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine};
|
||||
use super::{CreateAction, CreateSelection, ALLIANCE, HORDE, INITIAL_FACING};
|
||||
use crate::glue::art::{
|
||||
tc_rect, GlueArt, ALLIANCE_BORDER, ALLIANCE_FILL, BACKDROP, BTN_BG, DIM, GOLD, INFO_TEXT,
|
||||
@@ -535,7 +535,6 @@ fn name_cluster(
|
||||
edit_font,
|
||||
(),
|
||||
DynText::Name,
|
||||
NameCaret,
|
||||
(156.0, 40.0),
|
||||
(ALLIANCE_BORDER, ALLIANCE_FILL),
|
||||
(15.0, 0.0, 0.0, 0.0), // CharacterCreate.xml: TextInsets left 15 only
|
||||
|
||||
@@ -11,14 +11,17 @@
|
||||
//! `GlueFontHighlight` (FRIZQT 15 white — outlined like every glue font), no TextInsets, and the
|
||||
//! standard drawn caret bar ([`caret_bar`]) blinking on the ref's 0.5 s clock, solid while typing.
|
||||
|
||||
use bevy::input::keyboard::{Key, KeyboardInput};
|
||||
use bevy::input::ButtonState;
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
|
||||
use crate::textinput::{self, HostClipboard};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::glue::art::{tc_rect, GlueArt, GOLD};
|
||||
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
|
||||
use crate::glue::widgets::{
|
||||
caret_bar, glue_button, outlined_text, overlay, GlueBtnKind, GlueDisabled, GlueText,
|
||||
caret_bar, glue_button, outlined_text, overlay, paint_glue_field, GlueBtnKind, GlueDisabled,
|
||||
GlueFieldPart, GlueText,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::net::{CharPick, CharRequest};
|
||||
@@ -33,8 +36,10 @@ pub(super) struct DeleteDialog {
|
||||
pub(super) open: bool,
|
||||
/// The snapshotted delete target (guid) + its display line pieces (name, level, class name).
|
||||
target: Option<(u64, String, u8, &'static str)>,
|
||||
/// What's been typed into the confirm box (`pub(super)` for the shot instrument's pre-type).
|
||||
pub(super) typed: String,
|
||||
/// What's been typed into the confirm box — a real [`EditBoxState`] (decision 0704), so it
|
||||
/// has the caret, selection, Ctrl+A and clipboard every other field has. The ref's
|
||||
/// `letters="32"` cap is the box's own `max_letters`. (`pub(super)` for the shot instrument.)
|
||||
pub(super) typed: EditBoxState,
|
||||
/// The spawned dialog root, while up.
|
||||
root: Option<Entity>,
|
||||
/// The glue scale the spawned tree was built at — a resize rebuilds it.
|
||||
@@ -46,18 +51,18 @@ impl DeleteDialog {
|
||||
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
|
||||
self.open = true;
|
||||
self.target = Some((guid, name, level, class));
|
||||
self.typed.clear();
|
||||
self.typed.set_text("");
|
||||
}
|
||||
|
||||
pub(super) fn close(&mut self) {
|
||||
self.open = false;
|
||||
self.target = None;
|
||||
self.typed.clear();
|
||||
self.typed.set_text("");
|
||||
}
|
||||
|
||||
/// The typed text matches `DELETE_CONFIRM_STRING` (case-insensitive, the ref's `strupper`).
|
||||
fn armed(&self, confirm: &str) -> bool {
|
||||
self.typed.eq_ignore_ascii_case(confirm)
|
||||
self.typed.text.eq_ignore_ascii_case(confirm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,14 +101,21 @@ pub(super) fn drive_delete_dialog(
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
presses: Query<(&DialogAction, &Interaction), Changed<Interaction>>,
|
||||
mut okay: Query<(&DialogAction, &mut GlueDisabled)>,
|
||||
mut typed_line: Query<&mut Text, With<TypedText>>,
|
||||
mut carets: Query<&mut Visibility, With<DeleteCaret>>,
|
||||
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
// The five row items of the confirm box (segments + carets), painted by the shared
|
||||
// `paint_glue_field`; plus the host pasteboard and the window handle its Wayland backend needs.
|
||||
mut parts: Query<(
|
||||
&crate::glue::widgets::GlueFieldPart,
|
||||
Option<&mut Text>,
|
||||
&mut Visibility,
|
||||
)>,
|
||||
mut clipboard: NonSendMut<HostClipboard>,
|
||||
// One query: the window drives the glue scale, and its raw handle carries the `wl_display` the
|
||||
// Wayland clipboard backend is built from (decision 0702).
|
||||
window: Query<
|
||||
(&Window, Option<&bevy::window::RawHandleWrapper>),
|
||||
With<bevy::window::PrimaryWindow>,
|
||||
>,
|
||||
time: Res<Time>,
|
||||
// The caret blink clock (the ref's `blinkSpeed` 0.5 s), reset on open and on every keystroke
|
||||
// so the caret is solid while you type — a `Local`, not a `DeleteDialog` field, so ticking it
|
||||
// never trips the resource's change detection.
|
||||
mut blink: Local<f32>,
|
||||
) {
|
||||
let empty = GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
@@ -120,7 +132,7 @@ pub(super) fn drive_delete_dialog(
|
||||
|
||||
// Spawn on the open edge — and respawn when a window resize has changed the glue scale the
|
||||
// tree was baked at (the typed text lives in the resource, so it survives the rebuild).
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
let s = crate::glue::screen_scale(window.single().ok().map(|(w, _)| w));
|
||||
if dialog.root.is_some() && dialog.spawned_s != s {
|
||||
if let Some(root) = dialog.root.take() {
|
||||
commands.entity(root).despawn();
|
||||
@@ -136,48 +148,26 @@ pub(super) fn drive_delete_dialog(
|
||||
s,
|
||||
));
|
||||
dialog.spawned_s = s;
|
||||
*blink = 0.0;
|
||||
}
|
||||
|
||||
// Typing: printable characters append (the ref's `letters="32"` cap — any typed character
|
||||
// counts; the box only ever *matches* on "DELETE"), Backspace pops.
|
||||
// Typing: the shared law (decision 0704) — editing, caret, selection and the clipboard trio.
|
||||
// ENTER/ESCAPE come back unclaimed and are handled by the button/key block above.
|
||||
let mods = textinput::mods_now(&keys);
|
||||
let wl = textinput::wayland_display(window.iter().next().and_then(|(_, h)| h));
|
||||
for ev in keyboard.read() {
|
||||
if ev.state != ButtonState::Pressed {
|
||||
continue;
|
||||
}
|
||||
match &ev.logical_key {
|
||||
Key::Character(chs) => {
|
||||
for ch in chs.chars() {
|
||||
if !ch.is_control() && dialog.typed.chars().count() < 32 {
|
||||
dialog.typed.push(ch);
|
||||
}
|
||||
}
|
||||
*blink = 0.0;
|
||||
}
|
||||
Key::Backspace => {
|
||||
dialog.typed.pop();
|
||||
*blink = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Ok(mut t) = typed_line.single_mut() {
|
||||
if t.0 != dialog.typed {
|
||||
t.0.clone_from(&dialog.typed);
|
||||
}
|
||||
}
|
||||
// The caret: 0.5 s on / 0.5 s off (solid right after a keystroke — the reset above).
|
||||
*blink += time.delta_secs();
|
||||
let want = if *blink % 1.0 < 0.5 {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut vis in &mut carets {
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
textinput::feed_key(
|
||||
&mut dialog.typed,
|
||||
ev,
|
||||
mods,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Any,
|
||||
);
|
||||
}
|
||||
// The dialog's box is always the focused one while it is up.
|
||||
textinput::tick_caret(&mut dialog.typed, true, time.delta_secs());
|
||||
paint_glue_field(&dialog.typed, true, parts.iter_mut());
|
||||
|
||||
let armed = dialog.armed(confirm);
|
||||
for (action, mut disabled) in &mut okay {
|
||||
if *action == DialogAction::Okay && disabled.0 == armed {
|
||||
@@ -424,21 +414,37 @@ fn spawn_dialog(
|
||||
..default()
|
||||
},))
|
||||
.with_children(|f| {
|
||||
outlined_text(
|
||||
// The same five-item row every other field uses
|
||||
// (`GlueFieldPart`): segments either side of the selection,
|
||||
// with a caret slot at each selection edge. Each segment is
|
||||
// its own `outlined_text`, and the outline copies follow
|
||||
// automatically (`glue::sync_outline_text`).
|
||||
let segment = |f: &mut ChildSpawnerCommands, part| {
|
||||
outlined_text(
|
||||
f,
|
||||
Node::default(),
|
||||
(),
|
||||
(TypedText, part),
|
||||
GlueText {
|
||||
text: "",
|
||||
size: 15.0, // GlueFontHighlight
|
||||
color: Color::WHITE,
|
||||
wrap: false,
|
||||
},
|
||||
&font,
|
||||
s,
|
||||
);
|
||||
};
|
||||
segment(f, GlueFieldPart::Before);
|
||||
caret_bar(
|
||||
f,
|
||||
Node::default(),
|
||||
(),
|
||||
TypedText,
|
||||
GlueText {
|
||||
text: &dialog.typed,
|
||||
size: 15.0, // GlueFontHighlight
|
||||
color: Color::WHITE,
|
||||
wrap: false,
|
||||
},
|
||||
&font,
|
||||
(DeleteCaret, GlueFieldPart::CaretAtStart),
|
||||
15.0,
|
||||
s,
|
||||
);
|
||||
caret_bar(f, DeleteCaret, 15.0, s);
|
||||
segment(f, GlueFieldPart::Selected);
|
||||
caret_bar(f, (DeleteCaret, GlueFieldPart::CaretAtEnd), 15.0, s);
|
||||
segment(f, GlueFieldPart::After);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -363,7 +363,7 @@ fn debug_select_dialog(
|
||||
};
|
||||
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, class_name(c.class));
|
||||
dialog.open_for(guid, name, level, class);
|
||||
dialog.typed = typed;
|
||||
dialog.typed.set_text(&typed);
|
||||
info!("char select: dialog instrument opened the delete confirm");
|
||||
*done = true;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,17 @@ pub struct LightingDebug {
|
||||
|
||||
impl Default for LightingDebug {
|
||||
fn default() -> Self {
|
||||
// `WOW_CLOCK=<minute 0..1439>` arms the manual scrub from the environment — the
|
||||
// matched-hour capture instrument (a headless probe can't reach the panel slider, and a
|
||||
// time-of-day A/B against a reference screenshot is meaningless at the wrong hour —
|
||||
// bug B33's whole diagnosis hinged on one). Unset = follow the server clock, as before.
|
||||
let clock = std::env::var("WOW_CLOCK")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.map(|m| m % 1440);
|
||||
Self {
|
||||
follow_server_time: true,
|
||||
manual_minute: 720, // noon, only used when not following the server
|
||||
follow_server_time: clock.is_none(),
|
||||
manual_minute: clock.unwrap_or(720), // noon, only used when not following the server
|
||||
disable_fog: false,
|
||||
disable_sky_dome: false,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! component (`CreateAction`, `SelectAction`, …); each degrades to a plain-fill + text face
|
||||
//! without client art.
|
||||
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
use bevy::prelude::*;
|
||||
use bevy::text::LineHeight;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
@@ -411,26 +412,92 @@ pub(crate) fn caret_bar<C: Bundle>(
|
||||
));
|
||||
}
|
||||
|
||||
/// Which flex item of a glue edit box's text row an entity is.
|
||||
///
|
||||
/// The row is `[before][caret][selected][caret][after]` — five items, so the caret lands **at the
|
||||
/// cursor** and a selection shows its highlight with **no text measuring anywhere**: flex layout
|
||||
/// does the positioning, exactly as the single caret sibling used to. `Selected` carries the
|
||||
/// highlight background permanently; with nothing selected its text is empty, so it has zero width
|
||||
/// and paints nothing. Which of the two caret slots is visible follows the cursor, which the box
|
||||
/// law keeps at one end of the selection or the other.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlueFieldPart {
|
||||
/// `display[..sel_start]`
|
||||
Before,
|
||||
/// The caret when it sits at the selection's start (and whenever nothing is selected).
|
||||
CaretAtStart,
|
||||
/// `display[sel_start..sel_end]` — the highlighted run.
|
||||
Selected,
|
||||
/// The caret when it sits at the selection's end.
|
||||
CaretAtEnd,
|
||||
/// `display[sel_end..]`
|
||||
After,
|
||||
}
|
||||
|
||||
/// Paint one glue edit box from its [`EditBoxState`] — the segments, the selection, and the caret.
|
||||
/// Shared so the login boxes, the create-name box and the delete dialog can never fork (decision
|
||||
/// 0704); everything it draws comes off the same state the shared law edits.
|
||||
pub(crate) fn paint_glue_field<'a>(
|
||||
field: &EditBoxState,
|
||||
focused: bool,
|
||||
parts: impl Iterator<
|
||||
Item = (
|
||||
&'a GlueFieldPart,
|
||||
Option<Mut<'a, Text>>,
|
||||
Mut<'a, Visibility>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
let display = field.display();
|
||||
let lo = field.sel_start.min(field.sel_end);
|
||||
let hi = field.sel_start.max(field.sel_end);
|
||||
let (d_lo, d_hi) = (field.text_to_display(lo), field.text_to_display(hi));
|
||||
let d_cursor = field.text_to_display(field.cursor);
|
||||
// Only a focused box blinks; `caret_shown` is the box's own blink phase, ticked by
|
||||
// `textinput::tick_caret`, so a glue caret and the chat caret share one clock.
|
||||
let caret_on = focused && field.caret_shown;
|
||||
for (part, text, mut vis) in parts {
|
||||
let (want_text, want_vis) = match part {
|
||||
GlueFieldPart::Before => (Some(&display[..d_lo]), true),
|
||||
GlueFieldPart::Selected => (Some(&display[d_lo..d_hi]), true),
|
||||
GlueFieldPart::After => (Some(&display[d_hi..]), true),
|
||||
GlueFieldPart::CaretAtStart => (None, caret_on && d_cursor <= d_lo),
|
||||
GlueFieldPart::CaretAtEnd => (None, caret_on && d_cursor > d_lo),
|
||||
};
|
||||
if let (Some(want), Some(mut t)) = (want_text, text) {
|
||||
if t.0 != want {
|
||||
t.0 = want.to_string();
|
||||
}
|
||||
}
|
||||
let want = if want_vis {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A glue edit box's CHROME — the shape every glue EditBox authors (`AccountLoginAccountEdit`/
|
||||
/// `PasswordEdit`, `CharacterCreateNameEdit`): the `UI-Tooltip-Background` fill tiled at 16 inside
|
||||
/// the (10,5,4,9) insets, the `Glue-Tooltip-Border` 16-edge over it (both caller-tinted — the
|
||||
/// login boxes take `DEFAULT_TOOLTIP_COLOR`, the create name box the Alliance row), the text
|
||||
/// line at the ref's TextInsets (left 15, vertically centered) in `GlueEditBoxFont` (ARIALN 18,
|
||||
/// white), and the caret bar seated immediately after it (a flex sibling, so it lands at the cursor
|
||||
/// without anyone measuring the text). `extras` rides the box node (the screen's click-to-focus
|
||||
/// action + `Button`); `marker` rides the text entity (the screen's refresh writes the content —
|
||||
/// typed text or password mask); `caret` rides the caret bar (the screen's refresh drives its
|
||||
/// `Visibility` from focus + blink phase — it spawns hidden).
|
||||
/// Plain-fill fallback without art. Focus, typing, and the blink clock are the owning screen's
|
||||
/// systems — this is chrome only, so the two screens' boxes can never fork.
|
||||
/// login boxes take `DEFAULT_TOOLTIP_COLOR`, the create name box the Alliance row), and the text
|
||||
/// row at the ref's TextInsets (left 15, vertically centered) in `GlueEditBoxFont` (ARIALN 18,
|
||||
/// white).
|
||||
///
|
||||
/// `extras` rides the box node (the screen's click-to-focus action + `Button`); `marker` is cloned
|
||||
/// onto all five row items ([`GlueFieldPart`]) so the screen's refresh can query them as a set and
|
||||
/// hand them to [`paint_glue_field`]. Plain-fill fallback without art. Focus and typing are the
|
||||
/// owning screen's systems — this is chrome only, so the screens' boxes can never fork.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn glue_edit_box<E: Bundle, T: Bundle, C: Bundle>(
|
||||
pub(crate) fn glue_edit_box<E: Bundle, T: Bundle + Clone>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
art: &GlueArt,
|
||||
edit_font: &Handle<Font>,
|
||||
extras: E,
|
||||
marker: T,
|
||||
caret: C,
|
||||
(w, h): (f32, f32),
|
||||
(border, fill): (Color, Color),
|
||||
text_insets: (f32, f32, f32, f32),
|
||||
@@ -464,9 +531,6 @@ pub(crate) fn glue_edit_box<E: Bundle, T: Bundle, C: Bundle>(
|
||||
));
|
||||
backdrop_border(b, art.name_border.as_ref().unwrap(), NAME_EDGE, border);
|
||||
}
|
||||
// The typed line and its caret share one row (default `FlexDirection::Row`), so the caret
|
||||
// is simply the next flex item — it seats at the end of the text with no measuring, and at
|
||||
// the left inset when the box is empty.
|
||||
// The ref's `<TextInsets>` (left, right, top, bottom): the EditBox's FontString rect is
|
||||
// the box inset by them, and the typed line is centred in THAT — not in the whole box. The
|
||||
// login boxes inset the bottom by 5, which lifts the line, and the caret beside it, off the
|
||||
@@ -482,23 +546,49 @@ pub(crate) fn glue_edit_box<E: Bundle, T: Bundle, C: Bundle>(
|
||||
..default()
|
||||
},))
|
||||
.with_children(|f| {
|
||||
f.spawn((
|
||||
marker,
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font: edit_font.clone(),
|
||||
font_size: EDIT_FONT_SIZE * s, // GlueEditBoxFont
|
||||
..default()
|
||||
},
|
||||
// Its own component in Bevy 0.18, not a `TextFont` field.
|
||||
LineHeight::RelativeToFont(EDIT_LINE_HEIGHT),
|
||||
TextColor(EDIT_TEXT_COLOR),
|
||||
TextLayout {
|
||||
linebreak: LineBreak::NoWrap,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
caret_bar(f, caret, EDIT_FONT_SIZE, s);
|
||||
let segment = |f: &mut ChildSpawnerCommands, part: GlueFieldPart| {
|
||||
let mut e = f.spawn((
|
||||
marker.clone(),
|
||||
part,
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font: edit_font.clone(),
|
||||
font_size: EDIT_FONT_SIZE * s, // GlueEditBoxFont
|
||||
..default()
|
||||
},
|
||||
// Its own component in Bevy 0.18, not a `TextFont` field.
|
||||
LineHeight::RelativeToFont(EDIT_LINE_HEIGHT),
|
||||
TextColor(EDIT_TEXT_COLOR),
|
||||
TextLayout {
|
||||
linebreak: LineBreak::NoWrap,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
if part == GlueFieldPart::Selected {
|
||||
// The box law's own highlight tint (`SetHighlightColor`'s ctor default,
|
||||
// opaque medium grey) — the same colour the chat box selects with.
|
||||
e.insert(BackgroundColor(Color::srgb(
|
||||
96.0 / 255.0,
|
||||
96.0 / 255.0,
|
||||
96.0 / 255.0,
|
||||
)));
|
||||
}
|
||||
};
|
||||
segment(f, GlueFieldPart::Before);
|
||||
caret_bar(
|
||||
f,
|
||||
(marker.clone(), GlueFieldPart::CaretAtStart),
|
||||
EDIT_FONT_SIZE,
|
||||
s,
|
||||
);
|
||||
segment(f, GlueFieldPart::Selected);
|
||||
caret_bar(
|
||||
f,
|
||||
(marker.clone(), GlueFieldPart::CaretAtEnd),
|
||||
EDIT_FONT_SIZE,
|
||||
s,
|
||||
);
|
||||
segment(f, GlueFieldPart::After);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ mod surface; // the against-real-client-files tests — they span both halves
|
||||
// spawned, never named from outside; add them here the day something needs to.
|
||||
pub(crate) use query::{
|
||||
camera_claim, describe_at, liquid_at, player_claim, unit_claim, water_surface_at, FoamPatch,
|
||||
LiquidClaim, LiquidSource, Underwater, WaterChunkInfo,
|
||||
LiquidClaim, LiquidSource, Underwater, WaterChunkInfo, WmoPool,
|
||||
};
|
||||
pub(crate) use surface::{spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource};
|
||||
|
||||
|
||||
@@ -75,19 +75,78 @@ pub(crate) struct Underwater(pub(crate) benilla_formats::Submersion);
|
||||
/// claims every position under its XY in *every other building on the map*, at any depth. That is
|
||||
/// decision 0696 — the Uldaman entrance read as submerged under a mushroom cave's pool 186 yd
|
||||
/// overhead, in a building the player was 191 yd below and had never entered.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) enum LiquidSource {
|
||||
/// An ADT map-chunk surface (MCLQ) — the outdoor world's lakes, rivers, coast.
|
||||
AdtChunk,
|
||||
/// A WMO group's embedded surface (MLIQ) — canals, fountains, the Great Forge lava,
|
||||
/// Undercity's slime — tagged with the [`WmoRoom`] that owns it.
|
||||
/// Undercity's slime — tagged with the room that owns it and that room's own floor.
|
||||
WmoGroup(WmoPool),
|
||||
}
|
||||
|
||||
/// A WMO pool's **scope**: whose room it is, and how far down that room reaches.
|
||||
///
|
||||
/// Both fields exist to bound a footprint that has none of its own. `owner` bounds it sideways, to
|
||||
/// one placement (0696). `floor` bounds it *downwards*, to one storey — the piece 0696 named as
|
||||
/// still open and deferred to wow-re, now measured against the shipped files: Undercity's upper
|
||||
/// slime channels (groups 7 and 10, world z ≈ 52) were submerging the Rogues'-Quarter-level rooms
|
||||
/// **115 yd below them**, in the same placement, so owner scoping alone could not reject them.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct WmoPool {
|
||||
/// The room this pool belongs to.
|
||||
///
|
||||
/// `None` only for a placement that spawned no [`WmoPortalInstance`](crate::wmo_portal::WmoPortalInstance)
|
||||
/// — a portal-less building with no `WMOAreaTable` identity. Nothing can claim to be *inside*
|
||||
/// such a placement (the interior trackers skip it), so its pool answers no one. That is the
|
||||
/// pre-existing behaviour made explicit rather than a new gap: an unowned pool was already
|
||||
/// unreachable, it just used to be unreachable by accident.
|
||||
WmoGroup(Option<WmoRoom>),
|
||||
pub(crate) owner: Option<WmoRoom>,
|
||||
/// World WoW Z of the **owning group's own bounding box floor** — a pool never claims a subject
|
||||
/// below the room it sits in.
|
||||
///
|
||||
/// The reference's WMO leg picks its group by testing the query point against each group's MOGI
|
||||
/// bounding box (`0x6a4e00`, per-group strict AABB) before sampling any MLIQ, so a pool three
|
||||
/// storeys up is never even a candidate. We take the box's **Z floor only**, not the whole AABB:
|
||||
/// measured over every WMO placed in the shipped world, a pool's wet cells reach up to 25 yd
|
||||
/// *outside* their own group's box in XY (Ahn'Qiraj, Stratholme), so testing XY against the box
|
||||
/// too would newly reject pools that work today — while the Z floor cannot, because anything
|
||||
/// genuinely swimming in a pool is above the floor of the room holding it.
|
||||
///
|
||||
/// `NEG_INFINITY` when the group's bounds are unknown: no floor, i.e. exactly the pre-0701
|
||||
/// behaviour, so a missing box can only ever fail open.
|
||||
pub(crate) floor: f32,
|
||||
}
|
||||
|
||||
impl WmoPool {
|
||||
/// The scope of the pool in `bounds`'s group, under a placement `transform`: the owning room,
|
||||
/// and that group's bounding-box floor carried into world WoW Z.
|
||||
///
|
||||
/// The floor is taken over all **eight** corners rather than off `bbox_min` alone, because a
|
||||
/// placement may be rotated and the lowest corner of a tilted box is not the box's own minimum.
|
||||
/// Absent bounds ⇒ [`NEG_INFINITY`](f32::NEG_INFINITY): a missing box must fail *open* (a pool
|
||||
/// that claims too much — the pre-0701 behaviour) rather than closed (a lake nobody can swim in).
|
||||
pub(crate) fn new(
|
||||
owner: Option<WmoRoom>,
|
||||
transform: &Transform,
|
||||
bounds: Option<&benilla_formats::WmoGroupInfo>,
|
||||
) -> Self {
|
||||
let Some(g) = bounds else {
|
||||
return Self {
|
||||
owner,
|
||||
floor: f32::NEG_INFINITY,
|
||||
};
|
||||
};
|
||||
let mut floor = f32::INFINITY;
|
||||
for x in [g.bbox_min[0], g.bbox_max[0]] {
|
||||
for y in [g.bbox_min[1], g.bbox_max[1]] {
|
||||
for z in [g.bbox_min[2], g.bbox_max[2]] {
|
||||
// Bevy's +Y is WoW's +Z, so a transformed corner's `y` IS its world height.
|
||||
floor = floor.min(transform.transform_point(wow_to_bevy([x, y, z])).y);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { owner, floor }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose liquid answers for one subject at one position — the query's context, and the whole of the
|
||||
@@ -375,14 +434,19 @@ impl WaterChunkInfo {
|
||||
/// else: not the ADT liquid overhead (the 0634 delegation), and not another building's pool
|
||||
/// (the 0696 owner scoping). An [`Outdoors`](LiquidClaim::Outdoors) subject reads only the ADT.
|
||||
/// [`Unknown`](LiquidClaim::Unknown) is the un-classified first frame and admits both.
|
||||
fn answers(&self, claim: LiquidClaim) -> bool {
|
||||
///
|
||||
/// `z` is the subject's own WoW height, and it is what bounds a WMO pool **downwards** to its
|
||||
/// own storey ([`WmoPool::floor`], decision 0701) — a test on the pool rather than on the
|
||||
/// delegation, so it holds under every claim including `Unknown`.
|
||||
fn answers(&self, claim: LiquidClaim, z: f32) -> bool {
|
||||
match (claim, self.source) {
|
||||
(_, LiquidSource::WmoGroup(pool)) if z < pool.floor => false,
|
||||
(LiquidClaim::Unknown, _) => true,
|
||||
(LiquidClaim::Outdoors, LiquidSource::AdtChunk) => true,
|
||||
(LiquidClaim::Outdoors, LiquidSource::WmoGroup(_)) => false,
|
||||
(LiquidClaim::Inside(_), LiquidSource::AdtChunk) => false,
|
||||
(LiquidClaim::Inside(room), LiquidSource::WmoGroup(owner)) => {
|
||||
owner.is_some_and(|o| o.instance == room.instance)
|
||||
(LiquidClaim::Inside(room), LiquidSource::WmoGroup(pool)) => {
|
||||
pool.owner.is_some_and(|o| o.instance == room.instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +457,7 @@ impl WaterChunkInfo {
|
||||
fn owner(&self) -> Option<WmoRoom> {
|
||||
match self.source {
|
||||
LiquidSource::AdtChunk => None,
|
||||
LiquidSource::WmoGroup(owner) => owner,
|
||||
LiquidSource::WmoGroup(pool) => pool.owner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,18 +569,17 @@ pub(crate) struct LiquidHit {
|
||||
/// (`.next()`) — an arbitrary pick that made the answer depend on spawn order. The lowest is the one
|
||||
/// whose volume you are actually in when standing between two stacked surfaces.
|
||||
///
|
||||
/// **Still open — the bound BELOW a surface within one room.** Owner scoping bounds a pool to its
|
||||
/// building; it does not bound it to its *storey*. A multi-floor WMO whose upper room holds water
|
||||
/// would still claim the room beneath it, and whether the reference scopes to the containing GROUP
|
||||
/// (and whether a WMO hit with no liquid suppresses the ADT leg) is a live wow-re dispatch — 0634's
|
||||
/// deferral, re-asked with the group question attached in 0696. Do not invent the rule here.
|
||||
/// And a WMO pool is bounded **below** by its own room's floor ([`WmoPool::floor`], decision 0701):
|
||||
/// owner scoping bounded a pool to its building but not to its *storey*, which left Undercity's
|
||||
/// upper slime channels submerging the rooms 115 yd beneath them — the same defect a third time,
|
||||
/// one level further in.
|
||||
pub(crate) fn liquid_at<'a>(
|
||||
liquids: impl Iterator<Item = &'a WaterChunkInfo>,
|
||||
wow: [f32; 3],
|
||||
claim: LiquidClaim,
|
||||
) -> Option<LiquidHit> {
|
||||
liquids
|
||||
.filter(|w| w.answers(claim))
|
||||
.filter(|w| w.answers(claim, wow[2]))
|
||||
.filter_map(|w| {
|
||||
w.surface_z_at(wow[0], wow[1]).map(|surface_z| LiquidHit {
|
||||
surface_z,
|
||||
@@ -566,18 +629,27 @@ pub(crate) fn describe_at<'a>(
|
||||
),
|
||||
(None, _) => "box-only (dry here)".to_string(),
|
||||
};
|
||||
let owner = match (w.source, w.owner()) {
|
||||
(LiquidSource::AdtChunk, _) => "AdtChunk".to_string(),
|
||||
(LiquidSource::WmoGroup(_), Some(o)) => {
|
||||
format!("WmoGroup {:?} g{}", o.instance, o.group)
|
||||
}
|
||||
(LiquidSource::WmoGroup(_), None) => "WmoGroup (unowned)".to_string(),
|
||||
// The owner column names the pool's room AND its floor: a candidate rejected for being
|
||||
// a storey up looks identical to one rejected for being another building's without it.
|
||||
let owner = match w.source {
|
||||
LiquidSource::AdtChunk => "AdtChunk".to_string(),
|
||||
LiquidSource::WmoGroup(pool) => match pool.owner {
|
||||
Some(o) => format!(
|
||||
"WmoGroup {:?} g{} floor {:.2}",
|
||||
o.instance, o.group, pool.floor
|
||||
),
|
||||
None => "WmoGroup (unowned)".to_string(),
|
||||
},
|
||||
};
|
||||
(
|
||||
z.unwrap_or(hi),
|
||||
format!(
|
||||
"{owner} {} {:?} {here} grid z [{lo:.2}..{hi:.2}] xy [{:.0}..{:.0}, {:.0}..{:.0}]",
|
||||
if w.answers(claim) { "◀ YOURS" } else { "· other room" },
|
||||
if w.answers(claim, wow[2]) {
|
||||
"◀ YOURS"
|
||||
} else {
|
||||
"· other room"
|
||||
},
|
||||
w.kind,
|
||||
w.min_x,
|
||||
w.max_x,
|
||||
@@ -660,6 +732,35 @@ fn submersion_of(kind: LiquidKind) -> benilla_formats::Submersion {
|
||||
/// Where surfaces stack, the **deepest submerging** one wins: standing in the Great Forge's lava
|
||||
/// under an unrelated water footprint should read as lava, and a `.any()` over an unordered query
|
||||
/// would otherwise answer with whichever entity the ECS happened to yield first.
|
||||
/// Which submerged atmosphere a position is in — the verdict [`detect_submersion`] publishes, as a
|
||||
/// plain function of a candidate set so it can be asked of the shipped files in a test.
|
||||
///
|
||||
/// It lived inline in the system until decision 0701, where the Undercity storey bug turned on it:
|
||||
/// the defect was never visible to [`liquid_at`] (which answers "the liquid over this XY", lowest
|
||||
/// wins, whether or not you are under it) but only to *this* rule — "every admitted surface the eye
|
||||
/// is beneath". A rule no test can call is a rule that drifts from the one being reasoned about.
|
||||
pub(super) fn submersion_at<'a>(
|
||||
liquids: impl Iterator<Item = &'a WaterChunkInfo>,
|
||||
wow: [f32; 3],
|
||||
claim: LiquidClaim,
|
||||
) -> benilla_formats::Submersion {
|
||||
liquids
|
||||
.filter(|w| w.answers(claim, wow[2]))
|
||||
.filter_map(|w| {
|
||||
let z = w.surface_z_at(wow[0], wow[1])?;
|
||||
let eps = if w.kind.is_fullbright() {
|
||||
0.0
|
||||
} else {
|
||||
SUBMERSION_EPS
|
||||
};
|
||||
(wow[2] < z + eps).then_some((z, submersion_of(w.kind)))
|
||||
})
|
||||
// Lowest surface first: `total_cmp` on the surface z, so a tie is still deterministic.
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn detect_submersion(
|
||||
mut underwater: ResMut<Underwater>,
|
||||
camera: Query<&Transform, With<WorldCamera>>,
|
||||
@@ -673,22 +774,7 @@ pub(super) fn detect_submersion(
|
||||
};
|
||||
let claim = camera_claim(&eye_claim);
|
||||
let eye = bevy_to_wow(cam.translation); // [x, y, z] WoW yards
|
||||
underwater.0 = water
|
||||
.iter()
|
||||
.filter(|w| w.answers(claim))
|
||||
.filter_map(|w| {
|
||||
let z = w.surface_z_at(eye[0], eye[1])?;
|
||||
let eps = if w.kind.is_fullbright() {
|
||||
0.0
|
||||
} else {
|
||||
SUBMERSION_EPS
|
||||
};
|
||||
(eye[2] < z + eps).then_some((z, submersion_of(w.kind)))
|
||||
})
|
||||
// Lowest surface first: `total_cmp` on the surface z, so a tie is still deterministic.
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or_default();
|
||||
underwater.0 = submersion_at(water.iter(), eye, claim);
|
||||
// `WOW_FOG_DUMP` also explains *this* decision (`frame` drops the 1 Hz throttle, as there). The
|
||||
// committed fog is whichever submerged atmosphere the verdict names, so a fog line alone cannot
|
||||
// say whether an atmosphere that reads wrong is the wrong record or the right record never
|
||||
@@ -703,10 +789,22 @@ pub(super) fn detect_submersion(
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
w.surface_z_at(eye[0], eye[1]).map(|z| {
|
||||
// Name the owner's GROUP, not just whether the claim admits it: with
|
||||
// placement scoping a pool in another STOREY of the same building still
|
||||
// answers, and its group index is the only thing on this line that tells
|
||||
// it apart from the room's own pool.
|
||||
format!(
|
||||
"{:?} z {z:.2}{}",
|
||||
"{:?} z {z:.2} {}{}",
|
||||
w.kind,
|
||||
if w.answers(claim) { "" } else { " (not yours)" }
|
||||
match w.owner() {
|
||||
Some(o) => format!("g{}", o.group),
|
||||
None => "adt".into(),
|
||||
},
|
||||
if w.answers(claim, eye[2]) {
|
||||
""
|
||||
} else {
|
||||
" (not yours)"
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -783,18 +881,36 @@ mod tests {
|
||||
Entity::from_raw_u32(n).expect("valid entity id")
|
||||
}
|
||||
|
||||
/// A WMO pool owned by placement `n`, group 0.
|
||||
/// A WMO pool owned by placement `n`, group 0, whose room has no floor — the fixture for the
|
||||
/// tests that are about the OWNER half of the scope. The floor half gets its own fixture
|
||||
/// ([`wmo_pool`]) so neither can silently stand in for the other.
|
||||
fn wmo_info(owner: u32, kind: LiquidKind, z: f32) -> WaterChunkInfo {
|
||||
flat_info(
|
||||
LiquidSource::WmoGroup(Some(WmoRoom {
|
||||
instance: placement(owner),
|
||||
group: 0,
|
||||
})),
|
||||
LiquidSource::WmoGroup(wmo_pool(owner, f32::NEG_INFINITY)),
|
||||
kind,
|
||||
z,
|
||||
)
|
||||
}
|
||||
|
||||
/// The scope of a pool in placement `n`'s group 0, whose room's floor is at `floor`.
|
||||
fn wmo_pool(owner: u32, floor: f32) -> WmoPool {
|
||||
WmoPool {
|
||||
owner: Some(WmoRoom {
|
||||
instance: placement(owner),
|
||||
group: 0,
|
||||
}),
|
||||
floor,
|
||||
}
|
||||
}
|
||||
|
||||
/// An unowned, unfloored pool — the portal-less placement's case.
|
||||
fn orphan_pool() -> WmoPool {
|
||||
WmoPool {
|
||||
owner: None,
|
||||
floor: f32::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// The claim of a subject standing inside placement `n`.
|
||||
fn inside(owner: u32) -> LiquidClaim {
|
||||
LiquidClaim::Inside(WmoRoom {
|
||||
@@ -832,7 +948,11 @@ mod tests {
|
||||
rotation: Quat::from_rotation_y(deg.to_radians()), // yaw about vertical
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
let info = wet_footprint(&flat_quad(5.0), &transform, LiquidSource::WmoGroup(None));
|
||||
let info = wet_footprint(
|
||||
&flat_quad(5.0),
|
||||
&transform,
|
||||
LiquidSource::WmoGroup(orphan_pool()),
|
||||
);
|
||||
let centre = bevy_to_wow(transform.transform_point(wow_to_bevy([5.0, 5.0, 5.0])));
|
||||
let z = info
|
||||
.surface_z_at(centre[0], centre[1])
|
||||
@@ -949,11 +1069,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The Undercity storey bug** (decision 0701): a pool in ANOTHER GROUP of the SAME placement,
|
||||
/// far above, still claimed you — owner scoping bounds a pool sideways to one building but not
|
||||
/// downwards to one room. Live repro at `.go xyz 1732.68 187.01 -65.70`: the eye's own room
|
||||
/// (group 182) held slime at z −64.48, *below* the eye and so not submerging it, while groups 7
|
||||
/// and 10 — the Ruins-of-Lordaeron-level channels at z 51.98, **115 yd overhead** — were what
|
||||
/// turned the screen green.
|
||||
///
|
||||
/// The floor is the fix, and the second half of this test is the reason it is a floor and not
|
||||
/// the reference's whole per-group AABB: a subject genuinely in a pool is always above the
|
||||
/// floor of the room holding it, so the bound cannot cost a swim.
|
||||
#[test]
|
||||
fn a_pool_upstairs_does_not_claim_the_room_below() {
|
||||
// Undercity's shape, in miniature: one placement, two rooms stacked 115 yd apart.
|
||||
let upstairs = flat_info(
|
||||
LiquidSource::WmoGroup(wmo_pool(1, 48.0)), // the upper channels' room floor
|
||||
LiquidKind::Slime,
|
||||
51.98,
|
||||
);
|
||||
let downstairs = flat_info(
|
||||
LiquidSource::WmoGroup(wmo_pool(1, -70.0)), // the Rogues'-Quarter-level room
|
||||
LiquidKind::Slime,
|
||||
-64.48,
|
||||
);
|
||||
let all = [&upstairs, &downstairs];
|
||||
|
||||
// The eye, standing in the lower room ABOVE its own slime. What turned the screen green was
|
||||
// the SUBMERSION rule — "every surface over the eye that the eye is under" — so that is what
|
||||
// this pins: the upstairs pool must not be a candidate for the eye at all.
|
||||
assert!(
|
||||
!upstairs.answers(inside(1), -63.59),
|
||||
"a pool 115 yd overhead, in another storey of the same building, must not submerge you"
|
||||
);
|
||||
assert!(
|
||||
downstairs.answers(inside(1), -63.59),
|
||||
"…while the eye's OWN room's pool stays a candidate (it is simply below the eye)"
|
||||
);
|
||||
// Step down into the lower room's OWN slime and it still answers — the floor bounds the
|
||||
// pool to its room, it does not cap how deep the room's own liquid reaches.
|
||||
assert_eq!(
|
||||
liquid_at(all.into_iter(), [5.0, 5.0, -66.0], inside(1))
|
||||
.unwrap()
|
||||
.surface_z,
|
||||
-64.48
|
||||
);
|
||||
// …and upstairs, standing in the upper channels, they answer as they always did.
|
||||
assert!(upstairs.answers(inside(1), 50.0));
|
||||
}
|
||||
|
||||
/// The floor is a property of the POOL, not of the delegation, so it holds for a subject whose
|
||||
/// claim has not been computed yet. An `Unknown` claim admits both sources — it must not also
|
||||
/// re-admit the pool three storeys up that every other claim rejects.
|
||||
#[test]
|
||||
fn the_floor_holds_even_for_an_unclassified_subject() {
|
||||
let upstairs = flat_info(
|
||||
LiquidSource::WmoGroup(wmo_pool(1, 48.0)),
|
||||
LiquidKind::Slime,
|
||||
51.98,
|
||||
);
|
||||
assert!(liquid_at(
|
||||
[&upstairs].into_iter(),
|
||||
[5.0, 5.0, -63.59],
|
||||
LiquidClaim::Unknown
|
||||
)
|
||||
.is_none());
|
||||
assert!(liquid_at(
|
||||
[&upstairs].into_iter(),
|
||||
[5.0, 5.0, 50.0],
|
||||
LiquidClaim::Unknown
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// A group with no bounds has no floor — a missing box fails OPEN (claims as it used to), never
|
||||
/// closed. Closing would turn "a pool claims too much" into "a lake nobody can swim in", which
|
||||
/// is the worse failure and the harder one to notice.
|
||||
#[test]
|
||||
fn a_pool_with_no_bounds_keeps_its_pre_floor_reach() {
|
||||
let unbounded = WmoPool::new(
|
||||
Some(WmoRoom {
|
||||
instance: placement(1),
|
||||
group: 0,
|
||||
}),
|
||||
&Transform::IDENTITY,
|
||||
None,
|
||||
);
|
||||
assert_eq!(unbounded.floor, f32::NEG_INFINITY);
|
||||
let pool = flat_info(LiquidSource::WmoGroup(unbounded), LiquidKind::Still, 8.0);
|
||||
assert!(liquid_at([&pool].into_iter(), [5.0, 5.0, -9999.0], inside(1)).is_some());
|
||||
}
|
||||
|
||||
/// The floor comes off the group box carried through the PLACEMENT transform, over all eight
|
||||
/// corners — a rotated placement's lowest corner is not the box's own `bbox_min`. Checked
|
||||
/// against a roll that swaps which corner is lowest.
|
||||
#[test]
|
||||
fn the_floor_follows_the_placement_transform() {
|
||||
let bounds = benilla_formats::WmoGroupInfo {
|
||||
interior: true,
|
||||
bbox_min: [-10.0, -10.0, 0.0],
|
||||
bbox_max: [10.0, 10.0, 4.0],
|
||||
};
|
||||
// A pure world lift: WoW z-floor 0 + 100 = 100 (Bevy +Y is the WoW z lift).
|
||||
let lifted = WmoPool::new(
|
||||
None,
|
||||
&Transform::from_translation(Vec3::new(0.0, 100.0, 0.0)),
|
||||
Some(&bounds),
|
||||
);
|
||||
assert!((lifted.floor - 100.0).abs() < 1e-3, "got {}", lifted.floor);
|
||||
// Rolled 90° about the Bevy Z axis (a WoW-X roll): the box's ±10 half-width in one
|
||||
// horizontal axis now reaches DOWN, so the floor is 10 below the placement, not 0.
|
||||
let rolled = WmoPool::new(
|
||||
None,
|
||||
&Transform {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
|
||||
scale: Vec3::ONE,
|
||||
},
|
||||
Some(&bounds),
|
||||
);
|
||||
assert!(
|
||||
rolled.floor < -9.0,
|
||||
"a rolled placement's floor must follow its corners, got {}",
|
||||
rolled.floor
|
||||
);
|
||||
}
|
||||
|
||||
/// A pool on a placement that spawned no instance entity is claimable by nobody — the honest
|
||||
/// consequence of having no owner, rather than a silent fallback to "everybody".
|
||||
#[test]
|
||||
fn an_unowned_pool_answers_no_one() {
|
||||
let orphan = flat_info(LiquidSource::WmoGroup(None), LiquidKind::Still, 8.0);
|
||||
let orphan = flat_info(
|
||||
LiquidSource::WmoGroup(orphan_pool()),
|
||||
LiquidKind::Still,
|
||||
8.0,
|
||||
);
|
||||
let feet = [5.0, 5.0, 0.0];
|
||||
assert!(liquid_at([&orphan].into_iter(), feet, inside(1)).is_none());
|
||||
assert!(liquid_at([&orphan].into_iter(), feet, LiquidClaim::Outdoors).is_none());
|
||||
@@ -1018,10 +1267,7 @@ mod tests {
|
||||
// Three cells in a row; the MIDDLE one is a hole, so the box spans dry ground between two
|
||||
// wet halves — the canal-either-side-of-a-tunnel shape.
|
||||
let info = grid_info(
|
||||
LiquidSource::WmoGroup(Some(WmoRoom {
|
||||
instance: placement(1),
|
||||
group: 0,
|
||||
})),
|
||||
LiquidSource::WmoGroup(wmo_pool(1, f32::NEG_INFINITY)),
|
||||
LiquidKind::Still,
|
||||
4,
|
||||
2,
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::query::{liquid_at, wet_footprint, LiquidClaim, LiquidSource, WaterChunkInfo};
|
||||
use super::query::{
|
||||
liquid_at, submersion_at, wet_footprint, LiquidClaim, LiquidSource, WaterChunkInfo, WmoPool,
|
||||
};
|
||||
use crate::wmo_portal::WmoRoom;
|
||||
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
|
||||
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh, LiquidMesh};
|
||||
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh, LiquidMesh, Submersion};
|
||||
|
||||
/// Every loaded liquid surface covering a position's tile neighbourhood, world-placed and
|
||||
/// **owner-tagged** — the ADT's own MCLQ chunks and every WMO placement's MLIQ groups, i.e. the same
|
||||
@@ -139,10 +141,16 @@ fn liquid_scene(map: &str, wow: [f32; 3]) -> Option<LiquidScene> {
|
||||
scene.surfaces.push(wet_footprint(
|
||||
lq_ref(&lq),
|
||||
&transform,
|
||||
LiquidSource::WmoGroup(Some(WmoRoom {
|
||||
instance,
|
||||
group: gi as u16,
|
||||
})),
|
||||
// Built through the APP's own constructor, not a copy of the rule —
|
||||
// a test that re-derived the floor would pin its own version of 0701.
|
||||
LiquidSource::WmoGroup(WmoPool::new(
|
||||
Some(WmoRoom {
|
||||
instance,
|
||||
group: gi as u16,
|
||||
}),
|
||||
&transform,
|
||||
root.group_infos().get(gi as usize),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -245,16 +253,29 @@ fn uldaman_is_not_submerged_in_a_mushroom_caves_pool() {
|
||||
eprintln!("skipping: no WoW client data");
|
||||
return;
|
||||
};
|
||||
// The bug, on the shipped files: with no owner scoping, a pool 185.91 yd up claims the player.
|
||||
// (There is no ADT liquid over this XY, so `Unknown` here is exactly the pre-0696 "indoors".)
|
||||
let unscoped = liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown)
|
||||
// The bug, on the shipped files: the pool B85 reported really is over this XY and really is
|
||||
// 185.91 yd up. Read off the surfaces with NO delegation at all — the reported number is a
|
||||
// property of the files, so the control must not run through any rule this test also judges.
|
||||
let reported = scene
|
||||
.surfaces
|
||||
.iter()
|
||||
.filter_map(|w| w.surface_z_at(feet[0], feet[1]))
|
||||
.filter(|z| *z > feet[2])
|
||||
.min_by(f32::total_cmp)
|
||||
.expect("the mushroom cave's pool is what B85 reported");
|
||||
assert!(
|
||||
(unscoped.surface_z - 399.64).abs() < 0.05,
|
||||
"the surface B85 reported (got {})",
|
||||
unscoped.surface_z
|
||||
(reported - 399.64).abs() < 0.05,
|
||||
"the surface B85 reported (got {reported})"
|
||||
);
|
||||
assert!(reported - feet[2] > 185.0, "…and 186 yd overhead");
|
||||
|
||||
// Since 0701 that pool is rejected TWICE over, and the second bound is independent of the
|
||||
// first: even a subject with no interior claim at all is out of the cave's room, 186 yd under
|
||||
// its floor. B85 would not have needed the owner half.
|
||||
assert!(
|
||||
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown).is_none(),
|
||||
"the cave's pool is below-the-floor rejected even for an unclassified subject"
|
||||
);
|
||||
assert!(unscoped.surface_z - feet[2] > 185.0, "…and 186 yd overhead");
|
||||
|
||||
// The fix: the player's room is Uldaman's own placement, and Uldaman has no pool here.
|
||||
let room = scene
|
||||
@@ -271,6 +292,83 @@ fn uldaman_is_not_submerged_in_a_mushroom_caves_pool() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The Undercity STOREY bug** (decision 0701) — the same wrong-underwater-filter as B60, one
|
||||
/// storey further in, and the half owner scoping could not reach. Live repro at
|
||||
/// `.go xyz 1732.68 187.01 -65.70`, where `WOW_FOG_DUMP` read:
|
||||
///
|
||||
/// ```text
|
||||
/// [submerged] Slime claim Inside(WmoRoom { instance: …, group: 182 }) eye [1731.9 187.0 -63.59]
|
||||
/// over-xy Slime z -64.48 g182, Slime z 51.98 g10, Slime z 51.98 g7
|
||||
/// ```
|
||||
///
|
||||
/// The eye's own room (group 182) holds slime at −64.48, *below* the eye and so not submerging it.
|
||||
/// What turned the screen green were groups 7 and 10 — Undercity's upper channels at z 51.98,
|
||||
/// **115 yd overhead** — which owner scoping admits because they are the same placement.
|
||||
///
|
||||
/// The server agrees the eye is dry there: `.gps` reports `Liquid level: -64.478561`, i.e. 0.9 yd
|
||||
/// *under* the eye.
|
||||
#[test]
|
||||
fn undercitys_upper_channels_do_not_submerge_the_rooms_below() {
|
||||
let eye = [1732.68_f32, 187.01, -63.59];
|
||||
let Some(scene) = liquid_scene("Azeroth", eye) else {
|
||||
eprintln!("skipping: no WoW client data");
|
||||
return;
|
||||
};
|
||||
// The control, straight off the files with no delegation: the pools that were claiming the eye
|
||||
// really are up at 51.98, and really are over this XY.
|
||||
let overhead: Vec<f32> = scene
|
||||
.surfaces
|
||||
.iter()
|
||||
.filter_map(|w| w.surface_z_at(eye[0], eye[1]))
|
||||
.filter(|z| *z > eye[2])
|
||||
.collect();
|
||||
assert!(
|
||||
!overhead.is_empty() && overhead.iter().all(|z| (z - 51.98).abs() < 0.05),
|
||||
"the surfaces over the eye are Undercity's upper channels at 51.98 (got {overhead:?})"
|
||||
);
|
||||
|
||||
// …and they belong to the eye's OWN placement, which is why 0696's owner scoping let them
|
||||
// through: this is a storey bug, not a building bug.
|
||||
let room = scene
|
||||
.containing_room(eye)
|
||||
.expect("the eye stands inside a placement");
|
||||
assert!(
|
||||
scene.model_of(room).contains("undercity"),
|
||||
"the containing placement is Undercity (got {})",
|
||||
scene.model_of(room)
|
||||
);
|
||||
|
||||
// The fix, asked of the rule the SCREEN runs: a pool never claims below its own room's floor,
|
||||
// so nothing over the eye submerges it and the filter stays off.
|
||||
assert_eq!(
|
||||
submersion_at(scene.surfaces.iter(), eye, LiquidClaim::Inside(room)),
|
||||
Submersion::Dry,
|
||||
"a pool 115 yd overhead, in another storey of the same building, must not submerge the eye"
|
||||
);
|
||||
// And the room's OWN slime is untouched — step down into it and the screen goes green properly.
|
||||
let inside_the_slime = [eye[0], eye[1], -66.0];
|
||||
assert_eq!(
|
||||
submersion_at(
|
||||
scene.surfaces.iter(),
|
||||
inside_the_slime,
|
||||
LiquidClaim::Inside(room)
|
||||
),
|
||||
Submersion::Slime,
|
||||
"the floor bounds the pool to its room; it does not cost the room its own swim"
|
||||
);
|
||||
let hit = liquid_at(
|
||||
scene.surfaces.iter(),
|
||||
inside_the_slime,
|
||||
LiquidClaim::Inside(room),
|
||||
)
|
||||
.expect("…and the surface itself still answers");
|
||||
assert!(
|
||||
(hit.surface_z - -64.48).abs() < 0.05,
|
||||
"…at the height the server reports (got {})",
|
||||
hit.surface_z
|
||||
);
|
||||
}
|
||||
|
||||
/// **B60 — Undercity's Rogues' Quarter under Tirisfal's water** (director repro,
|
||||
/// `.go xyz 1414.08 53.00 -62.26`; the `/liquid` line read `AdtChunk Still WET-CELL surface z 32.93
|
||||
/// (+95.19 over feet)` while the player's own VERDICT was already `none`).
|
||||
|
||||
@@ -16,12 +16,11 @@ use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::ExtendedMaterial;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::query::{wet_footprint, FoamPatch, LiquidSource};
|
||||
use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool};
|
||||
use crate::assets::LockRecover;
|
||||
use crate::assets::{liquid_frame_array, RenderConfig, WorldAssets};
|
||||
use crate::lighting::WATER_SHININESS;
|
||||
use crate::terrain::{LiquidExt, LiquidMaterial};
|
||||
use crate::wmo_portal::WmoRoom;
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use benilla_formats::{read_texture_mip_chain, BlpMipChain, LiquidKind, LiquidMesh};
|
||||
|
||||
@@ -209,11 +208,11 @@ fn liquid_bevy_mesh(lq: &LiquidMesh) -> Mesh {
|
||||
/// surface draws under — the Great Forge's lava hazes with the forge's own fog, Stormwind's open
|
||||
/// canals with the sky's (see [`LiquidKey`]).
|
||||
///
|
||||
/// `owner` is the [`WmoRoom`] the pool belongs to — the query's **scope key** (decision 0696). A
|
||||
/// liquid footprint has no floor, so a pool with no owner claims every position under its XY
|
||||
/// anywhere on the map: the Uldaman entrance read as submerged under a mushroom cave's water 186 yd
|
||||
/// overhead. `None` only for a placement that spawned no instance entity (see [`LiquidSource`]).
|
||||
#[allow(clippy::too_many_arguments)] // one param per concern: assets, placement, fog block, owner
|
||||
/// `pool` is the surface's **scope** (see [`WmoPool`]): the room it belongs to, and that room's own
|
||||
/// floor. A liquid footprint has no floor of its own, so an unscoped pool claims every position
|
||||
/// under its XY forever — the Uldaman entrance read as submerged under a mushroom cave's water
|
||||
/// 186 yd overhead (0696), and Undercity's upper slime submerged the rooms 115 yd below it (0701).
|
||||
#[allow(clippy::too_many_arguments)] // one param per concern: assets, placement, fog block, scope
|
||||
pub(crate) fn spawn_wmo_liquids<'a>(
|
||||
commands: &mut Commands,
|
||||
liquids: impl Iterator<Item = &'a LiquidMesh>,
|
||||
@@ -221,7 +220,7 @@ pub(crate) fn spawn_wmo_liquids<'a>(
|
||||
meshes: &mut Assets<Mesh>,
|
||||
transform: Transform,
|
||||
interior: bool,
|
||||
owner: Option<WmoRoom>,
|
||||
pool: WmoPool,
|
||||
entities: &mut Vec<Entity>,
|
||||
) {
|
||||
let Some(liquid) = liquid_assets else {
|
||||
@@ -260,7 +259,7 @@ pub(crate) fn spawn_wmo_liquids<'a>(
|
||||
commands.entity(surface).insert(wet_footprint(
|
||||
lq,
|
||||
&transform,
|
||||
LiquidSource::WmoGroup(owner),
|
||||
LiquidSource::WmoGroup(pool),
|
||||
));
|
||||
// Foam stays water-only: it is white surf, and there is no such thing on magma.
|
||||
if !lq.kind.is_fullbright() {
|
||||
|
||||
@@ -18,7 +18,10 @@ mod screen;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use bevy::input::keyboard::{Key, KeyboardInput};
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
|
||||
use crate::textinput::{self, HostClipboard};
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -332,14 +335,39 @@ pub(super) enum Field {
|
||||
Password,
|
||||
}
|
||||
|
||||
/// The typed form: both boxes' contents, the focus, the Remember checkbox, and the caret clock.
|
||||
#[derive(Resource, Default)]
|
||||
/// The typed form: both boxes, the focus, and the Remember checkbox. Each box is a real
|
||||
/// [`EditBoxState`] — the same byte-verified model the chat box uses — so the login fields get
|
||||
/// caret movement, selection, Ctrl+A and the clipboard from the shared law rather than the
|
||||
/// three-case imitation they used to carry (decision 0704). The caret clock lives in the box too
|
||||
/// (`blink_accum`/`caret_shown`), so it blinks on the client's own 0.5 s period.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct LoginForm {
|
||||
pub(super) account: String,
|
||||
pub(super) password: String,
|
||||
pub(super) account: EditBoxState,
|
||||
pub(super) password: EditBoxState,
|
||||
pub(super) focus: Field,
|
||||
pub(super) save: bool,
|
||||
pub(super) caret_t: f32,
|
||||
}
|
||||
|
||||
impl Default for LoginForm {
|
||||
fn default() -> Self {
|
||||
LoginForm {
|
||||
account: textinput::field(MAX_LETTERS, false),
|
||||
// `password` masks the *display* only; the real text is never rendered or copied.
|
||||
password: textinput::field(MAX_LETTERS, true),
|
||||
focus: Field::default(),
|
||||
save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
/// The box that currently owns the keyboard.
|
||||
fn focused(&mut self) -> &mut EditBoxState {
|
||||
match self.focus {
|
||||
Field::Account => &mut self.account,
|
||||
Field::Password => &mut self.password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The armed quit (`gsTitleQuit` needs [`QUIT_GRACE_SECS`] to be audible before `AppExit`).
|
||||
@@ -357,9 +385,8 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
|
||||
} else {
|
||||
Field::Password
|
||||
};
|
||||
form.account = saved;
|
||||
form.password.clear();
|
||||
form.caret_t = 0.0;
|
||||
form.account.set_text(&saved);
|
||||
form.password.set_text("");
|
||||
preview.scene = Some(GlueScene::MainMenu);
|
||||
preview.look = None;
|
||||
preview.yaw = 0.0;
|
||||
@@ -373,6 +400,9 @@ fn login_input(
|
||||
presses: Query<(&LoginAction, &Interaction), Changed<Interaction>>,
|
||||
mut keyboard: MessageReader<KeyboardInput>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
// The host pasteboard + the window handle the Wayland backend needs (decision 0702).
|
||||
mut clipboard: NonSendMut<HostClipboard>,
|
||||
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
|
||||
mut form: ResMut<LoginForm>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
mut dialog: ResMut<LoginDialog>,
|
||||
@@ -386,7 +416,6 @@ fn login_input(
|
||||
) {
|
||||
let empty = GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
form.caret_t += time.delta_secs();
|
||||
|
||||
let mut do_login = false;
|
||||
let mut do_quit = false;
|
||||
@@ -420,50 +449,38 @@ fn login_input(
|
||||
}
|
||||
}
|
||||
|
||||
let mods = textinput::mods_now(&keys);
|
||||
let wl = textinput::wayland_display(raw_handle.iter().next());
|
||||
for ev in keyboard.read() {
|
||||
if ev.state != ButtonState::Pressed || dialog_open {
|
||||
if dialog_open {
|
||||
continue;
|
||||
}
|
||||
match &ev.logical_key {
|
||||
Key::Character(chs) => {
|
||||
for ch in chs.chars() {
|
||||
if ch.is_ascii_graphic() {
|
||||
let field = match form.focus {
|
||||
Field::Account => &mut form.account,
|
||||
Field::Password => &mut form.password,
|
||||
};
|
||||
if field.len() < MAX_LETTERS {
|
||||
field.push(ch);
|
||||
}
|
||||
if form.focus == Field::Account {
|
||||
on_account_edited(&mut form);
|
||||
}
|
||||
}
|
||||
}
|
||||
form.caret_t = 0.0;
|
||||
// The shared law first (editing, caret, selection, the clipboard trio); only what it
|
||||
// hands back unclaimed is the screen's own — TAB cycles the two boxes, ENTER/ESCAPE are
|
||||
// handled below off `just_pressed`.
|
||||
if textinput::feed_key(
|
||||
form.focused(),
|
||||
ev,
|
||||
mods,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Any,
|
||||
) == textinput::FieldKey::Consumed
|
||||
{
|
||||
if form.focus == Field::Account {
|
||||
on_account_edited(&mut form);
|
||||
}
|
||||
Key::Backspace => {
|
||||
match form.focus {
|
||||
Field::Account => {
|
||||
form.account.pop();
|
||||
on_account_edited(&mut form);
|
||||
}
|
||||
Field::Password => {
|
||||
form.password.pop();
|
||||
}
|
||||
}
|
||||
form.caret_t = 0.0;
|
||||
}
|
||||
Key::Tab => {
|
||||
form.focus = match form.focus {
|
||||
Field::Account => Field::Password,
|
||||
Field::Password => Field::Account,
|
||||
};
|
||||
form.caret_t = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
continue;
|
||||
}
|
||||
if ev.state == ButtonState::Pressed && ev.key_code == KeyCode::Tab {
|
||||
form.focus = match form.focus {
|
||||
Field::Account => Field::Password,
|
||||
Field::Password => Field::Account,
|
||||
};
|
||||
form.focused().reset_blink();
|
||||
}
|
||||
}
|
||||
|
||||
if !dialog_open
|
||||
&& (keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter))
|
||||
{
|
||||
@@ -475,21 +492,21 @@ fn login_input(
|
||||
|
||||
if do_login && !intent.in_flight {
|
||||
// The ref's own guards: empty account / empty password get their dialog, no wire.
|
||||
if form.account.is_empty() {
|
||||
if form.account.text.is_empty() {
|
||||
dialog.open_error(strings.text("LOGIN_ENTER_NAME", "Please enter your account name."));
|
||||
} else if form.password.is_empty() {
|
||||
} else if form.password.text.is_empty() {
|
||||
dialog.open_error(strings.text("LOGIN_ENTER_PASSWORD", "Please enter your password."));
|
||||
} else {
|
||||
sounds.write(GlueSound("gsLogin"));
|
||||
// `AccountLogin_Login`: save or clear the account name per the checkbox, clear the
|
||||
// password box after grabbing it.
|
||||
if form.save {
|
||||
save_account(&form.account);
|
||||
save_account(&form.account.text);
|
||||
} else {
|
||||
save_account("");
|
||||
}
|
||||
let (user, pass) = (form.account.clone(), form.password.clone());
|
||||
form.password.clear();
|
||||
let (user, pass) = (form.account.text.clone(), form.password.text.clone());
|
||||
form.password.set_text("");
|
||||
dialog.open_status(strings.text("LOGIN_STATE_CONNECTING", "Connecting"));
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
|
||||
}
|
||||
@@ -506,7 +523,7 @@ fn login_input(
|
||||
fn on_account_edited(form: &mut LoginForm) {
|
||||
if form.save {
|
||||
let saved = load_saved_account();
|
||||
if !saved.is_empty() && saved != form.account {
|
||||
if !saved.is_empty() && saved != form.account.text {
|
||||
save_account("");
|
||||
form.save = false;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::char_select::wow_font;
|
||||
use crate::glue::art::{GlueArt, BACKDROP, GOLD};
|
||||
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
|
||||
use crate::glue::widgets::{
|
||||
abs, glue_button, glue_edit_box, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText,
|
||||
Hilight,
|
||||
abs, glue_button, glue_edit_box, outlined_text, overlay, paint_glue_field, ArtSwap,
|
||||
GlueBtnKind, GlueFieldPart, GlueText, Hilight,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::portrait::{PortraitImages, PortraitSource, GLUE_SLOT};
|
||||
@@ -56,18 +56,13 @@ pub(super) struct LoginUi {
|
||||
with_art: bool,
|
||||
s: f32,
|
||||
}
|
||||
/// The account box's text line (typed content, [`refresh_boxes`]).
|
||||
#[derive(Component)]
|
||||
/// The account box's row items (segments + carets — [`refresh_boxes`] paints them through
|
||||
/// [`paint_glue_field`]).
|
||||
#[derive(Component, Clone)]
|
||||
pub(super) struct AccountText;
|
||||
/// The password box's text line (the `*` mask).
|
||||
#[derive(Component)]
|
||||
/// The password box's row items (its display is the `*` mask — the box law's own `password` flag).
|
||||
#[derive(Component, Clone)]
|
||||
pub(super) struct PasswordText;
|
||||
/// Each box's caret bar — shown while that box holds the focus and the blink is on
|
||||
/// ([`refresh_boxes`]).
|
||||
#[derive(Component)]
|
||||
pub(super) struct AccountCaret;
|
||||
#[derive(Component)]
|
||||
pub(super) struct PasswordCaret;
|
||||
/// The checkbox's checked overlay (visibility = the form's save flag).
|
||||
#[derive(Component)]
|
||||
pub(super) struct CheckMark;
|
||||
@@ -331,7 +326,6 @@ fn spawn_screen(
|
||||
&edit_font,
|
||||
(action, Button),
|
||||
AccountText,
|
||||
AccountCaret,
|
||||
(160.0, 37.0),
|
||||
(BOX_BORDER, BOX_FILL),
|
||||
(15.0, 0.0, 0.0, 5.0), // AccountLogin.xml TextInsets
|
||||
@@ -344,7 +338,6 @@ fn spawn_screen(
|
||||
&edit_font,
|
||||
(action, Button),
|
||||
PasswordText,
|
||||
PasswordCaret,
|
||||
(160.0, 37.0),
|
||||
(BOX_BORDER, BOX_FILL),
|
||||
(15.0, 0.0, 0.0, 5.0), // AccountLogin.xml TextInsets
|
||||
@@ -499,44 +492,27 @@ fn spawn_screen(
|
||||
});
|
||||
}
|
||||
|
||||
/// Write each box's display line (the typed content, masked for the password) and show the caret
|
||||
/// bar of whichever box holds the focus, blinking 0.5 s on / 0.5 s off. The clock resets on every
|
||||
/// keystroke ([`super::drive_input`]), so the caret is solid while you type — the ref's own
|
||||
/// blink-accumulator reset.
|
||||
/// Paint both boxes from their [`EditBoxState`]s — segments, selection highlight, and the caret at
|
||||
/// the cursor — through the shared [`paint_glue_field`] (decision 0704).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_boxes(
|
||||
form: Res<LoginForm>,
|
||||
mut account: Query<&mut Text, (With<AccountText>, Without<PasswordText>)>,
|
||||
mut password: Query<&mut Text, With<PasswordText>>,
|
||||
mut account_caret: Query<&mut Visibility, (With<AccountCaret>, Without<PasswordCaret>)>,
|
||||
mut password_caret: Query<&mut Visibility, With<PasswordCaret>>,
|
||||
mut account: Query<
|
||||
(&GlueFieldPart, Option<&mut Text>, &mut Visibility),
|
||||
(With<AccountText>, Without<PasswordText>),
|
||||
>,
|
||||
mut password: Query<(&GlueFieldPart, Option<&mut Text>, &mut Visibility), With<PasswordText>>,
|
||||
) {
|
||||
if let Ok(mut t) = account.single_mut() {
|
||||
if t.0 != form.account {
|
||||
t.0.clone_from(&form.account);
|
||||
}
|
||||
}
|
||||
if let Ok(mut t) = password.single_mut() {
|
||||
let line = "*".repeat(form.password.len());
|
||||
if t.0 != line {
|
||||
t.0 = line;
|
||||
}
|
||||
}
|
||||
let caret_on = form.caret_t % 1.0 < 0.5;
|
||||
let show = |vis: Option<Mut<Visibility>>, field: Field| {
|
||||
if let Some(mut vis) = vis {
|
||||
let want = if form.focus == field && caret_on {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
};
|
||||
show(account_caret.single_mut().ok(), Field::Account);
|
||||
show(password_caret.single_mut().ok(), Field::Password);
|
||||
paint_glue_field(
|
||||
&form.account,
|
||||
form.focus == Field::Account,
|
||||
account.iter_mut(),
|
||||
);
|
||||
paint_glue_field(
|
||||
&form.password,
|
||||
form.focus == Field::Password,
|
||||
password.iter_mut(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The checkbox's visuals: the checked overlay tracks the form's save flag; the ADD hover ring
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
mod area;
|
||||
mod area_trigger;
|
||||
mod assets;
|
||||
mod bgwin;
|
||||
mod billboard;
|
||||
mod blob_shadow;
|
||||
mod bowstring;
|
||||
@@ -83,6 +84,7 @@ mod sun;
|
||||
mod target;
|
||||
mod terrain;
|
||||
mod terrain_stream;
|
||||
mod textinput;
|
||||
mod thread_qos;
|
||||
mod transport;
|
||||
mod ui_action;
|
||||
@@ -95,6 +97,7 @@ mod ui_craft;
|
||||
mod ui_duel;
|
||||
mod ui_gamma;
|
||||
mod ui_gossip;
|
||||
mod ui_hide;
|
||||
mod ui_inspect;
|
||||
mod ui_item_text;
|
||||
mod ui_items;
|
||||
@@ -164,6 +167,7 @@ use sun::SunPlugin;
|
||||
use target::TargetPlugin;
|
||||
use terrain::{TerrainMaterial, WowModelMaterial};
|
||||
use terrain_stream::TerrainPlugin;
|
||||
use textinput::TextInputPlugin;
|
||||
use transport::TransportPlugin;
|
||||
use ui_action::UiActionPlugin;
|
||||
use ui_aura::UiAuraPlugin;
|
||||
@@ -220,6 +224,9 @@ fn main() {
|
||||
// Visual A/B harness (decision 0008): with `$WOW_CAPTURE` set, the app runs a deterministic,
|
||||
// server-less capture (net off so no NPCs stream in nondeterministically) and exits. See `capture`.
|
||||
let capturing = capture::scenario_active();
|
||||
// Any instrumented run — captures AND the live-probe fleet — opens in the background so it
|
||||
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
|
||||
let background = bgwin::background_run();
|
||||
if capturing {
|
||||
// Ground clutter scatters with per-run randomness (the only non-deterministic element in the
|
||||
// scene), so disable it for byte-stable baselines — clutter isn't what the lighting rework
|
||||
@@ -277,14 +284,22 @@ fn main() {
|
||||
} else {
|
||||
bevy::window::PresentMode::default()
|
||||
},
|
||||
// A capture run must never steal the keyboard. It opens a real window for its
|
||||
// few seconds of settle time, and a focused one takes over whatever the
|
||||
// director is typing into — on 2026-07-19 a login-shot run swallowed their
|
||||
// keystrokes out of another app and typed them into the account box, which is
|
||||
// also how that capture lost the bare caret it was taken to measure. Any run
|
||||
// driven by a capture/shot env opens unfocused; an ordinary `cargo run` is
|
||||
// unaffected and still focuses normally.
|
||||
focused: !(capturing || std::env::var_os("WOW_LOGIN_SHOT_OUT").is_some()),
|
||||
// An instrumented run must never fight the director's screen (decision 0703).
|
||||
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
|
||||
// their keystrokes out of another app and typed them into the account box,
|
||||
// which is also how that capture lost the bare caret it was taken to measure.
|
||||
// Merely unfocused, a normal-level window still opens on TOP of their work.
|
||||
// So every probe/capture/regression run (`bgwin`) opens unfocused at the
|
||||
// bottom of the window stack — still on screen, so rendering, readback and
|
||||
// screenshots are untouched. An ordinary `cargo run` is unaffected and
|
||||
// focuses normally. The app-level half (winit's forced macOS app activation)
|
||||
// is `BgWinPlugin`'s job below.
|
||||
focused: !background,
|
||||
window_level: if background {
|
||||
bevy::window::WindowLevel::AlwaysOnBottom
|
||||
} else {
|
||||
bevy::window::WindowLevel::Normal
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
@@ -354,6 +369,10 @@ fn main() {
|
||||
.disable::<bevy::audio::AudioPlugin>(),
|
||||
)
|
||||
.add_plugins(thread_qos::ThreadQosPlugin)
|
||||
// The app-side half of background instrumented runs: undo winit's forced macOS app
|
||||
// activation so a probe/capture launch never yanks focus off the director's screen. The
|
||||
// window-side half (unfocused + always-on-bottom) is in the `Window` above. Decision 0703.
|
||||
.add_plugins(bgwin::BgWinPlugin)
|
||||
.add_plugins(MaterialPlugin::<TerrainMaterial>::default())
|
||||
.add_plugins(MaterialPlugin::<WowModelMaterial>::default())
|
||||
// Physics (avian3d): collider storage + broadphase BVH + shape-casts for the character
|
||||
@@ -497,7 +516,7 @@ fn main() {
|
||||
// Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a
|
||||
// `SetPortraitTexture`-bound region (the modern high-res 2D model bake).
|
||||
.add_plugins(PortraitPlugin)
|
||||
.add_plugins(UiScriptPlugin)
|
||||
.add_plugins((TextInputPlugin, UiScriptPlugin))
|
||||
// The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the
|
||||
// plain data the `Unit*` bindings read, and fires the matching WoW events.
|
||||
.add_plugins(UiUnitPlugin)
|
||||
@@ -555,6 +574,9 @@ fn main() {
|
||||
// Chat speech bubbles (0598): the over-the-head bubble a say/yell/party line spawns, the
|
||||
// plates' 2-D overlay sibling — mutually exclusive with both the plate and the name.
|
||||
.add_plugins(chat_bubble::ChatBubblePlugin)
|
||||
// TOGGLEUI (`CTRL-Z`/`Cmd-Z`): the whole quad layer goes dark — frames, minimap, plates,
|
||||
// bubbles, combat text — leaving the world and the cursor.
|
||||
.add_plugins(ui_hide::UiHidePlugin)
|
||||
.add_plugins(UiItemsPlugin)
|
||||
// The gossip window (decision 0081): fills from the net drain's GossipState and drives
|
||||
// GossipFrame.xml over the Era gossip API.
|
||||
|
||||
@@ -305,6 +305,24 @@ fn control(
|
||||
let typing = ui_capture.0;
|
||||
let keys_pressed = |k: KeyCode| !typing && keys.pressed(k);
|
||||
let keys_just_pressed = |k: KeyCode| !typing && keys.just_pressed(k);
|
||||
// Decision 0585's bare-binding rule, for this module's discrete letter bindings (F/X/Z): the
|
||||
// reference names a binding `[ALT-][CTRL-][SHIFT-]<key>` and matches it by string equality, so a
|
||||
// modified press is a *different* entry and must never fall through to the unmodified one —
|
||||
// `CTRL-Z` is TOGGLEUI ([`crate::ui_hide`]), not the sheath toggle. Same definition of "modified"
|
||||
// as the key feed's (`ui_script::input`, which adds our Cmd plane to the reference's three).
|
||||
// Held-state reads (WASD, Space, the Ctrl sprint) stay outside the rule deliberately: they read
|
||||
// keys as *state*, not as binding edges, and must keep working with a modifier down.
|
||||
let modified = keys.any_pressed([
|
||||
KeyCode::ShiftLeft,
|
||||
KeyCode::ShiftRight,
|
||||
KeyCode::ControlLeft,
|
||||
KeyCode::ControlRight,
|
||||
KeyCode::AltLeft,
|
||||
KeyCode::AltRight,
|
||||
KeyCode::SuperLeft,
|
||||
KeyCode::SuperRight,
|
||||
]);
|
||||
let bare_binding = |k: KeyCode| keys_just_pressed(k) && !modified;
|
||||
|
||||
// Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while
|
||||
// the character steers with the mouse (turns like a right-drag), regardless of which button went
|
||||
@@ -356,7 +374,7 @@ fn control(
|
||||
apply_zoom_scroll(mouse_scroll, dt, &mut rig);
|
||||
}
|
||||
|
||||
if keys_just_pressed(KeyCode::KeyF) {
|
||||
if bare_binding(KeyCode::KeyF) {
|
||||
player.detached = !player.detached;
|
||||
}
|
||||
|
||||
@@ -604,7 +622,7 @@ fn control(
|
||||
}
|
||||
let stand_state = player.stand_pending.unwrap_or(stand_byte);
|
||||
let mut request_stand = None;
|
||||
if keys_just_pressed(KeyCode::KeyX) {
|
||||
if bare_binding(KeyCode::KeyX) {
|
||||
request_stand = Some(u8::from(stand_state == 0));
|
||||
}
|
||||
// Any movement input stands the avatar back up (the client volunteers the stand — the
|
||||
@@ -652,7 +670,7 @@ fn control(
|
||||
// in the whole client that plays it (`bInstant = 0` at the 4 ToggleSheath sites — wow-re
|
||||
// `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own
|
||||
// refusal.
|
||||
if keys_just_pressed(KeyCode::KeyZ) {
|
||||
if bare_binding(KeyCode::KeyZ) {
|
||||
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _)) = self_player.single() {
|
||||
// The manual toggle's guard chain (decision 0080d) — the guards of the client's
|
||||
// 12-deep silent-refusal chain (`ToggleSheath` `0x5eb480`) whose states exist
|
||||
|
||||
@@ -258,12 +258,17 @@ pub(super) fn spawn_loaded_placements(
|
||||
// its pool draws under, so an indoor pool hazes with the room the way
|
||||
// the walls beside it do (decision 0691's open lane).
|
||||
m.group_bounds.get(gi).is_some_and(|g| g.interior),
|
||||
// …and the ROOM the pool belongs to — the liquid query's scope key
|
||||
// (0696): only a subject standing in this placement can be in it.
|
||||
p.portal_instance.map(|instance| WmoRoom {
|
||||
instance,
|
||||
group: gi as u16,
|
||||
}),
|
||||
// …and the pool's SCOPE: the room it belongs to (0696 — only a subject
|
||||
// standing in this placement can be in it) plus that room's own floor
|
||||
// in world Z (0701 — and only one at or above it).
|
||||
crate::liquid::WmoPool::new(
|
||||
p.portal_instance.map(|instance| WmoRoom {
|
||||
instance,
|
||||
group: gi as u16,
|
||||
}),
|
||||
&p.transform,
|
||||
m.group_bounds.get(gi),
|
||||
),
|
||||
&mut ents,
|
||||
);
|
||||
if let Some(instance) = p.portal_instance {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The host OS pasteboard seam — the one place benilla talks to the platform clipboard.
|
||||
//!
|
||||
//! The UI engine is engine-free and can't reach the OS clipboard (RF-0082's stated gap), so the
|
||||
//! three clipboard chords ([`keymap::Chord`]) resolve host-side: copy/cut write out what
|
||||
//! `editbox_copy`/`editbox_cut` hand back, paste reads the pasteboard and feeds `UiScript::paste`.
|
||||
//!
|
||||
//! **Why this is a held resource with a per-platform backend, and not `arboard::Clipboard::new()`
|
||||
//! per call.** Both halves of copy/paste were broken on Linux, for two independent reasons
|
||||
//! (decision 0702):
|
||||
//!
|
||||
//! 1. **On X11 the handle *is* the clipboard.** An X11 selection has no backing store: the owning
|
||||
//! client serves the bytes on request, so the clipboard lives exactly as long as the connection
|
||||
//! that owns it. arboard keeps one process-global connection plus a server thread and tears the
|
||||
//! whole thing down in `Drop` as soon as the last handle goes (its `MIN_OWNERS == 3` check —
|
||||
//! a lone handle *is* the last one), first offering the data to a clipboard manager. So a fresh
|
||||
//! handle per copy relinquishes the selection the instant the copy returns: with no clipboard
|
||||
//! manager running — a bare WM, the common gaming setup — text copied out of benilla vanishes
|
||||
//! immediately. One handle, held for the process lifetime, is the fix. macOS and Windows never
|
||||
//! showed this because `NSPasteboard` and the Win32 clipboard both take ownership of the *data*,
|
||||
//! not of a live connection, which is exactly why the per-call pattern read as fine.
|
||||
//! 2. **Our window is Wayland; arboard here is X11-only.** benilla builds with bevy's `wayland`
|
||||
//! feature on, so on a Wayland session winit hands us a native Wayland window with no X11
|
||||
//! surface at all — while `arboard`, without its **non-default** `wayland-data-control` feature,
|
||||
//! only ever speaks X11. Reading then finds no X11 CLIPBOARD owner and fails with
|
||||
//! `ContentNotAvailable`: precisely the reported "can't paste into the client on Linux".
|
||||
//! Turning arboard's Wayland feature on is *not* the fix — it speaks `wlr-data-control`, a
|
||||
//! privileged clipboard-manager protocol GNOME/Mutter deliberately does not implement, and
|
||||
//! wl-clipboard-rs' own README points windowed apps at `smithay-clipboard` instead. So Wayland
|
||||
//! goes through `smithay-clipboard`, which drives the **standard** `wl_data_device` from our own
|
||||
//! winit `wl_display` and seat — the protocol every compositor supports.
|
||||
//!
|
||||
//! Windows needs none of this: `clipboard-win` resolves with no feature gates and the Win32
|
||||
//! clipboard is store-backed, so the chord table ([`keymap`]) was already the whole story
|
||||
//! there.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RawHandleWrapper;
|
||||
|
||||
/// One platform pasteboard, behind just the two operations the chord table needs. Boxed as a trait
|
||||
/// object so the entire Wayland half — crate, types, and `unsafe` — stays inside one `cfg` block
|
||||
/// instead of sprouting a `cfg` on every enum variant and match arm.
|
||||
trait Pasteboard {
|
||||
/// The pasteboard's text. `Ok(None)` is the ordinary "nothing text-shaped on it" case (an empty
|
||||
/// clipboard is not an error); `Err` is a genuine failure worth a `warn!`.
|
||||
fn read_text(&mut self) -> Result<Option<String>, String>;
|
||||
|
||||
/// Put `text` on the pasteboard.
|
||||
fn write_text(&mut self, text: &str) -> Result<(), String>;
|
||||
|
||||
/// What to call this backend in a log line.
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl Pasteboard for arboard::Clipboard {
|
||||
fn read_text(&mut self) -> Result<Option<String>, String> {
|
||||
// `ContentNotAvailable` is arboard's "empty, or holds no format we asked for" — the normal
|
||||
// nothing-to-paste case, not a failure to report.
|
||||
match self.get_text() {
|
||||
Ok(text) if text.is_empty() => Ok(None),
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(arboard::Error::ContentNotAvailable) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text(&mut self, text: &str) -> Result<(), String> {
|
||||
self.set_text(text.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
// The backend arboard itself picked, which is the part worth naming in a bug report.
|
||||
if cfg!(target_os = "macos") {
|
||||
"arboard/NSPasteboard"
|
||||
} else if cfg!(windows) {
|
||||
"arboard/win32"
|
||||
} else {
|
||||
"arboard/x11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland half: the standard `wl_data_device` through our own `wl_display`. Gated to the
|
||||
/// platforms `smithay-clipboard` is a dependency on (see `Cargo.toml`) — Linux and the BSDs.
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
|
||||
mod wl {
|
||||
use super::Pasteboard;
|
||||
|
||||
impl Pasteboard for smithay_clipboard::Clipboard {
|
||||
fn read_text(&mut self) -> Result<Option<String>, String> {
|
||||
match self.load() {
|
||||
Ok(text) if text.is_empty() => Ok(None),
|
||||
Ok(text) => Ok(Some(text)),
|
||||
// Nothing offered for this seat — the Wayland spelling of an empty clipboard.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text(&mut self, text: &str) -> Result<(), String> {
|
||||
// `store` is infallible: it hands the text to the clipboard thread, which then serves
|
||||
// it to whoever asks for as long as we hold the seat.
|
||||
self.store(text.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"smithay/wl_data_device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `wl_display` behind the primary window, or `None` when this isn't a Wayland session (X11,
|
||||
/// macOS, Windows) — which is exactly the signal to fall back to [`arboard`].
|
||||
///
|
||||
/// Taken from bevy's own [`RawHandleWrapper`] rather than `WAYLAND_DISPLAY`: `smithay-clipboard`
|
||||
/// needs *our* connection (it works off the seat holding our keyboard focus), and the env var only
|
||||
/// says a Wayland session exists, not that winit chose it.
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
|
||||
pub(crate) fn wayland_display(handle: Option<&RawHandleWrapper>) -> Option<*mut c_void> {
|
||||
match handle?.get_display_handle() {
|
||||
raw_window_handle::RawDisplayHandle::Wayland(wl) => Some(wl.display.as_ptr()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Off the Wayland-capable platforms there is no `wl_display` to find, so the backend is always
|
||||
/// [`arboard`].
|
||||
#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
|
||||
pub(crate) fn wayland_display(_handle: Option<&RawHandleWrapper>) -> Option<*mut c_void> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the Wayland backend for `display`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `display` must be a live `wl_display` that outlives the returned backend. It comes from bevy's
|
||||
/// [`RawHandleWrapper`], which holds an `Arc` on the window keeping the connection alive, and the
|
||||
/// backend lives in a resource dropped during app teardown.
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
|
||||
fn open_wayland(display: *mut c_void) -> Box<dyn Pasteboard> {
|
||||
// SAFETY: see the doc comment — `display` is winit's own live `wl_display`.
|
||||
Box::new(unsafe { smithay_clipboard::Clipboard::new(display) })
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
|
||||
fn open_wayland(_display: *mut c_void) -> Box<dyn Pasteboard> {
|
||||
unreachable!("wayland_display() only yields Some on Wayland-capable platforms")
|
||||
}
|
||||
|
||||
/// The session's display-server environment, appended to the clipboard log lines. A clipboard bug
|
||||
/// report is nearly useless without it — "paste does nothing on Linux" has at least three different
|
||||
/// causes depending on these three variables, and asking the reporter costs a round trip.
|
||||
fn session_note() -> String {
|
||||
if !cfg!(unix) || cfg!(target_os = "macos") {
|
||||
return String::new();
|
||||
}
|
||||
let var = |k: &str| std::env::var(k).unwrap_or_else(|_| "unset".to_string());
|
||||
format!(
|
||||
" [XDG_SESSION_TYPE={}, WAYLAND_DISPLAY={}, DISPLAY={}]",
|
||||
var("XDG_SESSION_TYPE"),
|
||||
var("WAYLAND_DISPLAY"),
|
||||
var("DISPLAY"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The process-wide handle on the OS pasteboard.
|
||||
///
|
||||
/// A `NonSend` resource, for three reasons that all point the same way: neither backend is `Sync`,
|
||||
/// macOS's `NSPasteboard` must be touched from the main thread, and — the load-bearing one — the
|
||||
/// handle has to be *held* for the whole run or X11 copy silently does nothing (module docs).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HostClipboard {
|
||||
backend: Option<Box<dyn Pasteboard>>,
|
||||
/// Opening is attempted exactly once. A failure is sticky: a box with no usable clipboard
|
||||
/// shouldn't re-attempt (and re-warn) on every keystroke.
|
||||
opened: bool,
|
||||
}
|
||||
|
||||
impl HostClipboard {
|
||||
/// Resolve the backend on first use. Deferred rather than done at startup because the Wayland
|
||||
/// pointer comes from the window, and lazily because a run that never copies anything should
|
||||
/// never open a pasteboard connection at all.
|
||||
fn ensure(&mut self, wl_display: Option<*mut c_void>) {
|
||||
if self.opened {
|
||||
return;
|
||||
}
|
||||
self.opened = true;
|
||||
self.backend = match wl_display {
|
||||
Some(display) => Some(open_wayland(display)),
|
||||
None => match arboard::Clipboard::new() {
|
||||
Ok(clipboard) => Some(Box::new(clipboard)),
|
||||
Err(e) => {
|
||||
warn!("clipboard: unavailable — {e}{}", session_note());
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(backend) = &self.backend {
|
||||
info!("clipboard: {}{}", backend.name(), session_note());
|
||||
}
|
||||
}
|
||||
|
||||
/// The pasteboard's text, or `None` if it's empty, non-text, or unavailable.
|
||||
pub(crate) fn read(&mut self, wl_display: Option<*mut c_void>) -> Option<String> {
|
||||
self.ensure(wl_display);
|
||||
let backend = self.backend.as_mut()?;
|
||||
match backend.read_text() {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"clipboard: read failed via {} — {e}{}",
|
||||
backend.name(),
|
||||
session_note()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `text` on the pasteboard (the copy/cut half).
|
||||
pub(crate) fn write(&mut self, wl_display: Option<*mut c_void>, text: &str) {
|
||||
self.ensure(wl_display);
|
||||
let Some(backend) = self.backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = backend.write_text(text) {
|
||||
warn!(
|
||||
"clipboard: write failed via {} — {e}{}",
|
||||
backend.name(),
|
||||
session_note()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
//! CUA mirrors). Ctrl+Backspace/Delete word deletes are modern-OS additions with no 1.12
|
||||
//! counterpart; the whole macOS table is the platform's native law (Cmd/Option families), not
|
||||
//! the (Windows) reference's.
|
||||
//!
|
||||
//! One rule spans both of those platforms and is easy to miss: **AltGr is not Ctrl**. Windows and
|
||||
//! Linux both deliver AltGr as Ctrl+Alt, and European layouts type real letters with it, so the
|
||||
//! Ctrl letter chords all exclude it (decision 0702) — see [`chord_pc`].
|
||||
|
||||
use benilla_ui::script::{EditAction, EditUnit};
|
||||
use bevy::input::keyboard::KeyCode;
|
||||
@@ -16,16 +20,16 @@ use bevy::input::keyboard::KeyCode;
|
||||
/// The modifier snapshot a chord is read against. `sup` is the Super family — Cmd on macOS, the
|
||||
/// OS key elsewhere.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Mods {
|
||||
pub(super) shift: bool,
|
||||
pub(super) ctrl: bool,
|
||||
pub(super) alt: bool,
|
||||
pub(super) sup: bool,
|
||||
pub(crate) struct Mods {
|
||||
pub(crate) shift: bool,
|
||||
pub(crate) ctrl: bool,
|
||||
pub(crate) alt: bool,
|
||||
pub(crate) sup: bool,
|
||||
}
|
||||
|
||||
/// What a keypress means for the focused box.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Chord {
|
||||
pub(crate) enum Chord {
|
||||
/// A semantic edit — hand to `UiScript::editbox_action`.
|
||||
Edit(EditAction),
|
||||
/// Copy the selection to the OS clipboard (`UiScript::editbox_copy` + host write).
|
||||
@@ -38,7 +42,7 @@ pub(super) enum Chord {
|
||||
|
||||
/// The chord table: what `key` under `m` means, `None` when it means nothing (an unbound key —
|
||||
/// the caller falls through to plain character input, minus command-modified chars).
|
||||
pub(super) fn chord(key: KeyCode, m: Mods, mac: bool) -> Option<Chord> {
|
||||
pub(crate) fn chord(key: KeyCode, m: Mods, mac: bool) -> Option<Chord> {
|
||||
if mac {
|
||||
chord_mac(key, m)
|
||||
} else {
|
||||
@@ -167,10 +171,18 @@ fn chord_pc(key: KeyCode, m: Mods) -> Option<Chord> {
|
||||
// The other CUA mirrors the ref honors: Ctrl+Insert = copy, Shift+Insert = paste.
|
||||
KeyCode::Insert if m.ctrl => Some(Chord::Copy),
|
||||
KeyCode::Insert if m.shift => Some(Chord::Paste),
|
||||
KeyCode::KeyA if m.ctrl => Some(Chord::Edit(EditAction::SelectAll)),
|
||||
KeyCode::KeyC if m.ctrl => Some(Chord::Copy),
|
||||
KeyCode::KeyX if m.ctrl => Some(Chord::Cut),
|
||||
KeyCode::KeyV if m.ctrl => Some(Chord::Paste),
|
||||
// `&& !m.alt` is AltGr, and it is load-bearing on both Windows and Linux. AltGr is
|
||||
// delivered as Ctrl+Alt, and it is how European layouts type real letters: on a Polish
|
||||
// layout AltGr+A is `ą`, AltGr+C `ć`, AltGr+X `ź`. Without the exclusion those four
|
||||
// keystrokes are eaten as Select-All/Copy/Cut/Paste and the letter never reaches the box —
|
||||
// the character is simply untypeable in chat. Both platforms' own edit controls resolve it
|
||||
// this way (Ctrl+Alt+A is not Select All anywhere AltGr exists), and the char-input branch
|
||||
// in `input.rs` already lets the AltGr plane through for exactly this reason; the chord
|
||||
// table has to agree with it or it just intercepts the key first. Decision 0702.
|
||||
KeyCode::KeyA if m.ctrl && !m.alt => Some(Chord::Edit(EditAction::SelectAll)),
|
||||
KeyCode::KeyC if m.ctrl && !m.alt => Some(Chord::Copy),
|
||||
KeyCode::KeyX if m.ctrl && !m.alt => Some(Chord::Cut),
|
||||
KeyCode::KeyV if m.ctrl && !m.alt => Some(Chord::Paste),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -348,4 +360,31 @@ mod tests {
|
||||
// Super means nothing on this side.
|
||||
assert_eq!(chord(KeyCode::KeyA, SUP, false), None);
|
||||
}
|
||||
|
||||
/// AltGr — delivered as Ctrl+Alt on both Windows and Linux — types real letters on European
|
||||
/// layouts (`ą`/`ć`/`ź` on a Polish one), so it must fall through to character input rather
|
||||
/// than being swallowed as the Ctrl clipboard chord. Regression for decision 0702.
|
||||
#[test]
|
||||
fn altgr_letters_are_not_clipboard_chords() {
|
||||
const ALTGR: Mods = Mods {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
..NONE
|
||||
};
|
||||
for key in [KeyCode::KeyA, KeyCode::KeyC, KeyCode::KeyX, KeyCode::KeyV] {
|
||||
assert_eq!(
|
||||
chord(key, ALTGR, false),
|
||||
None,
|
||||
"AltGr+{key:?} must reach character input, not act as a clipboard chord"
|
||||
);
|
||||
}
|
||||
// The exclusion is exactly AltGr: plain Ctrl is still the chord.
|
||||
assert_eq!(
|
||||
edit(chord(KeyCode::KeyA, CTRL, false)),
|
||||
EditAction::SelectAll
|
||||
);
|
||||
assert_eq!(chord(KeyCode::KeyC, CTRL, false), Some(Chord::Copy));
|
||||
assert_eq!(chord(KeyCode::KeyX, CTRL, false), Some(Chord::Cut));
|
||||
assert_eq!(chord(KeyCode::KeyV, CTRL, false), Some(Chord::Paste));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! **The one text-input law, host half.** Every text field in the client — the FrameXML EditBoxes
|
||||
//! (chat, `/who`, the StaticPopup) and the three glue-screen fields (login account/password, the
|
||||
//! character name, the delete confirmation) — resolves its keystrokes here.
|
||||
//!
|
||||
//! There are three parts, and the split is deliberate:
|
||||
//!
|
||||
//! - [`keymap`] — *which chord means what*, per OS. The one place Ctrl+V vs Cmd+V is decided
|
||||
//! (decision 0301).
|
||||
//! - [`clipboard`] — *the OS pasteboard*, one held handle per process, per-platform backend
|
||||
//! (decision 0702).
|
||||
//! - [`feed_key`] (here) — the glue between them and the **engine's** byte-verified box law
|
||||
//! ([`EditBoxState`], decision 0704). It owns no editing semantics of its own.
|
||||
//!
|
||||
//! ## Why this module exists
|
||||
//!
|
||||
//! The editing law was reachable only through the Lua UI runtime, so the glue screens — which have
|
||||
//! no Lua VM — each hand-rolled a three-case imitation: append a printable char, Backspace, Tab.
|
||||
//! No caret movement, no selection, no Ctrl+A, and no clipboard at all. Worse, they matched on
|
||||
//! winit's `logical_key`, which is `Character("v")` for Ctrl+V, so **pasting into the login box
|
||||
//! typed a literal `v` into the password**. Four fields, four different laws, three of them wrong.
|
||||
//!
|
||||
//! Now the law is [`EditBoxState`]'s (pure, no Lua — decision 0704) and the *routing* is this
|
||||
//! module's, so a field gets the whole thing by owning an `EditBoxState` and calling [`feed_key`].
|
||||
//! The FrameXML path keeps its own dispatcher because it must also fire Lua handlers and route
|
||||
//! focus through the widget arena, but it reads the same [`keymap`] and the same [`clipboard`].
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
|
||||
pub(crate) mod clipboard;
|
||||
pub(crate) mod keymap;
|
||||
|
||||
pub(crate) use clipboard::{wayland_display, HostClipboard};
|
||||
pub(crate) use keymap::{chord, Chord, Mods};
|
||||
|
||||
/// Adds the process-wide OS pasteboard. Nothing else here is a system: [`feed_key`] is a plain
|
||||
/// function each screen calls from its own input pass, because every screen already owns its focus
|
||||
/// model (the login form's two-field enum, the dialog's single box) and inverting that into a
|
||||
/// component-driven focus would be churn for no gain.
|
||||
pub(crate) struct TextInputPlugin;
|
||||
|
||||
impl Plugin for TextInputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Held for the whole run: on X11 dropping the handle *is* clearing the clipboard
|
||||
// (decision 0702). `NonSend` — no backend is `Sync`, NSPasteboard is main-thread-only.
|
||||
app.init_non_send_resource::<HostClipboard>();
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier snapshot for this frame, read once and handed to every [`feed_key`] call.
|
||||
pub(crate) fn mods_now(keys: &ButtonInput<KeyCode>) -> Mods {
|
||||
Mods {
|
||||
shift: keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight),
|
||||
ctrl: keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight),
|
||||
alt: keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight),
|
||||
sup: keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which characters a field accepts. The box law's own `numeric` flag covers digits-only; this
|
||||
/// covers the one other rule the client has — a character name is letters (the create screen's
|
||||
/// `is_ascii_alphabetic` guard). It is applied to **pasted** text as well as typed, which the old
|
||||
/// hand-rolled screens could not do at all, having no paste.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CharFilter {
|
||||
/// Everything printable (the login boxes).
|
||||
Any,
|
||||
/// ASCII letters only (the character-name box).
|
||||
Letters,
|
||||
}
|
||||
|
||||
impl CharFilter {
|
||||
fn allows(self, c: char) -> bool {
|
||||
match self {
|
||||
CharFilter::Any => true,
|
||||
CharFilter::Letters => c.is_ascii_alphabetic(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the characters this filter allows.
|
||||
fn keep(self, text: &str) -> String {
|
||||
match self {
|
||||
CharFilter::Any => text.chars().filter(|c| !c.is_control()).collect(),
|
||||
CharFilter::Letters => text.chars().filter(|&c| self.allows(c)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`feed_key`] did with a key press.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FieldKey {
|
||||
/// The field handled it — an edit, a caret move, or a clipboard operation. The screen must not
|
||||
/// also act on this key.
|
||||
Consumed,
|
||||
/// Not a text-editing key. The screen decides (ENTER submits, ESCAPE cancels, TAB cycles
|
||||
/// focus) — those are the screen's semantics, not the field's, exactly as the FrameXML box
|
||||
/// hands ENTER/ESCAPE/TAB to its own scripts.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
/// Feed one key press to `field`. The whole shared law in one call: the per-OS chord table decides
|
||||
/// what the chord means, [`EditBoxState`] executes it, and the clipboard trio resolves against the
|
||||
/// held host pasteboard.
|
||||
///
|
||||
/// `wl_display` comes from [`wayland_display`] — `None` off Wayland.
|
||||
pub(crate) fn feed_key(
|
||||
field: &mut EditBoxState,
|
||||
ev: &KeyboardInput,
|
||||
mods: Mods,
|
||||
clipboard: &mut HostClipboard,
|
||||
wl_display: Option<*mut c_void>,
|
||||
filter: CharFilter,
|
||||
) -> FieldKey {
|
||||
if ev.state != ButtonState::Pressed {
|
||||
return FieldKey::Passthrough;
|
||||
}
|
||||
// The three box-event keys are the screen's, always — even though a chord table entry could
|
||||
// claim them. This mirrors the FrameXML split (`script::editbox::key_input`).
|
||||
if matches!(
|
||||
ev.key_code,
|
||||
KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Escape | KeyCode::Tab
|
||||
) {
|
||||
return FieldKey::Passthrough;
|
||||
}
|
||||
if let Some(chord) = chord(ev.key_code, mods, cfg!(target_os = "macos")) {
|
||||
match chord {
|
||||
Chord::Edit(action) => {
|
||||
field.apply(action);
|
||||
}
|
||||
Chord::Copy => {
|
||||
if let Some(text) = field.selected_text() {
|
||||
clipboard.write(wl_display, &text);
|
||||
}
|
||||
}
|
||||
Chord::Cut => {
|
||||
if let Some(text) = field.cut_selection() {
|
||||
clipboard.write(wl_display, &text);
|
||||
}
|
||||
}
|
||||
Chord::Paste => {
|
||||
if let Some(text) = clipboard.read(wl_display) {
|
||||
// Filter before the box sees it, so a name box can't be pasted full of digits.
|
||||
field.paste(&filter.keep(&text));
|
||||
}
|
||||
}
|
||||
}
|
||||
return FieldKey::Consumed;
|
||||
}
|
||||
// Plain character input. A command-modified char never types (Cmd/Ctrl+L must not insert "l"),
|
||||
// but Ctrl+Alt passes: that is AltGr, the plane European layouts type real characters with —
|
||||
// the same guard the FrameXML feed uses, and the reason the chord table excludes AltGr too
|
||||
// (decision 0702).
|
||||
if !(mods.sup || (mods.ctrl && !mods.alt)) {
|
||||
if let Some(text) = &ev.text {
|
||||
// C0 control characters are consumed-but-inert, as in the box's own `char_input`.
|
||||
let printable = filter.keep(text);
|
||||
if !printable.is_empty() {
|
||||
field.insert(&printable);
|
||||
return FieldKey::Consumed;
|
||||
}
|
||||
}
|
||||
}
|
||||
FieldKey::Passthrough
|
||||
}
|
||||
|
||||
/// A fresh single-line field with `max_letters` (0 = unlimited) and optional password masking —
|
||||
/// the glue screens' constructor, so they never hand-assemble an [`EditBoxState`] and quietly miss
|
||||
/// a flag.
|
||||
pub(crate) fn field(max_letters: usize, password: bool) -> EditBoxState {
|
||||
EditBoxState {
|
||||
max_letters,
|
||||
password,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the caret blink and report whether it is currently drawn — the box's own blink law
|
||||
/// (`E+0x370`/`E+0x374`, ctor default 0.5 s), so a glue caret and a chat caret blink identically.
|
||||
/// An unfocused field always reports "hidden" without accumulating.
|
||||
pub(crate) fn tick_caret(field: &mut EditBoxState, focused: bool, dt: f32) -> bool {
|
||||
if !focused {
|
||||
return false;
|
||||
}
|
||||
if field.blink_period > 0.0 {
|
||||
field.blink_accum += dt;
|
||||
if field.blink_accum > field.blink_period {
|
||||
field.caret_shown = !field.caret_shown;
|
||||
field.blink_accum = 0.0;
|
||||
}
|
||||
}
|
||||
field.caret_shown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The name box takes letters only — and the filter runs on **pasted** text too, which is the
|
||||
/// case the old hand-rolled screens could not express at all (they had no paste). Regression
|
||||
/// for decision 0704.
|
||||
#[test]
|
||||
fn letters_filter_applies_to_pasted_text() {
|
||||
assert_eq!(CharFilter::Letters.keep("Bob123"), "Bob");
|
||||
assert_eq!(CharFilter::Letters.keep("a b\tc\n"), "abc");
|
||||
assert_eq!(CharFilter::Letters.keep("123"), "");
|
||||
}
|
||||
|
||||
/// `Any` keeps everything printable but still drops control characters — a pasted newline must
|
||||
/// never reach a single-line box as a literal.
|
||||
#[test]
|
||||
fn any_filter_keeps_printables_and_drops_controls() {
|
||||
assert_eq!(CharFilter::Any.keep("pass word!"), "pass word!");
|
||||
assert_eq!(CharFilter::Any.keep("one\ntwo\r"), "onetwo");
|
||||
}
|
||||
|
||||
/// A letters-only paste still honours the box's own cap, because the filter feeds
|
||||
/// `EditBoxState::paste`, which enforces `max_letters` like any other insert.
|
||||
#[test]
|
||||
fn a_filtered_paste_still_obeys_max_letters() {
|
||||
let mut f = field(4, false);
|
||||
f.paste(&CharFilter::Letters.keep("ab12cdef"));
|
||||
assert_eq!(f.text, "abcd");
|
||||
}
|
||||
|
||||
/// A password field masks its display but never its buffer — the login password box relies on
|
||||
/// this, and on `selected_text` yielding the mask rather than the secret.
|
||||
#[test]
|
||||
fn a_password_field_masks_display_and_copies() {
|
||||
let mut f = field(0, true);
|
||||
f.insert("hunter2");
|
||||
assert_eq!(f.text, "hunter2");
|
||||
assert_eq!(f.display(), "*******");
|
||||
f.highlight_text(0, -1);
|
||||
assert_eq!(f.selected_text().as_deref(), Some("*******"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! **TOGGLEUI** — the hide-the-interface binding (`CTRL-Z`, and `Cmd-Z` as its Mac twin).
|
||||
//!
|
||||
//! The state ([`UiHidden`]) and the binding that flips it; the two things that *obey* it are the
|
||||
//! quad pass's mesh rebuild (`ui_pass::rebuild_ui_mesh` draws nothing) and the UI's pointer feed
|
||||
//! (`ui_script::input::feed_ui_input` stops hit-testing). Kept out of `ui_pass` deliberately: this
|
||||
//! is a *game binding*, not part of the render substrate.
|
||||
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::ui_script::{UiInput, UiKeyboardCapture};
|
||||
|
||||
/// Is the player UI hidden right now? (`CTRL-Z`/`Cmd-Z` — [`toggle_ui_hidden`].)
|
||||
///
|
||||
/// Hidden means *"draw nothing"*, not *"stop producing"*: the widget arena keeps ticking and both
|
||||
/// quad lanes keep filling, so open panels, tooltip state, cooldown sweeps and chat scrollback are
|
||||
/// exactly where they were the moment the UI comes back — only the pass's mesh batches go away.
|
||||
/// **Everything that lands in `ui_pass::UiQuads` goes dark together**: the FrameXML layer, the
|
||||
/// minimap, the V-plates, chat bubbles and floating combat text — the world and nothing else,
|
||||
/// which is the point of the binding. What stays up: the dev overlays (their own camera and their
|
||||
/// own `Ctrl`+`Cmd` chords — an instrument, not the player's UI), the glue/loading screens (Bevy UI
|
||||
/// nodes, not quads), and the cursor.
|
||||
///
|
||||
/// The UI also stops taking the **mouse** while hidden: an invisible action bar must not eat a
|
||||
/// click or arm a tooltip. The keyboard feed stays live — a hidden UI is still the client you're
|
||||
/// playing, and `ENTER`/`ESCAPE`/the bar keys keep working exactly as the reference's do.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct UiHidden(pub bool);
|
||||
|
||||
/// The TOGGLEUI binding + its state.
|
||||
pub(crate) struct UiHidePlugin;
|
||||
|
||||
impl Plugin for UiHidePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<UiHidden>()
|
||||
.add_systems(
|
||||
Update,
|
||||
// After `UiInput`, like every other gameplay key reader: an EditBox that consumed
|
||||
// this frame's keys has already published its capture flag by then.
|
||||
toggle_ui_hidden
|
||||
.after(UiInput)
|
||||
.run_if(in_state(ClientState::InWorld)),
|
||||
)
|
||||
.add_systems(OnExit(ClientState::InWorld), show_ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CTRL-Z` / `Cmd-Z` — the reference's TOGGLEUI binding (`BINDING_NAME_TOGGLEUI` in
|
||||
/// `GlobalStrings.lua`; the 1.12.1 install's own `bindings-cache.wtf` reads `bind CTRL-Z TOGGLEUI`
|
||||
/// on all three accounts in it), plus the `Cmd` twin — the Mac keyboard the director plays the
|
||||
/// reference on presses `Cmd` for that binding, and nothing in game binds the Cmd plane.
|
||||
///
|
||||
/// Exactly one of the two, and no `Alt`/`Shift`: the reference names a binding
|
||||
/// `[ALT-][CTRL-][SHIFT-]<key>` and matches it by string equality, so `CTRL-ALT-Z` is a *different*
|
||||
/// entry and a modified press must never fall through to another binding (decision 0585's
|
||||
/// bare-binding rule — the discipline `vplates::toggle_vplates` spends `SHIFT` under), while
|
||||
/// `Ctrl`+`Cmd` together is the dev-overlay plane. Inert while an EditBox holds the keyboard: a
|
||||
/// `Z` in a chat line is text.
|
||||
///
|
||||
/// Driven off the key **message stream** rather than `ButtonInput`'s edges, because AppKit swallows
|
||||
/// `keyUp` for a key released while `Cmd` is held: the release never reaches winit, `ButtonInput`
|
||||
/// goes on believing `Z` is down, and the *second* `Cmd-Z` would never produce a `just_pressed`
|
||||
/// edge — the UI would hide and refuse to come back. `repeat` presses are dropped so a held chord
|
||||
/// doesn't strobe.
|
||||
fn toggle_ui_hidden(
|
||||
mut keyboard: MessageReader<KeyboardInput>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
ui_capture: Res<UiKeyboardCapture>,
|
||||
mut hidden: ResMut<UiHidden>,
|
||||
) {
|
||||
let pressed = keyboard
|
||||
.read()
|
||||
.any(|ev| ev.key_code == KeyCode::KeyZ && ev.state == ButtonState::Pressed && !ev.repeat);
|
||||
if !pressed || ui_capture.0 || !toggle_chord(&keys) {
|
||||
return;
|
||||
}
|
||||
hidden.0 = !hidden.0;
|
||||
info!(
|
||||
"ui: {} (Ctrl/Cmd-Z)",
|
||||
if hidden.0 { "HIDDEN" } else { "shown" }
|
||||
);
|
||||
}
|
||||
|
||||
/// The modifier half of the chord: exactly one of `Ctrl`/`Cmd` held, and neither `Alt` nor `Shift`.
|
||||
/// See [`toggle_ui_hidden`] for why each arm is what it is.
|
||||
fn toggle_chord(keys: &ButtonInput<KeyCode>) -> bool {
|
||||
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
|
||||
let cmd = keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]);
|
||||
let blocked = keys.any_pressed([
|
||||
KeyCode::AltLeft,
|
||||
KeyCode::AltRight,
|
||||
KeyCode::ShiftLeft,
|
||||
KeyCode::ShiftRight,
|
||||
]);
|
||||
(ctrl ^ cmd) && !blocked
|
||||
}
|
||||
|
||||
/// Leaving the world clears the hide — safety, not fidelity: the binding is `InWorld`-only, so a UI
|
||||
/// left hidden at logout would come back invisible with no on-screen affordance to explain it.
|
||||
fn show_ui(mut hidden: ResMut<UiHidden>) {
|
||||
hidden.0 = false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The plugin's own wiring, driven the way winit drives it: a real [`KeyboardInput`] message
|
||||
/// plus the modifier mirror. Covers what the pure-chord tests can't — the message path (the
|
||||
/// `Cmd`-swallowed-`keyUp` insurance), the repeat filter, and the typing gate.
|
||||
fn app(held: &[KeyCode]) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::input::InputPlugin,
|
||||
bevy::state::app::StatesPlugin,
|
||||
))
|
||||
.insert_state(ClientState::InWorld)
|
||||
.init_resource::<UiKeyboardCapture>()
|
||||
.add_plugins(UiHidePlugin);
|
||||
for &k in held {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(k);
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
fn press_z(app: &mut App, repeat: bool) {
|
||||
let window = Entity::PLACEHOLDER;
|
||||
app.world_mut().write_message(KeyboardInput {
|
||||
key_code: KeyCode::KeyZ,
|
||||
logical_key: bevy::input::keyboard::Key::Character("z".into()),
|
||||
state: ButtonState::Pressed,
|
||||
text: Some("z".into()),
|
||||
repeat,
|
||||
window,
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn hidden(app: &App) -> bool {
|
||||
app.world().resource::<UiHidden>().0
|
||||
}
|
||||
|
||||
/// The binding toggles both ways off the message stream — the second press is the one an
|
||||
/// edge-driven read would lose on macOS (AppKit eats the `keyUp` under `Cmd`).
|
||||
#[test]
|
||||
fn cmd_z_hides_and_shows_again() {
|
||||
let mut app = app(&[KeyCode::SuperLeft]);
|
||||
press_z(&mut app, false);
|
||||
assert!(hidden(&app), "Cmd-Z hides");
|
||||
press_z(&mut app, false);
|
||||
assert!(!hidden(&app), "Cmd-Z again shows");
|
||||
}
|
||||
|
||||
/// A held chord auto-repeats; only the real press counts, or the UI would strobe.
|
||||
#[test]
|
||||
fn repeats_do_not_strobe() {
|
||||
let mut app = app(&[KeyCode::ControlLeft]);
|
||||
press_z(&mut app, false);
|
||||
press_z(&mut app, true);
|
||||
press_z(&mut app, true);
|
||||
assert!(hidden(&app), "the repeats after the press changed nothing");
|
||||
}
|
||||
|
||||
/// While an EditBox owns the keyboard the chord is inert — `Ctrl-Z` in a chat line is the
|
||||
/// box's business, not a binding.
|
||||
#[test]
|
||||
fn inert_while_typing() {
|
||||
let mut app = app(&[KeyCode::ControlLeft]);
|
||||
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
|
||||
press_z(&mut app, false);
|
||||
assert!(!hidden(&app), "a captured keyboard eats the chord");
|
||||
}
|
||||
|
||||
/// The bare-binding rule at the wiring level: an unmodified `Z` is the sheath toggle
|
||||
/// (`player::control`), and it must not also hide the UI.
|
||||
#[test]
|
||||
fn bare_z_is_not_the_binding() {
|
||||
let mut app = app(&[]);
|
||||
press_z(&mut app, false);
|
||||
assert!(!hidden(&app), "bare Z belongs to the sheath toggle");
|
||||
}
|
||||
|
||||
fn keys(held: &[KeyCode]) -> ButtonInput<KeyCode> {
|
||||
let mut k = ButtonInput::default();
|
||||
for &c in held {
|
||||
k.press(c);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The chord accepts either command modifier alone — `CTRL-Z` is the reference's own entry,
|
||||
/// `Cmd-Z` the Mac twin — and both sides of each family.
|
||||
#[test]
|
||||
fn either_command_modifier_alone_fires() {
|
||||
for held in [
|
||||
KeyCode::ControlLeft,
|
||||
KeyCode::ControlRight,
|
||||
KeyCode::SuperLeft,
|
||||
KeyCode::SuperRight,
|
||||
] {
|
||||
assert!(toggle_chord(&keys(&[held])), "{held:?} alone fires");
|
||||
}
|
||||
}
|
||||
|
||||
/// Decision 0585's bare-binding rule, both directions: an unmodified `Z` is the *sheath*
|
||||
/// binding and must not hide the UI, and a press carrying an extra modifier names a different
|
||||
/// binding entry (`CTRL-ALT-Z`, `CTRL-SHIFT-Z`) that this one must not answer for. `Ctrl`+`Cmd`
|
||||
/// is the dev-overlay plane and is likewise not ours.
|
||||
#[test]
|
||||
fn bare_and_over_modified_presses_are_not_the_binding() {
|
||||
assert!(!toggle_chord(&keys(&[])), "bare Z is the sheath binding");
|
||||
for held in [
|
||||
vec![KeyCode::ControlLeft, KeyCode::AltLeft],
|
||||
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft],
|
||||
vec![KeyCode::SuperLeft, KeyCode::AltRight],
|
||||
vec![KeyCode::SuperLeft, KeyCode::ShiftRight],
|
||||
vec![KeyCode::ControlLeft, KeyCode::SuperLeft],
|
||||
] {
|
||||
assert!(!toggle_chord(&keys(&held)), "{held:?} is another binding");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,13 +601,32 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||||
fn rebuild_ui_mesh(
|
||||
mut quads: ResMut<UiQuads>,
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<UiQuadMaterial>>,
|
||||
// The two asset stores the rebuild writes, bundled (clippy's argument ceiling).
|
||||
mut stores: (ResMut<Assets<Mesh>>, ResMut<Assets<UiQuadMaterial>>),
|
||||
mut pools: Local<BatchPools>,
|
||||
white: Option<Res<UiWhiteTexture>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
hidden: Res<crate::ui_hide::UiHidden>,
|
||||
) {
|
||||
let (meshes, materials) = (&mut stores.0, &mut stores.1);
|
||||
let q = quads.as_mut();
|
||||
// TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes
|
||||
// back exactly as it was (see [`crate::ui_hide::UiHidden`]). Retire the batches on the toggle's
|
||||
// down edge; on the way back up, force one full rebuild — while dark we swallow each frame's
|
||||
// change flag and keep the append-lane mirror current, so neither lane can hand the rebuild a
|
||||
// stale "nothing changed" the moment the UI returns. The edge is the resource's own change tick
|
||||
// (`UiHidden` is written only by the binding and the world-exit reset), not a `Local` mirror.
|
||||
if hidden.0 {
|
||||
if hidden.is_changed() {
|
||||
retire_batches(&mut pools, &mut commands);
|
||||
}
|
||||
q.dirty = false;
|
||||
q.last_overlays.clone_from(&q.overlays);
|
||||
return;
|
||||
}
|
||||
if hidden.is_changed() {
|
||||
q.dirty = true;
|
||||
}
|
||||
if !q.dirty && q.overlays == q.last_overlays {
|
||||
return;
|
||||
}
|
||||
@@ -916,6 +935,67 @@ fn seed_demo_quads(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The rebuild in isolation: real resources, no renderer (the batches are plain entities until
|
||||
/// something draws them).
|
||||
fn rebuild_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
|
||||
.init_asset::<Mesh>()
|
||||
.init_asset::<UiQuadMaterial>()
|
||||
.init_resource::<UiQuads>()
|
||||
.init_resource::<crate::ui_hide::UiHidden>()
|
||||
.insert_resource(UiWhiteTexture(Handle::default()))
|
||||
.add_systems(Update, rebuild_ui_mesh);
|
||||
app.world_mut().spawn((Window::default(), PrimaryWindow));
|
||||
// One flat quad in the BASE lane — enough content for exactly one batch.
|
||||
let mut quads = app.world_mut().resource_mut::<UiQuads>();
|
||||
quads.quads.push(UiQuad {
|
||||
rect: Rect::new(0.0, 0.0, 64.0, 64.0),
|
||||
..default()
|
||||
});
|
||||
quads.dirty = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn batches(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query_filtered::<(), With<UiQuadBatch>>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn set_hidden(app: &mut App, hidden: bool) {
|
||||
app.world_mut().resource_mut::<crate::ui_hide::UiHidden>().0 = hidden;
|
||||
}
|
||||
|
||||
/// TOGGLEUI at the draw: hiding retires the batches, and un-hiding brings the SAME content back
|
||||
/// — the case the two lanes' change protocol would otherwise swallow, since nothing about the
|
||||
/// quads themselves changed while the UI was dark (`dirty` false, the append lane identical),
|
||||
/// so the rebuild would have taken its early-out and left the screen bare.
|
||||
#[test]
|
||||
fn toggleui_retires_the_batches_and_restores_them() {
|
||||
let mut app = rebuild_app();
|
||||
app.update();
|
||||
assert_eq!(batches(&mut app), 1, "one batch drawn to begin with");
|
||||
|
||||
set_hidden(&mut app, true);
|
||||
app.update();
|
||||
assert_eq!(batches(&mut app), 0, "hidden ⇒ nothing drawn");
|
||||
// Still nothing after further frames: the producers keep filling both lanes, and the
|
||||
// rebuild must keep taking its dark path rather than re-spawning on the next flagged frame.
|
||||
app.world_mut().resource_mut::<UiQuads>().dirty = true;
|
||||
app.update();
|
||||
assert_eq!(batches(&mut app), 0, "hidden stays hidden");
|
||||
|
||||
set_hidden(&mut app, false);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
batches(&mut app),
|
||||
1,
|
||||
"the UI comes back on the same content"
|
||||
);
|
||||
}
|
||||
|
||||
// The mirror-preservation guarantee, and the reason [`UvRect`] exists instead of
|
||||
// `bevy::math::Rect`: PlayerFrameTexture's `<TexCoords left="1.0" right="0.09375">`
|
||||
// (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The player-UI input pass: [`feed_ui_input`] hit-tests the cursor and dispatches mouse/keyboard
|
||||
//! events into the UI engine (after [`super::extract::drive_script`] has resolved the frame's
|
||||
//! rects), plus the action-bar key map and the OS clipboard read for paste. Split out of [`super`]
|
||||
//! purely for size — the plugin wiring and the extraction pass live there and in
|
||||
//! [`super::extract`] respectively.
|
||||
//! rects), plus the action-bar key map. The OS pasteboard itself lives in [`crate::textinput`].
|
||||
//! Split out of [`super`] purely for size — the plugin wiring and the extraction pass live there
|
||||
//! and in [`super::extract`] respectively.
|
||||
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::mouse::AccumulatedMouseScroll;
|
||||
@@ -12,7 +12,8 @@ use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::{textkeys, CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use crate::textinput::{self, keymap, HostClipboard};
|
||||
|
||||
/// The pointer-side state [`feed_ui_input`] reads and writes, as one
|
||||
/// [`bevy::ecs::system::SystemParam`] (the argument ceiling): the UI hover + click-consumed
|
||||
@@ -28,6 +29,9 @@ pub(super) struct PointerFeed<'w> {
|
||||
hovered_object: Res<'w, crate::target::HoveredObject>,
|
||||
occlusion: Res<'w, crate::target::PickOcclusion>,
|
||||
payload_held: ResMut<'w, CursorPayloadHeld>,
|
||||
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all — it rides here
|
||||
/// because that is precisely the pointer half of this pass.
|
||||
hidden: Res<'w, crate::ui_hide::UiHidden>,
|
||||
}
|
||||
|
||||
impl PointerFeed<'_> {
|
||||
@@ -57,7 +61,10 @@ impl PointerFeed<'_> {
|
||||
/// box consumes must never also reach the world in the same frame.
|
||||
pub(super) fn feed_ui_input(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
// The window's raw handle rides along with it: on Wayland it carries the `wl_display` the
|
||||
// clipboard backend is built from (decision 0702). `Option`, because it only appears once
|
||||
// winit has actually created the surface.
|
||||
window: Query<(&Window, Option<&bevy::window::RawHandleWrapper>), With<PrimaryWindow>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
scroll: Res<AccumulatedMouseScroll>,
|
||||
// One [`PointerFeed`] (clippy's argument ceiling): the hover + click-consumed outputs this
|
||||
@@ -65,17 +72,20 @@ pub(super) fn feed_ui_input(
|
||||
// the payload-held mirror written for the Send-side world-click consumers.
|
||||
mut pointer: PointerFeed,
|
||||
// Bundled into one param (clippy's argument ceiling): this frame's key messages, the modifier
|
||||
// mirror, and the capture gate the keyboard feed writes.
|
||||
// mirror, the capture gate the keyboard feed writes, and the held OS pasteboard the three
|
||||
// clipboard chords resolve against (decision 0702).
|
||||
mut kbd: (
|
||||
MessageReader<KeyboardInput>,
|
||||
Res<ButtonInput<KeyCode>>,
|
||||
ResMut<UiKeyboardCapture>,
|
||||
NonSendMut<HostClipboard>,
|
||||
),
|
||||
// The uiScale dial folded into the seam scale (decision 0584).
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
) {
|
||||
let (keyboard, keys, capture) = (&mut kbd.0, &kbd.1, &mut kbd.2);
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
let world_pick = pointer.world_pick();
|
||||
let ui_hidden = pointer.hidden.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
@@ -87,11 +97,13 @@ pub(super) fn feed_ui_input(
|
||||
payload_held.0 = false;
|
||||
return;
|
||||
};
|
||||
let Ok(window) = window.single() else {
|
||||
let Ok((window, raw_handle)) = window.single() else {
|
||||
capture.0 = false;
|
||||
payload_held.0 = false;
|
||||
return;
|
||||
};
|
||||
// `Some` only on a Wayland session — the signal the clipboard backend picks itself by.
|
||||
let wl_display = textinput::wayland_display(raw_handle);
|
||||
// The engine's world-drop routing (decisions 0571 + 0574): an object pick keeps every
|
||||
// payload (the reference's object leg dispatches SELECT with the item still held), terrain
|
||||
// drops items only, nothing drops any arm.
|
||||
@@ -104,7 +116,11 @@ pub(super) fn feed_ui_input(
|
||||
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
|
||||
script.set_modifiers(shift, ctrl, alt);
|
||||
// ── Mouse ── (cursor-off-window only skips the mouse feed; keyboard still flows to a focused box)
|
||||
if let Some(cursor) = window.cursor_position() {
|
||||
// A UI hidden by TOGGLEUI takes the same route as a cursor outside the window: an action bar
|
||||
// nobody can see must not eat the click or arm a tooltip, and the else-arm below is exactly the
|
||||
// "no pointer here" bookkeeping (leave the hovered frame once, disarm any press/drag every
|
||||
// frame) that keeps a stale gesture from firing when the UI comes back.
|
||||
if let Some(cursor) = window.cursor_position().filter(|_| !ui_hidden) {
|
||||
// Window cursor is logical px, y-down from top-left; the UI is y-up 768-virtual units
|
||||
// (decisions 0582 + 0584's uiScale dial) — flip through the window height, then ÷s into
|
||||
// the VM's space (the inverse of the extract seam's ×s).
|
||||
@@ -164,14 +180,14 @@ pub(super) fn feed_ui_input(
|
||||
|
||||
// ── Keyboard → VM ── the three box-event keys route to `key_input` by name (and never also
|
||||
// as text, so Enter isn't a stray newline); every *editing* key goes through the per-OS
|
||||
// chord table ([`textkeys::chord`]) and reaches the box as a semantic `EditAction` — or as
|
||||
// chord table ([`keymap::chord`]) and reaches the box as a semantic `EditAction` — or as
|
||||
// one of the clipboard operations, handled here (this is a NonSend, main-thread system —
|
||||
// required for the macOS NSPasteboard). What's left goes to `char_input`, minus
|
||||
// command-modified chars (Cmd/Ctrl+letter must never type the letter — except Ctrl+Alt, the
|
||||
// AltGr plane European layouts type real characters with). Repeats carry `state == Pressed`,
|
||||
// so held keys repeat. Modifiers reuse the mirror read above (the message carries none).
|
||||
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
|
||||
let mods = textkeys::Mods {
|
||||
let mods = keymap::Mods {
|
||||
shift,
|
||||
ctrl,
|
||||
alt,
|
||||
@@ -222,7 +238,7 @@ pub(super) fn feed_ui_input(
|
||||
// The editing keys/chords — dispatched unconditionally like the named keys (the engine's
|
||||
// route law decides; unfocused they return unconsumed, and the camera/turn keys that
|
||||
// read arrows after UiInput see them exactly as before).
|
||||
let chord = textkeys::chord(ev.key_code, mods, mac);
|
||||
let chord = keymap::chord(ev.key_code, mods, mac);
|
||||
if let Some(name) = named {
|
||||
let consumed = script.key_input(name);
|
||||
// ESCAPE with no EditBox focused runs the escape binding: the reference's own
|
||||
@@ -238,24 +254,25 @@ pub(super) fn feed_ui_input(
|
||||
}
|
||||
} else if let Some(chord) = chord {
|
||||
match chord {
|
||||
textkeys::Chord::Edit(action) => {
|
||||
keymap::Chord::Edit(action) => {
|
||||
script.editbox_action(action);
|
||||
}
|
||||
// The clipboard trio needs the OS pasteboard, so it resolves here: copy/cut pull
|
||||
// the selection out of the box (RF-0082 §4 `0x77e1d0` — selection required; a
|
||||
// password box yields its mask run, never the real text), paste sanitizes+inserts.
|
||||
textkeys::Chord::Copy => {
|
||||
// The clipboard trio needs the OS pasteboard, so it resolves here against the held
|
||||
// [`HostClipboard`]: copy/cut pull the selection out of the box (RF-0082 §4
|
||||
// `0x77e1d0` — selection required; a password box yields its mask run, never the
|
||||
// real text), paste sanitizes+inserts.
|
||||
keymap::Chord::Copy => {
|
||||
if let Some(text) = script.editbox_copy() {
|
||||
write_clipboard(&text);
|
||||
clipboard.write(wl_display, &text);
|
||||
}
|
||||
}
|
||||
textkeys::Chord::Cut => {
|
||||
keymap::Chord::Cut => {
|
||||
if let Some(text) = script.editbox_cut() {
|
||||
write_clipboard(&text);
|
||||
clipboard.write(wl_display, &text);
|
||||
}
|
||||
}
|
||||
textkeys::Chord::Paste => {
|
||||
if let Some(text) = read_clipboard() {
|
||||
keymap::Chord::Paste => {
|
||||
if let Some(text) = clipboard.read(wl_display) {
|
||||
script.paste(&text);
|
||||
}
|
||||
}
|
||||
@@ -343,26 +360,3 @@ fn bare_key_binding(key: KeyCode) -> Option<(&'static str, &'static str)> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the OS clipboard's text, or `None` if it's empty/non-text or the clipboard is unavailable. A
|
||||
/// fresh `arboard::Clipboard` per paste (paste is rare, so the handle isn't worth caching in a
|
||||
/// resource — and `Clipboard` isn't `Send + Sync`, so it couldn't be a Bevy resource anyway). Must be
|
||||
/// called on the main thread (the caller is a NonSend system), which macOS's NSPasteboard requires.
|
||||
fn read_clipboard() -> Option<String> {
|
||||
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
|
||||
Ok(text) if !text.is_empty() => Some(text),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("ui_script(paste): clipboard read failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `text` to the OS clipboard (the copy/cut half of the seam; same main-thread
|
||||
/// NSPasteboard constraint as [`read_clipboard`]).
|
||||
fn write_clipboard(text: &str) {
|
||||
if let Err(e) = arboard::Clipboard::new().and_then(|mut c| c.set_text(text.to_string())) {
|
||||
warn!("ui_script(copy): clipboard write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ use crate::ui_unit::UnitFeed;
|
||||
|
||||
mod extract;
|
||||
mod input;
|
||||
mod textkeys;
|
||||
|
||||
/// Is the pointer over *any* UI this frame — the egui dev overlay OR a mouse-enabled player-UI
|
||||
/// frame? The single source of truth for "the mouse is talking to the UI, not the world",
|
||||
|
||||
Reference in New Issue
Block a user