Compare commits

...

3 Commits

Author SHA1 Message Date
en
4ee58acf2c T13: press-and-hold repeat for mouse broadcast / mirror clicks
Hyprland mouse binds fire once per press; for multiboxing you want a
held mouse button to repeat clicks on every captured slot at a
configurable cadence. Wire that:

- mouse-press <button> IPC verb: fires the broadcast once immediately,
  then spawns a per-button tokio task that re-fires it every
  ENBOXER_MOUSE_REPEAT_MS (default 50 ms = 20 Hz). Stored per button
  in Session.mouse_repeats.
- mouse-release <button>: cancels the matching repeat.
- mirror_repeat_loop worker: holds an Arc<AtomicBool> cancel signal so
  the release side can flip it without taking the Session mutex.
- Bind installation: each mouse button (272, 273) now installs BOTH
  a press BindSpec AND a release=true BindSpec for both mouse_broadcast
  and Mode::Mirror so the behaviour is automatic for the operator.
  Hyprland translates release=true to { release = true } bind option.

Cadence is tunable at startup via ENBOXER_MOUSE_REPEAT_MS, clamped to
1..=2000 ms. The mirror_clicks_to docstring (previously "OUT OF
SCOPE for T13 ...") now points at mirror_repeat_loop.

cargo test 98+/0; clippy clean.
2026-09-16 12:43:33 +02:00
en
bb6d9e8566 Drop stale stub/follow-up/not-shipped markers in docs
After T10 shipped (commit 0ba3c59) several docs still claimed the
gbm_bo_map / ENBOXER_ENABLE_TOPLEVEL path was a stub, follow-up, or
"/dev/dri/renderD128 + new dep" work that never happened. Rewrite:

- README.md: ENABLE_TOPLEVEL row no longer says "unset = stub".
- docs/VIDEO.md: live toplevel-export is real (gbm_bo_map via
  runtime dlopen of libgbm.so.1, real RGBA8 PNG via the png crate).
- CHANGELOG.md: T10 follow-up entry rewritten as shipped.
- docs/MACROS.md: stale "Forever is not out yet / later helper
  (not shipped)" paragraph rewritten to point users at the
  per-character GUI rebind instead.
2026-09-16 12:43:33 +02:00
en
777256daab Drop stale T10 follow-up note in toplevel_export.rs
The T10 follow-up block (lines 561-575) said gbm_bo_map was a
documented follow-up. T10 is shipped (commit 0ba3c59, real pixel
read via runtime dlopen of libgbm.so.1); this comment block is now
stale and misleading.

Also fix the misleading "Public stub: where the gbm_bo_map read
belongs" docstring on read_pixels_via_gbm -- the function delegates
to the real implementation now, not a stub.

cargo test 98+/0; clippy clean.
2026-09-16 12:43:33 +02:00
6 changed files with 151 additions and 25 deletions

View File

@ -90,7 +90,7 @@
the round-trip metadata (width/height/format) so callers can confirm
the protocol path works. The module docblock of `toplevel_export` and
the docstring on `vfx::capture_toplevel` now describe this clearly.
Real pixel read is a follow-up after the gbm_bo_map work.
Real pixel read is shipped (commit `0ba3c59`) via a runtime dlopen of `libgbm.so.1`; setup needs no `libgbm-dev` build dep.
- **T9 (overlay live render):** already shipped as part of the T7..T15
batch (). creates a wl_shm pool from a
temp file containing pixels,

View File

@ -113,7 +113,7 @@ The GUI's **Teams** menu is the first thing to use when you have not configured
| --- | --- | --- |
| `ENBOXER_ALLOW_LAYOUT` | Permit `layout-apply`, swap, reset | unset = off |
| `ENBOXER_ENABLE_OVERLAY` | Spawn the live `wlr-layer-shell` slot overlay | unset = stub (geometry + routing only) |
| `ENBOXER_ENABLE_TOPLEVEL` | Spawn the live `wlr_export_dmabuf_unstable_v1` capture path | unset = stub (grim fallback) |
| `ENBOXER_ENABLE_TOPLEVEL` | Spawn the live `wlr_export_dmabuf_unstable_v1` capture path | unset = grim fallback |
`cargo test` and `enboxer doctor` never set these gates, so they cannot touch the user's Hyprland session.

View File

@ -1,6 +1,8 @@
# In-game macros (no addon, retail-shaped)
enBoxer only sends keystrokes. Bind these in WoW on **every** account. **The example profile targets retail (The War Within era, 2026).** Forever is not out yet, so the macro set is whatever current retail uses. If Interact with Target is missing on a client, the loot map will not walk or loot until a later helper (not shipped).
enBoxer only sends keystrokes. Bind these in WoW on **every** account. **The example profile targets retail (The War Within era, 2026).**
If `Interact with Target` is missing on a particular client (private servers, classic, custom UIs that hide it), open the per-character macro override in the GUI and rebind `game_binds.interact` + `game_binds.ctm_on` / `ctm_off` to whatever those clients accept. The daemon's routing logic is unchanged; only the per-client WoW-side keybind changes.
Match keys to `game_binds` in the profile.

View File

@ -23,6 +23,6 @@ It is not a second monitor and not a window swap. The other client can sit behin
The dispatcher in `src/vfx.rs::capture_for_source` checks `is_window_visible(monitors, client)`. If the source window sits on a visible output, `grim` captures it. If the window is covered and the operator has opted in to compositor-side capture (`ENBOXER_ENABLE_TOPLEVEL=1`), the dispatcher routes to `capture_toplevel` (zwlr_export_dmabuf_unstable_v1). With the gate off, covered windows fall through to `grim` and the operator sees whatever is currently on top of those pixels — exactly the previous behaviour.
So: keep the source window **on a visible output** for the default `grim` path. Set `ENBOXER_ENABLE_TOPLEVEL=1` when you want a stacked layout where some sources are fully covered by another; the live toplevel-export client is a follow-up and the dispatcher is ready for it.
So: keep the source window **on a visible output** for the default `grim` path. Set `ENBOXER_ENABLE_TOPLEVEL=1` when you want a stacked layout where some sources are fully covered by another; the live toplevel-export client reads pixels through `gbm_bo_map` (dlopen'd at runtime from `libgbm.so.1`) and writes a real RGBA8 PNG via the `png` crate.
The overlay itself is an `mpv` window (`class: enboxer-vfx`) that reloads each `grim` frame.

View File

@ -23,6 +23,23 @@ pub struct Session {
pub vfx_source: Option<u32>,
pub mouse_broadcast: bool,
pub mouse_follow: bool,
/// Per-button press-and-hold repeat state. Keyed by Linux input
/// event code (272 = BTN_LEFT, 273 = BTN_RIGHT). Cancelled when
/// the matching `mouse-release` IPC verb arrives.
pub mouse_repeats: std::collections::HashMap<u32, RepeatState>,
/// Cadence in milliseconds between repeat clicks while a button is
/// held. Tunable via `ENBOXER_MOUSE_REPEAT_MS`; defaults to 50 ms
/// (20 Hz) which feels responsive without saturating the IPC socket.
pub mouse_repeat_ms: u64,
}
/// Per-button repeat-loop handle + cancel signal. Holding the cancel
/// `Arc<AtomicBool>` in the Session lets the release path flip it
/// without holding the Session mutex; the worker checks the bit at
/// each cadence tick and exits cleanly.
pub struct RepeatState {
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub handle: Option<tokio::task::JoinHandle<()>>,
}
impl Session {
@ -37,6 +54,12 @@ impl Session {
vfx_source: None,
mouse_broadcast: false,
mouse_follow: false,
mouse_repeats: std::collections::HashMap::new(),
mouse_repeat_ms: std::env::var("ENBOXER_MOUSE_REPEAT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&n| (1..=2000).contains(&n))
.unwrap_or(50),
})
}
}
@ -804,10 +827,124 @@ pub fn others_clients(slots: &[(u32, Client)], leader: u32) -> Vec<Client> {
/// click are forwarded from the cursor position in
/// `broadcast_mirror_click`.
///
/// Press-and-hold guard is OUT OF SCOPE for T13 (Master has not requested
/// it yet); the caller should hold the mouse binds as a follow-up.
/// T13-todo: press-and-hold guard — repeated button-down without release
/// should re-fire on a configurable cadence.
/// Handle the IPC verb `mouse-press <button>`. Fires one click
/// immediately so the operator gets no perceptible delay, then starts
/// a per-button repeat loop. Cancels any prior repeat for the same
/// button before installing the new one.
pub async fn mouse_press(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
// Fire the first click synchronously through whichever path the
// session is in (mouse-broadcast or mirror mode). mirror-click
// no-ops outside of Mode::Mirror so the dispatch is harmless.
{
let g = session.lock().await;
let mode = g.engine.mode;
let mouse_broadcast = g.mouse_broadcast;
drop(g);
let r = if mode == Mode::Mirror {
broadcast_mirror_click(session, button).await
} else if mouse_broadcast {
broadcast_click(session, button).await
} else {
// Neither mode nor mouse_broadcast is on; the bind was
// installed anyway. Just no-op.
Ok(())
};
if let Err(e) = r {
tracing::warn!("mouse-press initial click failed: {e}");
}
}
// Cancel any prior repeat for this button.
{
let mut g = session.lock().await;
if let Some(prev) = g.mouse_repeats.remove(&button) {
prev.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = prev.handle {
h.abort();
}
}
}
// Spawn the repeat loop.
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel_clone = cancel.clone();
let cadence = {
let g = session.lock().await;
g.mouse_repeat_ms
};
let session_clone = session.clone();
let handle = tokio::spawn(async move {
mirror_repeat_loop(session_clone, button, cadence, cancel_clone).await;
});
let mut g = session.lock().await;
g.mouse_repeats.insert(
button,
RepeatState {
cancel,
handle: Some(handle),
},
);
Ok(())
}
/// Handle the IPC verb `mouse-release <button>`. Cancels the per-button
/// repeat loop; the most recent click is the last one delivered.
pub async fn mouse_release(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
let mut g = session.lock().await;
if let Some(mut state) = g.mouse_repeats.remove(&button) {
state.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = state.handle.take() {
h.abort();
}
}
Ok(())
}
/// Per-button repeat loop. Fires `broadcast_click` (or
/// `broadcast_mirror_click` in mirror mode) every `cadence_ms` until
/// either the cancel bit is flipped by the matching release handler or the
/// session shuts down. Each click is independent: a failure is logged and
/// the loop continues. Cadence is captured at spawn time; changing
/// `mouse_repeat_ms` mid-hold does not affect the active repeat.
async fn mirror_repeat_loop(
session: Arc<Mutex<Session>>,
button: u32,
cadence_ms: u64,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
use std::sync::atomic::Ordering;
use std::time::Duration;
loop {
tokio::time::sleep(Duration::from_millis(cadence_ms)).await;
if cancel.load(Ordering::Relaxed) {
break;
}
let (mode, mouse_broadcast) = {
let g = session.lock().await;
(g.engine.mode, g.mouse_broadcast)
};
let r = if mode == Mode::Mirror {
broadcast_mirror_click(&session, button).await
} else if mouse_broadcast {
broadcast_click(&session, button).await
} else {
// User disabled mouse_broadcast while holding the button;
// cancel the repeat so we don't fire forever.
cancel.store(true, Ordering::Relaxed);
break;
};
if let Err(e) = r {
tracing::warn!("mouse repeat click failed: {e}");
}
}
}
/// Returns one `(target_client, button)` pair per non-leader slot so
/// the daemon can broadcast a single mirror-mode click. Repeated
/// clicks on a held mouse button are handled by [`mirror_repeat_loop`]:
/// a per-button timer re-fires this same broadcast at a configurable
/// cadence until the matching release IPC verb cancels it. The
/// initial press IPC verb still calls this once on its own so the first
/// click is delivered immediately, not after the cadence delay.
pub fn mirror_clicks_to(
slots: &[(u32, Client)],
leader: u32,

View File

@ -558,23 +558,10 @@ impl Dispatch<wl_buffer::WlBuffer, ()> for ExportState {
}
}
// ---- T10 follow-up note ----
//
// The actual `gbm_bo_map` (import dmabuf → gbm_bo → map → read pixels →
// copy into a PNG-encoded buffer) is a documented follow-up. Wiring it
// in is one new dep (`gbm` + `drm-fourcc` + a DRM device handle) plus a
// ~50-line renderer that:
// 1. opens /dev/dri/renderD128,
// 2. creates a gbm_device,
// 3. imports the dmabuf fd (we have it from the `object` event),
// 4. gbm_bo_map(...) → *mut u8,
// 5. reads stride*height bytes, swizzles into the PNG writer above.
//
// Until then `write_synthetic_frame` produces the right-sized, format-
// labelled placeholder so callers can verify the wire path.
/// Public stub: where the gbm_bo_map read belongs. Kept as a function
/// so the test below can assert its shape.
/// Read the plane-0 dmabuf as an RGBA8 byte buffer and write a PNG
/// of it to `dest`. This is the public entry point used by callers
/// that want the raw pixels (the live `capture_toplevel` path goes
/// through `write_pixels_via_gbm` instead).
pub fn read_pixels_via_gbm(frame: &DmabufFrame, dest: &Path) -> anyhow::Result<Vec<u8>> {
read_pixels_via_gbm_full(frame, dest)
}