From 1531628a28faf01ab65e947f09bb35a25fedd790 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 27 Jul 2026 19:37:42 +0700 Subject: [PATCH] copy and paste works everywhere, and the sun keeps its colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 105 ++++- .../examples/wmo_liquid_scope.rs | 406 ++++++++++++++++++ .../src/bin/benilla-extract/main.rs | 34 +- crates/benilla-ui/src/script/editbox.rs | 246 +---------- .../benilla-ui/src/script/editbox/interact.rs | 46 +- crates/benilla-ui/src/script/mod.rs | 5 +- crates/benilla-ui/src/script/types.rs | 44 +- crates/benilla-ui/src/widget/kinds/editbox.rs | 395 +++++++++++++++++ crates/benilla-ui/src/widget/mod.rs | 9 +- crates/benilla-visual/src/lib.rs | 28 ++ crates/benilla-visual/src/main.rs | 137 +++++- crates/benilla/Cargo.toml | 21 +- crates/benilla/assets/shaders/wow_model.wgsl | 38 +- crates/benilla/src/bgwin.rs | 145 +++++++ crates/benilla/src/capture/live_shot.rs | 256 +++++++++++ crates/benilla/src/capture/mod.rs | 6 +- crates/benilla/src/capture/probes.rs | 138 +----- crates/benilla/src/char_create/mod.rs | 93 ++-- crates/benilla/src/char_create/parts.rs | 8 +- crates/benilla/src/char_create/refresh.rs | 6 +- crates/benilla/src/char_create/screen.rs | 3 +- crates/benilla/src/char_select/dialog.rs | 138 +++--- crates/benilla/src/char_select/mod.rs | 2 +- crates/benilla/src/debug_panel/state.rs | 12 +- crates/benilla/src/glue/widgets.rs | 152 +++++-- crates/benilla/src/liquid/mod.rs | 2 +- crates/benilla/src/liquid/query.rs | 344 ++++++++++++--- crates/benilla/src/liquid/real_data.rs | 124 +++++- crates/benilla/src/liquid/surface.rs | 17 +- crates/benilla/src/login/mod.rs | 125 +++--- crates/benilla/src/login/screen.rs | 72 ++-- crates/benilla/src/main.rs | 40 +- crates/benilla/src/player.rs | 24 +- .../benilla/src/terrain_stream/spawn/mod.rs | 17 +- crates/benilla/src/textinput/clipboard.rs | 236 ++++++++++ .../textkeys.rs => textinput/keymap.rs} | 61 ++- crates/benilla/src/textinput/mod.rs | 241 +++++++++++ crates/benilla/src/ui_hide.rs | 227 ++++++++++ crates/benilla/src/ui_pass.rs | 84 +++- crates/benilla/src/ui_script/input.rs | 84 ++-- crates/benilla/src/ui_script/mod.rs | 1 - 41 files changed, 3287 insertions(+), 885 deletions(-) create mode 100644 crates/benilla-formats/examples/wmo_liquid_scope.rs create mode 100644 crates/benilla/src/bgwin.rs create mode 100644 crates/benilla/src/capture/live_shot.rs create mode 100644 crates/benilla/src/textinput/clipboard.rs rename crates/benilla/src/{ui_script/textkeys.rs => textinput/keymap.rs} (81%) create mode 100644 crates/benilla/src/textinput/mod.rs create mode 100644 crates/benilla/src/ui_hide.rs diff --git a/Cargo.lock b/Cargo.lock index e1b708c7..9ba4799c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/benilla-formats/examples/wmo_liquid_scope.rs b/crates/benilla-formats/examples/wmo_liquid_scope.rs new file mode 100644 index 00000000..d29c49fe --- /dev/null +++ b/crates/benilla-formats/examples/wmo_liquid_scope.rs @@ -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, + /// 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 { + 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 { + 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 { + 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> = BTreeMap::new(); + let adts: Vec = 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 = 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 = BTreeMap::new(); + + for (model, sites) in &placed { + let stem = model.strip_suffix(".wmo").unwrap_or(model); + let mut groups: Vec = 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 = BTreeMap::new(); + let mut odd: Vec = Vec::new(); + for name in chain.list()?.into_iter().map(|e| e.name) { + let lower = name.to_lowercase(); + // group files are `_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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index b2523290..4b841c85 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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, diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 7dcf1462..6a51ea66 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -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) ─────────────────────────────────── diff --git a/crates/benilla-ui/src/script/editbox/interact.rs b/crates/benilla-ui/src/script/editbox/interact.rs index 61b31eeb..8dc3ccf0 100644 --- a/crates/benilla-ui/src/script/editbox/interact.rs +++ b/crates/benilla-ui/src/script/editbox/interact.rs @@ -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 { 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 { - 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) diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 5017b2c7..d84f27ca 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index b243983b..4f2b412e 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -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 diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index 8d58508b..dd905241 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -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 { + 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 { + 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::*; diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 7e73ca72..a77b3c22 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -173,10 +173,11 @@ impl Arena { 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, }; // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-visual/src/lib.rs b/crates/benilla-visual/src/lib.rs index 4ca1ec5b..d4362d48 100644 --- a/crates/benilla-visual/src/lib.rs +++ b/crates/benilla-visual/src/lib.rs @@ -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]); diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index 3f963a45..f0067931 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -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, + rect: Option, + 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 ")?; 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 ,,, (source pixels)")?; + let out = opts.out.as_deref().context("crop needs --out ")?; + 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 { + let parts: Vec<&str> = spec.split(',').map(str::trim).collect(); + let [x, y, w, h] = parts.as_slice() else { + bail!("--rect wants \",,,\", got {spec:?}"); + }; + let parse = |v: &str, name: &str| -> Result { + 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> { spec.split(';') @@ -637,7 +748,8 @@ fn print_usage() { benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual series --at \",[;,…]\"\n \ - benilla-visual compose-dir --out (side-by-side `a | b` per image)\n\ + benilla-visual compose-dir --out (side-by-side `a | b` per image)\n \ + benilla-visual crop --rect \",,,\" --out [--scale ] [--at \",;…\"]\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()); + } +} diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index 0ab2bbfb..b09cf2cc 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -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", diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 9d5a59a4..5558d06a 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -433,14 +433,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp let quad = vec4(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(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(0.0), - vec3(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(0.0), vec3(1.0), ); diff --git a/crates/benilla/src/bgwin.rs b/crates/benilla/src/bgwin.rs new file mode 100644 index 00000000..fa051341 --- /dev/null +++ b/crates/benilla/src/bgwin.rs @@ -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) { + 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(); + } + } + } +} diff --git a/crates/benilla/src/capture/live_shot.rs b/crates/benilla/src/capture/live_shot.rs new file mode 100644 index 00000000..9c9d24d8 --- /dev/null +++ b/crates/benilla/src/capture/live_shot.rs @@ -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=`, 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=` makes it a burst** — `n` shots written as `-000.png`, +/// `-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 ` collapses the burst to the envelope of what +/// would not hold still, and the lit pixels are the defect. +/// +/// **`WOW_SHOT_REQUIRE=` 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, + 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, + time: Res