docs(mosaic): preserve negative-origin nested-grab probe evidence

This commit is contained in:
fy59 2026-10-11 17:02:34 +02:00
parent 4c1c395423
commit 88755fd92d
10 changed files with 1020 additions and 4 deletions

View file

@ -4,6 +4,7 @@
### STEP20C experimental evidence
- Added a source-only negative-origin nested-grab probe. Two outputs at `x=-800/0` verified signed pointer conversion, real standard popup crossing, sampled private Mosaic clipping, hidden-owner return, standard-layout return and a positive horizontal regression. Production STEP20C support remains open.
- Added a source-only stacked-output nested valid-grab probe. Both standard controls showed real popup overflow and pointer delivery below `y=600`; the private guarded Mosaic build clipped A/B at sampled lower points, preserved hidden-owner and standard-layout return, and reproduced the horizontal control. STEP20C production support remains open.
- Added a source-only nested XDG grab fixture and a comparative headless probe. Two real button serials produced an interactive toplevel → popup A → popup B chain; sampled guarded Mosaic clipping, child-first dismissal, hidden-owner camera return and standard-layout return were feasible. STEP20C production support remains open.
- Preserved the valid-serial XDG grab / hidden-owner probe's original C and Python sources, private patches and protocol XML with hashes and a disposable headless replay procedure. Its result remains **PARTIALLY_FEASIBLE**; no production source, package or UX policy changed.

View file

@ -85,6 +85,18 @@ popups, and `splith` restored the sampled ordinary lower-output behavior.
This is vertical popup evidence only. The production compositor still has no
qualified cross-output viewport clip or committed scene/transaction contract.
The [negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md)
measured HEADLESS-1 at `(-800,0)` and HEADLESS-2 at `(0,0)` in both standard
controls and private Mosaic. In the pinned wlroots path, the virtual-pointer
absolute fraction maps through the signed output-layout box; the runner
verified that `global_x+800` reaches the requested output and client surface.
The disposable applied SceneFX clip and popup-owner guard excluded sampled
right-output A/B pixels and hits, while standard layouts retained real
crossing and popup buttons. A fully hidden applied owner and return to
`splith` also passed at measured points. This remains experimental evidence:
production viewport clipping, transaction consistency, continuous damage
coverage and SceneFX ABI ownership are not settled.
## IPC
On a Mosaic workspace, `get_tree` reports `layout: "mosaic"` and adds a `mosaic` object with `active_row` and `rows`. Each row entry contains ordered child node IDs, `scroll_x`, and focused node ID or null. Existing Sway fields retain their shapes; normal workspace/window events and `get_workspaces` paths remain in use. This is an extension to the Sway layout vocabulary: strict IPC consumers with a closed layout enum must recognize `mosaic`. Additional rows in the model are not a promise of usable multi-row IPC behavior.

View file

@ -47,7 +47,18 @@ coverage, other output geometries, migrations, transaction generations,
effects, or SceneFX ABI/package maintenance. Production adjacent-output
Mosaic remains **NOT YET SUPPORTED**.
The [isolated feasibility probe](design/STEP20C_FEASIBILITY_PROBE.md) is **PARTIALLY FEASIBLE**, not a release qualification. On two horizontal headless outputs, real Mosaic content leaked visually into an empty adjacent output; the disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage too. Popups, effects, saved buffers, vertical Y-crossing behavior, pointer behavior in those additional arrangements and complete damage correctness remain unqualified. Multi-output Mosaic remains **NOT YET SUPPORTED**.
The [negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md)
is **FEASIBLE for its sampled matrix only**. With HEADLESS-1 actually at
`x=-800`, signed global pointer requests reached both outputs, standard
controls exposed real A/B popup pixels and hits beyond `x=0`, and the
disposable guarded Mosaic build clipped those measured pixels and hits while
preserving visible interactions. Hidden-owner and standard-layout return
cases passed at measured points. This does not qualify other output
transforms, migration/resize, complete damage/effects, transaction
generations or SceneFX ABI/package maintenance. Production adjacent-output
Mosaic remains **NOT YET SUPPORTED**.
The [isolated feasibility probe](design/STEP20C_FEASIBILITY_PROBE.md) is **PARTIALLY FEASIBLE**, not a release qualification. On two horizontal headless outputs, real Mosaic content leaked visually into an empty adjacent output; that earlier disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage in that candidate. Popups, effects, saved buffers and complete damage correctness remain unqualified across the full matrix. The later probes above narrow individual cases without qualifying multi-output Mosaic, which remains **NOT YET SUPPORTED**.
The later [SceneFX subtree clipping spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md) is also **PARTIALLY FEASIBLE**. Its private inherited tree clip stopped tested tiled pixels and pointer hits from crossing the output boundary, including a fully hidden cell, but a real GTK XDG popup rendered and received input on the neighboring output because its scene tree lives in the global popup layer. Dedicated subsurface, saved-buffer, nested-popup and complete effect/damage validation remain open. The spike changed no production library or compositor source; multi-output Mosaic remains **NOT YET SUPPORTED**.

View file

@ -23,7 +23,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use
[Nested-grab source fixture](../tooling/probes/step20c-nested-grab/README.md) reconstructs the same disposable experiment.
14. [STEP20C vertical-output nested-grab probe](design/STEP20C_VERTICAL_OUTPUT_NESTED_GRAB_PROBE.md): sampled vertical popup clipping and pointer evidence with two standard controls and private Mosaic; production gates remain open.
[Vertical source fixture](../tooling/probes/step20c-vertical-grab/README.md) replays it in a disposable laboratory.
15. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence.
15. [STEP20C negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md): sampled signed-coordinate pointer, popup clipping, hidden-owner and standard-layout evidence with real `x=-800` headless origin; production gates remain open.
[Negative-origin source fixture](../tooling/probes/step20c-negative-origin/README.md) replays it in a disposable laboratory.
16. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence.
## Project
@ -61,6 +63,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use
| [STEP20C valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; valid-serial popup grab measured, hidden-owner leak found and closed only in a private guard; broader STEP20C gates remain open |
| [STEP20C nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases on horizontal headless outputs; no production qualification |
| [STEP20C vertical-output nested-grab probe](design/STEP20C_VERTICAL_OUTPUT_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases across the horizontal output boundary; no production qualification |
| [STEP20C negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases with HEADLESS-1 at `x=-800`; no production qualification |
A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation.

View file

@ -42,11 +42,21 @@ controls produced real Y-crossing A/B popup pixels and button targets, while
the disposable guarded Mosaic build excluded sampled lower popup pixels/hits
and preserved upper interaction, hidden-owner return and standard-layout
return. Its [source fixture](../tooling/probes/step20c-vertical-grab/README.md)
also reproduced the horizontal B-crossing control. The next separate geometry
gate is a bounded negative-origin comparison. Scaling, rotation,
also reproduced the horizontal B-crossing control. The separate negative-origin
comparison is recorded below. Scaling, rotation,
resize/migration, transaction generations, complete damage/effects and
dependency maintenance remain open. STEP20C stays **NEXT**.
The [negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md)
is **FEASIBLE within its sampled signed-coordinate headless matrix**:
HEADLESS-1 remained at `x=-800`, the virtual pointer reached both outputs,
standard controls proved A/B crossing pixels and buttons, and the private
guarded Mosaic build clipped sampled right-output pixels and hits. Hidden
owner and `splith` return passed. Its [source fixture](../tooling/probes/step20c-negative-origin/README.md)
preserves replay. Scaling, rotation, resize/migration, transaction
generations, complete damage/effects and dependency maintenance remain open.
STEP20C stays **NEXT**.
## STEP20D — multi-row Spatial Canvas · PLANNED
**Goal:** connect rows to runtime tree, transactions, scene, input, and IPC. **Planned deliverables:** active-row navigation, row-local scroll/focus, deterministic insertion/reflow, in-row reorder, and between-row movement. **Exit criteria:** real tiled containers preserve identity and order across row changes; focus and camera restore correctly; headless multi-row tests pass. **Docs first:** settle exact interactions and row invariants in UX and Architecture. Unset key combinations remain TBD.

View file

@ -0,0 +1,338 @@
# STEP20C negative-origin nested-grab probe
**Status: EXPERIMENTAL / FEASIBLE for the measured negative-origin matrix.** This is a
disposable feasibility test, not a production clipping change or UX decision.
The canonical [architecture](../ARCHITECTURE.md), [UX](../UX.md),
[limitations](../KNOWN_LIMITATIONS.md) and [roadmap](../ROADMAP.md) retain
authority.
## Problem and hypotheses (recorded before fixture adaptation)
An output at global `x=-800` makes global scene and pointer coordinates signed.
The virtual-pointer protocol instead transmits unsigned absolute coordinates
with unsigned extents. Passing a negative global X directly to that protocol
would wrap it. **Hypothesis H1:** with two adjacent 800×600 outputs, wlroots
maps normalized pointer X through the layout bounding box; therefore
`injected_x=global_x-min_x=global_x+800` with extent 1600 reaches the
requested global point. This must be confirmed from the pinned wlroots path
and from client pointer events on both outputs before any clipping result is
valid. A protocol-level injection acknowledgement alone is insufficient.
**Hypothesis H2:** the disposable SceneFX subtree clip, owner-derived popup
clip and hidden-owner guard use signed applied scene positions correctly when
the viewport begins at `-800`. The control layouts must first demonstrate
real pixels and button delivery beyond `x=0`; configured rectangles alone do
not prove a crossing. **Hypothesis H3:** changing Mosaic back to `splith`
removes the private clip while both XDG popups remain mapped.
## Coordinate contract and requested geometry
| Quantity | HEADLESS-1 | HEADLESS-2 |
| --- | --- | --- |
| Requested global origin | `(-800,0)` | `(0,0)` |
| Requested size, scale, transform | `800×600`, `1`, `normal` | `800×600`, `1`, `normal` |
| Global X interval | `[-800,0)` | `[0,800)` |
| Local X | `global_x+800` | `global_x` |
The requested layout bounds are `[-800,800) × [0,600)`, with border `x=0`.
`get_outputs` is authoritative. If the compositor normalizes these origins,
the negative-origin test is **INCONCLUSIVE** and the actual rectangles must
still be retained. Workspace 1 and the tiled popup owner belong to
HEADLESS-1; workspace 2 holds `wev` on HEADLESS-2.
For a requested global point `(-100,190)`, HEADLESS-1 local X is `700` and
candidate virtual-pointer coordinates are `(700,190)` with extent `1600×600`.
For `(100,190)`, HEADLESS-2 local X is `100` and the candidate transmitted X
is `900`. The runner must log requested signed global point, layout minimum,
transmitted normalized point, extents, reached output, client-local motion and
button events. It must reject out-of-bounds and nonintegral injection values.
Mosaic row order and logical owner IPC rectangle remain distinct from camera
and applied scene placement. For each sample, record logical `rect`,
`window_rect`, `scroll_x`, applied owner/popup scene positions, SceneFX clip,
XDG role configure rectangles and actual captures. The candidate applied
content X is `owner.rect.x - scroll_x + owner.window_rect.x` in Mosaic;
the compositor scene log and captured pixels must verify it. Popup A's XDG
configured rectangle is relative to the toplevel surface; B's is relative
to A's surface. Their resulting global rectangles are estimates until scene
position and pixels corroborate them. XDG anchor rectangles must lie within
their effective parent window geometry.
## Experimental method and acceptance
Reuse the historical nested client, the vertical fixture's parameterized
pointer and the same three pinned private SceneFX/Mosaic patches, including
the hidden-owner guard. Adapt only the new fixture directory. Run identical
clients in (A) unmodified SwayFX `splith`, (B) private compositor `splith`,
and (C) private compositor `mosaic`, all with verified negative output origins.
Use isolated runtimes and sockets, `swaymsg -s`, two-output captures, IPC,
`WAYLAND_DEBUG=1`, compositor logs and structured measurements. A separate
positive-origin horizontal subset checks regression. No installed library,
daily session or production source is touched.
1. Validate signed pointer targeting on both outputs, including round trip and
left/right buttons. Failed targeting invalidates later graphics tests.
2. Open two visible popups: TOPLEVEL button serial grabs A, then A button
serial grabs B; verify distinct actual serials, parent XDG identities,
configure/ack/map order and interaction with each surface.
3. Test B crossing `x=0`, then A and B crossing. Both standard controls must
show actual popup pixels and B/A button targets on HEADLESS-2. Mosaic must
preserve visible left portions and suppress right popup pixels/hits, with
ordinary-client events interpreted under normal XDG grab semantics.
4. In separate runs, test outside-click dismissal, fully hidden applied owner
with no click and with a click, and `layout splith` while popups remain open.
5. Replay a horizontal positive-origin subset with the same adapted clients
and injector. Derive samples from actual configured/scene geometry and
capture data, never from assumed historic positions.
Each scenario retains output JSON, signed coordinate conversions, pointer
events, owner IPC/Mosaic state, XDG configuration and identities, scene clips,
two captures per stage, lifecycle events, process exit codes and a
`measurements.json`. Rendering, hit testing, XDG grab, coordinate transform
and lifecycle verdicts are separate. A screenshot alone or a protocol send
alone cannot establish a pass. Keep failures and inconclusive runs visible.
The probe is **FEASIBLE** only if negative origins and control crossing are
real, Mosaic excludes measured clipped pixels/hits while preserving visible
interaction, and private standard layout has no blocking regression. It is
**PARTIALLY_FEASIBLE** for demonstrated core behavior with matrix gaps,
**BLOCKED** for reproducible rendering/input leak, XDG violation or blocking
standard regression, and **INCONCLUSIVE** if geometry, crossing or pointer
events cannot be established. These labels apply only to this probe.
## Initial observations and correction history
The first unmodified SwayFX `pointer` run recorded `get_outputs` with active
HEADLESS-1 `(-800,0,800,600)` and HEADLESS-2 `(0,0,800,600)`, both scale `1`
and transform `normal`. Thus SwayFX did apply the requested negative origin.
The fixture transmitted `302` for global `-498` and `830` for global `30`.
The owner received `wl_pointer.enter` with surface-local X `100`, then `105`
on the return trip; `wev` received enters with local X `28`, then `33`
(its surface starts inside the container). This establishes preliminary
two-output targeting, subject to full button validation. Evidence is retained
in `run-negative-control-pointer-initial/` under the new laboratory.
The first calibration checker falsely required `POINTER_MOTION` lines. An
inter-output move produces `wl_pointer.enter` at the destination instead;
both clients logged those enters. The checker now accepts enter or motion.
That first run exited with a fixture `RuntimeError` after its captures; it is
not classified as a compositor or clipping failure.
The actual wlroots 0.20.2 path confirms H1 at the source level:
[`types/wlr_virtual_pointer_v1.c`](https://gitlab.freedesktop.org/wlroots/wlroots/-/blob/0.20.2/types/wlr_virtual_pointer_v1.c)
forms normalized `x / x_extent`, while
[`types/wlr_cursor.c`](https://gitlab.freedesktop.org/wlroots/wlroots/-/blob/0.20.2/types/wlr_cursor.c)
converts that fraction to `mapping.width * x + mapping.x` using the output
layout box when no device mapping overrides it. Sway's absolute-motion path
uses the wlroots cursor. With box X `-800`, width `1600`, and transmitted X
`global_x+800`, the nominal reconstructed global point is `global_x`.
Client events remain the empirical check for device mapping and targeting.
The completed `pointer` runs in unmodified SwayFX, private `splith`, and
private Mosaic each reached both outputs on two round trips. TOPLEVEL received
right and left press/release (`button=273/272`); `wev` received left and
right press/release. In the control, requested global `(-498,150)` reached
TOPLEVEL local `(100,125)` and requested `(30,80)` reached `wev` local
`(28,55)`; the approximately 2 px X and 25 px Y differences are the client
surface inset, not an output-origin shift. Private `splith` repeated those
coordinates, and private Mosaic used global `(-458,150)` for TOPLEVEL local
`(100,125)` after its camera translation. All protocol X values were
nonnegative and inside the `1600×600` extent.
All three negative-origin `visible` runs mapped A and B and kept them open
through separate right clicks. A's role configure was `(50,80,250,170)` and
B's `(20,60,170,130)` relative to its parent. Popup pixels were confined to
HEADLESS-1 in this reference placement; HEADLESS-2 contained zero exact A
yellow or B green pixels.
The negative-origin `b_cross` controls both configured B at
`(-178,165,300,130)` after applied-owner translation and measured **15,860**
exact B-green pixels on HEADLESS-2 at the open capture. Private Mosaic
configured B at `(-138,165,300,130)` and measured **0** B-green pixels on
HEADLESS-2 while retaining **17,940** on HEADLESS-1. These are preliminary
render measurements; button traces and the remaining matrix are under review.
At the B crossing point, both standard controls delivered two B right-button
events and retained **15,860** B-green pixels beyond `x=0`; private Mosaic
delivered the left visible B button, then delivered the right-output button
to `wev`, with no right-output B button. Its legitimate outside click then
caused `popup_done` B before A. In the `both_cross` placement, both standard
controls had **6,120** A-yellow and **27,560** B-green pixels on HEADLESS-2;
private Mosaic had **0** of each there, while retaining **30,080** A-yellow
and **6,240** B-green pixels on HEADLESS-1. A separate `a_cross` run is used
for A's right-output button, since a first outside click can dismiss the
grabbed hierarchy.
## Comparative results
All three compositors used HEADLESS-1 `(-800,0,800,600)` and HEADLESS-2
`(0,0,800,600)` at scale 1, transform normal. The same nested client used
TOPLEVEL XDG object 9, A object 12 and B object 15 in each run; A named
TOPLEVEL as parent, B named A. In `b_cross`, both standard controls took real
TOPLEVEL left-button serial **69** to grab A and A left-button serial **74**
to grab B. Private Mosaic used **63** and **68**. Each `GRAB_REQUEST` used
the exact target event's serial; configure, `xdg_surface` ack and MAP followed
in order. No protocol error or immediate grab denial occurred. The anchored
rectangles stay within their effective parent geometry: the horizontal
`b_cross` A anchor X `200` is inside the standard top buffer width `420`,
and B anchor X `220` is inside A's width `300`; `both_cross` A anchor X `350`
is likewise inside `420`, B anchor X `160` inside A's width `350`.
| Scenario | Unmodified SwayFX `splith` | Private `splith` | Private Mosaic | Measured distinction |
| --- | --- | --- | --- | --- |
| Pointer reference | PASS | PASS | PASS | Signed global points reached both clients twice; left/right press and release on both outputs. |
| A/B fully visible | PASS | PASS | PASS | Two grabs, mapped distinct surfaces, A/B right clicks, no unexpected dismissal. |
| B crosses `x=0` | PASS | PASS | PASS | Standard B right pixels 15,860 and B button; Mosaic right pixels 0, B left button, `wev` right button and normal outside dismissal. |
| A and B cross `x=0` | PASS | PASS | PASS | Standard right A/B pixels 6,120/27,560; Mosaic 0/0 with separate visible A/B clicks. |
| A crossing hit in separate run | PASS | PASS | PASS | Standard right A button; Mosaic no right A button, `wev` button and normal dismissal. |
| Outside click and lifecycle | PASS | PASS | PASS | `popup_done` B then A; client destroys B then A; ordinary client receives left button. |
| Owner fully hidden, no click | N/A | N/A | PASS | Applied scene owner `(-1440,0,640,600)` does not intersect viewport `(-800,0,800,600)`; A/B pixels 0 then return with same XDG IDs. |
| Owner hidden with click | N/A | N/A | PASS | No hidden A/B button; B then A dismissed; pixels stay absent after camera return. |
| Mosaic → `splith` with A/B open | N/A | N/A | PASS | Right B pixels return to 15,860; B receives right button at `(20,195)` before any `popup_done`. |
| Positive horizontal regression | PASS | PASS | PASS | Standard B right pixels 15,860; Mosaic 0 with the same button-routing distinction as the historical horizontal probe. |
The B-crossing point was global `(20,195)` on HEADLESS-2. In standard
controls its open-capture RGB was B green `(32,208,160)` and its right click
went to B. In private Mosaic that point was ordinary background
`(238,238,238)`, and its right click went to `wev`; B received no button
there. The corresponding Mosaic visible B point `(-98,195)` was green and
delivered a B right button. For A, the separate standard crossing point
`(87,125)` was A yellow `(224,192,32)` and delivered an A button; Mosaic's
scene-adjusted point `(127,125)` was ordinary background and delivered no A
button, while visible A/B points remained interactive. Ordinary-client
delivery and popup dismissal are distinct observations; dismissal alone is
never counted as a clipped-surface hit.
The private `PROBE_POPUP_CLIP` log recorded clip local `(-242,-25,800,600)`
in the sampled Mosaic runs. Relative to viewport origin `(-800,0)`, this
gives applied root popup-tree scene origin `(-558,25)`; combining the logged
XDG configure offsets gives A `(-358,105,300,170)` and B
`(-138,165,300,130)` in `b_cross`. These match the independently calculated
owner/window/camera positions and the captured crossing colors. In
`both_cross`, A is `(-208,105,350,170)` and B
`(-48,165,300,130)`. A negative global origin is therefore present in both
the applied clip and popup surface geometry, not only in an IPC label.
For hidden owner, the compositor logged `PROBE_POPUP_HIDDEN_OWNER` with
applied `(-1440,0,640,600)` versus viewport `(-800,0,800,600)`, proving
empty intersection from applied scene state. The no-click run's left A/B
exact pixel counts were **42,200/17,940 → 0/0 → 42,200/17,940**. There
was no `popup_done` before return and no new XDG CREATE or MAP; XDG IDs
remained A 12 and B 15.
The IPC owner stayed container ID 8 and `focused=true` in open, hidden,
hidden-hover and returned snapshots; its logical rectangle stayed
`(-160,25,640,575)` while the applied scene position changed with camera.
In the separate hidden-click run, neither popup
received the right button; B then A received `popup_done`, were destroyed
child first and did not return. With both still open, switching to `splith`
restored the standard right B-green count **15,860** and a B right-button
press/release with no earlier dismissal.
The positive-origin regression used HEADLESS-1 `(0,0)` and HEADLESS-2
`(800,0)` with the same compiled clients and pointer. Both standard controls
again produced **15,860** B-green pixels on HEADLESS-2 and delivered the
right-output button to B. Private Mosaic produced **0** and delivered the
button to `wev`, followed by normal B/A dismissal. Counts happen to match
the historical horizontal probe after checking the actual applied placement;
the test did not assume their equality.
## Scope of conclusion
This negative-origin, two-output, nested-valid-grab experiment is
**FEASIBLE for the measured placements and event points**. It establishes
correct signed-to-absolute pointer conversion in the observed wlroots path,
real control crossing, sampled private clipping/hit behavior, hidden-owner
guard behavior and standard-layout restoration. It does not prove continuous
pixel coverage between samples or define universal offscreen-popup dismissal
UX. Scaling, rotation, output migration/resize, complete damage/effects,
transaction generations and ABI/package maintenance remain **NOT TESTED**.
STEP20C remains **NEXT** and production adjacent-output clipping remains
**NOT YET SUPPORTED**.
## Independent freeze replay (2026-10-11)
A fresh laboratory, `/tmp/swayfx-mosaic-nested-grab-1DzZQbcH/`, was built by
the preserved `prepare.sh` from full Git checkouts, rather than an archive.
Its build log is `/tmp/mno-prepare-ltr6sSlW`. Fourteen new run directories
cover pointer targeting on both outputs in all three configurations,
negative-origin B crossing and A+B crossing in all three, private Mosaic
hidden-owner return and `splith` return, and positive-origin B crossing in
all three. `analyze.py` decoded the new PNGs. The fresh output JSON again
reported `(-800,0,800,600)` and `(0,0,800,600)` for negative-origin runs.
In each pointer run, the owner received destination `wl_pointer.enter` at
local `(100,125)` and `(105,135)`, and `wev` received enters at `(28,55)`
and `(33,60)`; both clients also logged press/release events. These client
events, not injector acknowledgements, validate signed-global targeting.
New control and private-standard B-crossing captures each contain 15,860
B-green pixels on HEADLESS-2 and two B button presses. Private Mosaic again
contains zero B-green pixels there, preserves 17,940 on HEADLESS-1, delivers
the visible B press, and sends the clipped-side press to `wev`, followed by
normal B/A `popup_done`. For A+B crossing, both standard controls again
contain 6,120 A-yellow and 27,560 B-green right-output pixels; private
Mosaic contains zero of either there while retaining 30,080/6,240 on the
left. The fresh logs show distinct A/B XDG objects and actual A/B grab
serials (69/74 in the control B run; 63/68 in private Mosaic), with matching
button serial, configure/ack/map order and no recorded protocol error.
Serials varied in another control run, as expected; their numeric values are
run evidence, not a protocol constant.
In the new no-click hidden-owner run, `scroll_x` moved 400 → 1280 → 400,
both popup colors changed 42,200/17,940 → 0/0 → 42,200/17,940 on
HEADLESS-1, the same XDG objects remained mapped, and no `popup_done`
preceded return. In the new layout-return run, `splith` restored 15,860
B-green pixels on HEADLESS-2 and a B press without prior dismissal. The
positive-origin B-crossing subset reproduced the same control/private
distinction. These are sampled stabilized observations: the replay did not
capture every presented frame or establish complete damage coverage.
## Reproduction, provenance and validation
The retained laboratory is
`/tmp/swayfx-mosaic-nested-grab-NWy4A23x/`; its preparation log is
`/tmp/mno-prepare-4FC2ZHqF`. The source-only
[fixture](../../tooling/probes/step20c-negative-origin/README.md) documents a
fresh replay from a full Git checkout. The three unchanged private patches
applied with `git apply --check` to Mosaic
`cd550382b311efdbfe3ed892f4e1d281d852e754` and SceneFX
`37ccd723bef49e6891156ffafce8f549f01446cc`. The historical pointer
extents patch alone adapted a disposable copy of `pointer-daemon.c`; no new
private patch was needed. The historical nested client was byte-identical.
The environment used `/usr/bin/sway` 0.6, wlroots 0.20.2, SceneFX 0.5.0
and Wayland client 1.26.0. `ldd` resolved the private Mosaic executable to
the laboratory `libscenefx-0.5.so` and system `libwlroots-0.20.so`.
`artifacts-sha256.txt` lists **895** run files from the final and retained
intermediate cases; its SHA-256 is
`0f3d74cb3477aa24b5e0f6bcf84f736ffcd31280de04b08b4622a761046eb793`.
`sha256sum -c` verified **895/895**. `negative-origin-source-sha256.txt`
lists six source inputs; its SHA-256 is
`8cbf6228c0bd317442262265692f4027e41fd82c2e7c7cd22bbb0e03ddaf729d`,
and `sha256sum -c` verified **6/6**. Each run directory retains verified
output JSON, signed injections, observed client-local pointer reference,
XDG and `WAYLAND_DEBUG=1` traces, IPC trees, scene clips, two-output PNGs,
RGB/count analysis, `popup_done` order and process exit status. The
intermediate failed pointer-calibration check and its evidence remain under
`run-negative-control-pointer-initial`; earlier `both_cross` and `toggle`
attempts remain under `*-initial`. The final `both_cross` runs were replayed
with the settled runner. The first `toggle` sampled B on the left after
return; the final run explicitly sampled its right-output portion.
Private SceneFX and Mosaic builds passed; the Mosaic Meson model test passed
**1/1**. C17 syntax checks passed for both client sources against generated
protocol headers. Shell syntax, Python AST parsing without bytecode cache,
document links, `git diff --check`, source hashes and artifact hashes passed.
An independent structured matrix check passed pointer targeting, both grabs,
crossing colors/buttons, outside dismissal, hidden-owner, layout return and
positive regression. All completed case directories include actual exit
codes; normal runner teardown produced compositor/client `0` and expected
`SIGTERM` `-15` for the persistent pointer and `wev`. Their private runtime
directories were removed after reaping; no probe process remained. These
validation passes support only the measured scientific conclusion above.
No production Mosaic source, production patch, `PKGBUILD`, installed library,
historical fixture, UX policy or active session was changed; at the time of
the original experiment nothing had been committed or pushed. The smallest next bounded experiment is repeated
open/close and camera movement at this same negative-origin geometry, with
frame-by-frame stale-pixel and damage observations. It would test a currently
open gate without changing output transform or popup protocol.

View file

@ -0,0 +1,94 @@
# STEP20C negative-origin nested-grab fixture (EXPERIMENTAL)
This source-only fixture rebuilds the pinned private compositor and SceneFX
through the historical [nested-grab fixture](../step20c-nested-grab/README.md),
then applies only the historical parameterized
[pointer patch](../step20c-vertical-grab/pointer-extents.patch) to a disposable
client copy. The original nested client supplies two real XDG grabs and
horizontal placements. No production source, installed library or historical
fixture is modified. The [probe report](../../../docs/design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md)
contains the coordinate contract and evidence.
## Pinned inputs
| Input | Base | SHA-256 |
| --- | --- | --- |
| SceneFX private patch | SceneFX `37ccd723bef49e6891156ffafce8f549f01446cc` | `c74c515b98da8f5f637e260e6a3755c7a75bd78d1c39e54a67850f1c383e30e7` |
| Mosaic private patch | Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754` | `d416f6413ea097a48a44d231e50ffba363914f46c8687f0ea2e4dcacd5f31fe9` |
| Hidden-owner guard | After Mosaic private patch | `35785a8f3e863b3c1fd87932d8ff0c2949b03cb04382cc34e1126170968f802b` |
| `nested-client.c` | Original nested fixture | `2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d` |
| `pointer-daemon.c` | Original valid-grab fixture, then patch below | `a00fcedc38ccb7ca4c25c982d9f38e8bde3168a3833f0c9cf2056e841d720938` |
| `pointer-extents.patch` | Original vertical fixture | `4eeb9f808d0941edca82dc3c5d24a9c936cc3d9d8bb80af3db6aed6af0d47c50` |
The new `prepare.sh` (`ed9d0e8bf50338d39519e003463e637cdba3ecfb147e05a097e88ac6c8def694`),
`run.py` (`7b9e2d311dcf942e4dc625b8fcc25c650b7c3ec1f604828397cf1de120b43541`),
`analyze.py` (`2d33f4876ea1e982b32f9e73145db86836ba2fa7f5382f9433b4b9f076098a49`)
and patched pointer source (`ede9a39adbe1744c771a784f6ad0c296259e36b942c78d75be620b86b9f2a66d`)
can be rechecked against the source manifest in each laboratory. The
three private patches, nested client and pointer patch remain byte-identical
to their historical versions. No new SceneFX, Mosaic or client patch is
needed for this geometry; all changes are in the new runner and wrapper.
## Fresh preparation and replay
Run from a full Git checkout containing the pinned commits. Dependencies are
those listed in the linked historical fixture: Git, Meson, Ninja, C17
compiler, `pkg-config`, `wayland-scanner`, `kitty`, `grim`, `wev`, `swaymsg`,
Python and the documented development libraries. `prepare.sh` creates a new
temporary laboratory, checks and applies private patches there, builds
SceneFX/Mosaic privately, generates protocol bindings and compiles the two
clients. It prints `LAB` and `BUILD_LOG`; retain both paths.
`analyze.py` additionally needs Pillow to decode the retained PNG captures.
```sh
prep=$(sh tooling/probes/step20c-negative-origin/prepare.sh)
printf '%s\n' "$prep"
LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p')
test -d "$LAB"
for mode in control private_standard private_mosaic; do
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" pointer negative
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" visible negative
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" b_cross negative
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" both_cross negative
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" a_cross negative
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" outside negative
done
for scenario in hidden hidden_click toggle; do
python tooling/probes/step20c-negative-origin/run.py "$LAB" private_mosaic "$scenario" negative
done
for mode in control private_standard private_mosaic; do
python tooling/probes/step20c-negative-origin/run.py "$LAB" "$mode" b_cross positive
done
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-negative-origin/analyze.py "$LAB"
```
Each invocation creates a single-use `run-GEOMETRY-CASE-SCENARIO` directory
under `LAB`. Retain a failed case by renaming its directory before retrying.
The runner writes `outputs-verified.json`, `injections.json`,
`pointer-validation.json`, `measurements.json` for popup scenarios, IPC tree
and output snapshots, `WAYLAND_DEBUG=1` client trace, `wev`, pointer and
compositor logs, captures of both outputs, and `exit-status.json`. The
coordinate conversion is `sent_x=global_x-min_x` after IPC confirms the
negative layout bounds; the absolute extent is `1600×600`. Inspect client
events as well as pointer acknowledgements. A control popup crossing is
conclusive only if actual pixels and buttons cross the border.
Each case uses a short, private mode-0700 `/tmp/mno-*` runtime and an explicit
discovered `SWAYSOCK` for every `swaymsg -s` call. `WAYLAND_DISPLAY` is private.
The runner removes its own runtime only after reaping its process groups and
writing exit codes. Private SceneFX is loaded only for the private compositor.
The current desktop and ordinary Sway IPC socket are never addressed.
## Checks and cleanup
```sh
sh -n tooling/probes/step20c-negative-origin/prepare.sh
PYTHONDONTWRITEBYTECODE=1 python -c 'import ast; ast.parse(open("tooling/probes/step20c-negative-origin/run.py").read())'
(cd "$LAB" && sha256sum -c negative-origin-source-sha256.txt)
git diff --check
```
Review measurements and hash evidence before removing the named `LAB` and
`BUILD_LOG`. Generated protocol sources, binaries, captures, logs and sockets
remain outside Git. Cleanup must target only paths printed by this probe;
historical laboratories and the active session are outside its ownership.

View file

@ -0,0 +1,119 @@
"""Summarize retained headless evidence without treating counts as a verdict.
Usage: python analyze.py LAB
Requires Pillow for PNG decoding. Writes analysis.json inside each run directory.
"""
import json
from pathlib import Path
import re
import sys
from PIL import Image
if len(sys.argv) != 2:
raise SystemExit(__doc__)
lab = Path(sys.argv[1]).resolve()
colors = {'A_yellow': (224, 192, 32), 'B_green': (32, 208, 160)}
def open_image(path):
return Image.open(path).convert('RGB')
def local_points(lines, pattern):
points = []
for line in lines:
match = re.search(pattern, line)
if match:
points.append(tuple(map(float, match.groups())))
return points
for run in sorted(lab.glob('run-*')):
if not (run / 'exit-status.json').exists():
continue
client = (run / 'client.log').read_text()
ordinary = (run / 'wev.log').read_text()
compositor = (run / 'compositor.log').read_text()
result = {
'run': run.name,
'exit_status': json.loads((run / 'exit-status.json').read_text()),
'grab_requests': re.findall(
r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\w+) serial=(\d+) used=(\d+)',
client),
'popup_parents': re.findall(
r'(POPUP_[AB]) CREATE wl=(\d+) xdg=(\d+) role=(\d+) parent=(\w+) parent_xdg=(\d+)',
client),
'popup_done_order': re.findall(r'(POPUP_[AB]) POPUP_DONE', client),
'popup_destroy_order': re.findall(r'(POPUP_[AB]) DESTROY', client),
'popup_buttons': re.findall(
r'POINTER_BUTTON target=(POPUP_[AB]) serial=(\d+) button=(\d+) state=(\d+)',
client),
'ordinary_buttons': re.findall(
r'button: serial: (\d+); time: \d+; button: (\d+).*state: ([01])',
ordinary),
'scene_clip_logs': re.findall(
r'PROBE_POPUP_CLIP owner=(\S+) local=(-?\d+),(-?\d+) (\d+)x(\d+)',
compositor),
'hidden_owner_logs': re.findall(r'PROBE_POPUP_HIDDEN_OWNER[^\n]*', compositor),
'captures': {},
'open_sample_rgb': {},
}
reference_path = run / 'pointer-validation.json'
if reference_path.exists():
reference = json.loads(reference_path.read_text())
owner_points = local_points(reference['owner_motion_lines'],
r'x=(-?[\d.]+) y=(-?[\d.]+)')
ordinary_points = local_points(reference['ordinary_motion_lines'],
r'x, y: (-?[\d.]+), (-?[\d.]+)')
if len(owner_points) >= 2 and len(ordinary_points) >= 2:
result['pointer_observed'] = [
{'requested_global': list(reference['global_points'][i]),
'observed_output': output, 'observed_surface_local': list(point)}
for i, output, point in zip(range(4),
('HEADLESS-1', 'HEADLESS-2', 'HEADLESS-1', 'HEADLESS-2'),
(owner_points[-2], ordinary_points[-2],
owner_points[-1], ordinary_points[-1]))]
for path in sorted(run.glob('*.png')):
if not path.name.startswith(('left-', 'right-')):
continue
image = open_image(path)
pixels = image.get_flattened_data()
result['captures'][path.name] = {
'size': list(image.size),
'exact_color_pixels': {name: pixels.count(rgb) for name, rgb in colors.items()},
}
measurements_path = run / 'measurements.json'
if measurements_path.exists():
result['measurements'] = json.loads(measurements_path.read_text())
# CONTRACT: the private log's clip is local to the root popup tree.
# Subtracting it from the output viewport origin recovers that applied
# scene-tree origin; XDG role configure offsets locate A and B below it.
if result['scene_clip_logs']:
clip = result['scene_clip_logs'][-1]
left = result['measurements']['verified_outputs']['HEADLESS-1']
root_x, root_y = left[0] - int(clip[1]), left[1] - int(clip[2])
a = result['measurements']['popup_a_configure']
b = result['measurements']['popup_b_configure']
result['applied_scene_from_clip'] = {
'root_popup_tree': [root_x, root_y],
'popup_a': [root_x + a[0], root_y + a[1], a[2], a[3]],
'popup_b': [root_x + a[0] + b[0], root_y + a[1] + b[1], b[2], b[3]],
}
for sample in result['measurements']['sample_points']:
x, y = sample['global']
output = 'left' if x < result['measurements']['verified_outputs']['HEADLESS-2'][0] else 'right'
origin = result['measurements']['verified_outputs'][
'HEADLESS-1' if output == 'left' else 'HEADLESS-2']
path = run / f'{output}-open.png'
if path.exists():
local = (int(x - origin[0]), int(y - origin[1]))
image = open_image(path)
result['open_sample_rgb'][sample['purpose']] = {
'global': [x, y], 'output': output, 'local': list(local),
'rgb': list(image.getpixel(local)) if (
0 <= local[0] < image.width and 0 <= local[1] < image.height) else None,
}
(run / 'analysis.json').write_text(json.dumps(result, indent=2) + '\n')
print(run.name, result['captures'].get('left-open.png', {}).get('exact_color_pixels'),
result['captures'].get('right-open.png', {}).get('exact_color_pixels'))

View file

@ -0,0 +1,28 @@
#!/bin/sh
# EXPERIMENTAL: rebuild the historical private compositor in a fresh lab.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
fixture="$repo/tooling/probes/step20c-negative-origin"
prior="$repo/tooling/probes/step20c-nested-grab"
pointer_patch="$repo/tooling/probes/step20c-vertical-grab/pointer-extents.patch"
build_log=$(mktemp /tmp/mno-prepare-XXXXXXXX)
if ! sh "$prior/prepare.sh" > "$build_log" 2>&1; then
printf 'PRIVATE_BUILD_FAILED log=%s\n' "$build_log" >&2
tail -40 "$build_log" >&2
exit 1
fi
lab=$(sed -n 's/^LAB=//p' "$build_log")
test -n "$lab" && test -d "$lab"
trap 'rm -rf -- "$lab"' 0
# CONTRACT: adapt only the copied client; original fixtures and private
# compositor patches retain their historical hashes.
cp "$repo/tooling/probes/step20c-valid-grab/pointer-daemon.c" "$lab/pointer-daemon.c"
(cd "$lab" && git apply --check "$pointer_patch" && git apply "$pointer_patch")
cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$lab" \
"$lab/pointer-daemon.c" "$lab/virtual-pointer-protocol.c" -o "$lab/pointer-daemon" \
$(pkg-config --cflags --libs wayland-client)
sha256sum "$prior/nested-client.c" "$pointer_patch" "$fixture/prepare.sh" \
"$fixture/run.py" "$fixture/analyze.py" "$lab/pointer-daemon.c" \
> "$lab/negative-origin-source-sha256.txt"
trap - 0
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$build_log"

View file

@ -0,0 +1,400 @@
"""EXPERIMENTAL negative/positive-origin nested XDG grab runner.
Usage: python run.py LAB {control,private_standard,private_mosaic} SCENARIO GEOMETRY
SCENARIO: pointer, visible, b_cross, both_cross, a_cross, outside, hidden,
hidden_click, toggle
GEOMETRY: negative, positive
"""
import json
import os
import pathlib
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
if len(sys.argv) != 5:
raise SystemExit(__doc__)
LAB = pathlib.Path(sys.argv[1]).resolve()
case, scenario, geometry = sys.argv[2:5]
assert case in ('control', 'private_standard', 'private_mosaic')
assert scenario in ('pointer', 'visible', 'b_cross', 'both_cross', 'a_cross', 'outside',
'hidden', 'hidden_click', 'toggle')
assert geometry in ('negative', 'positive')
if scenario in ('hidden', 'hidden_click', 'toggle') and case != 'private_mosaic':
raise SystemExit('this scenario requires private_mosaic')
assert LAB.is_dir() and (LAB / 'nested-client').is_file()
placement = ('visible' if scenario in ('pointer', 'visible', 'outside') else
'both_cross' if scenario in ('both_cross', 'a_cross') else 'b_cross')
run = LAB / f'run-{geometry}-{case}-{scenario}'
run.mkdir(mode=0o700, exist_ok=False)
runtime = pathlib.Path(tempfile.mkdtemp(prefix='mno-', dir='/tmp'))
# WHY: keep both Unix socket names below sockaddr_un.sun_path even if LAB is
# long; the compositor derives its IPC path from XDG_RUNTIME_DIR.
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
raise RuntimeError('private IPC socket path exceeds Linux sockaddr_un.sun_path')
(run / 'runtime-path').write_text(str(runtime) + '\n')
origin_x = -800 if geometry == 'negative' else 0
output2_x = origin_x + 800
output2 = f'output HEADLESS-2 resolution 800x600 position {output2_x} 0\n'
(run / 'config').write_text(
f'output HEADLESS-1 resolution 800x600 position {origin_x} 0\n' + output2 +
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
'focus_follows_mouse no\n')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
env.pop('WAYLAND_DISPLAY', None)
env.pop('SWAYSOCK', None)
env.pop('I3SOCK', None)
env.pop('LD_LIBRARY_PATH', None)
compositor_env = env.copy()
if case == 'control':
binary = pathlib.Path('/usr/bin/sway')
else:
binary = LAB / 'mosaic-build/sway/swayfx-mosaic'
compositor_env['LD_LIBRARY_PATH'] = str(LAB / 'scenefx-build')
files = []
children = []
def spawn(argv, name, *, debug=False, stdin=None):
log = (run / name).open('w')
files.append(log)
child_env = env.copy()
if debug:
child_env['WAYLAND_DEBUG'] = '1'
proc = subprocess.Popen(argv, env=child_env, stdin=stdin, stdout=log,
stderr=subprocess.STDOUT, text=True,
start_new_session=True)
children.append(proc)
return proc
def wait(fn, seconds=12):
end = time.monotonic() + seconds
while time.monotonic() < end:
result = fn()
if result:
return result
if compositor.poll() is not None:
raise RuntimeError(f'compositor exited {compositor.returncode}')
if 'pointer' in globals() and pointer.poll() is not None:
raise RuntimeError(f'virtual pointer exited {pointer.returncode}')
time.sleep(.05)
raise RuntimeError('timeout waiting for probe event')
def ipc(*args):
return json.loads(subprocess.run(['swaymsg', '-s', str(sock), *args],
env=env, text=True, capture_output=True, check=True,
timeout=5).stdout)
def find(node, predicate):
if isinstance(node, dict):
if predicate(node):
return node
for child in node.get('nodes', []) + node.get('floating_nodes', []):
result = find(child, predicate)
if result:
return result
return None
def tree():
return ipc('-t', 'get_tree')
def ws():
return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
def owner():
return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe')
def logged(s):
path = run / 'client.log'
return path.exists() and s in path.read_text()
def configured(name):
"""The server's last role configure, not the positioner request."""
matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)',
(run / 'client.log').read_text())
if not matches:
raise RuntimeError(f'missing {name} ROLE_CONFIGURE')
return tuple(map(int, matches[-1]))
def verify_outputs():
expected = {'HEADLESS-1': (origin_x, 0, 800, 600),
'HEADLESS-2': (output2_x, 0, 800, 600)}
actual = {}
data = ipc('-t', 'get_outputs')
(run / 'outputs-verified.json').write_text(json.dumps(data, indent=2))
for output in data:
if output.get('name') in expected and output.get('active'):
rect = output['rect']
if output.get('scale') != 1.0 or output.get('transform') != 'normal':
raise RuntimeError(f'unexpected output scale/transform: {output}')
actual[output['name']] = (rect['x'], rect['y'], rect['width'], rect['height'])
if actual != expected:
raise RuntimeError(f'output geometry mismatch: {actual} != {expected}')
return expected
def capture(tag):
(run / f'tree-{tag}.json').write_text(json.dumps(tree(), indent=2))
(run / f'outputs-{tag}.json').write_text(json.dumps(ipc('-t', 'get_outputs'), indent=2))
for output, label in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
subprocess.run(['grim', '-o', output, str(run / f'{label}-{tag}.png')],
env=env, check=True, timeout=8, stdout=subprocess.DEVNULL)
def inject(action, x, y):
"""Convert a signed global point only after verifying layout bounds."""
if not isinstance(x, int) or not isinstance(y, int):
raise ValueError(f'nonintegral global pointer request: {x},{y}')
if not (origin_x <= x < origin_x + extent[0] and 0 <= y < extent[1]):
raise ValueError(f'global point outside verified layout: {x},{y}')
normalized_x = x - origin_x
output = 'HEADLESS-1' if x < output2_x else 'HEADLESS-2'
local_x = x - (origin_x if output == 'HEADLESS-1' else output2_x)
entry = {'action': action, 'global': [x, y],
'layout_min': [origin_x, 0], 'sent': [normalized_x, y],
'extent': list(extent), 'expected_output': output,
'expected_output_local': [local_x, y]}
injection_log.append(entry)
(run / 'injections.json').write_text(json.dumps(injection_log, indent=2))
marker = f'INJECT_DONE {action} {normalized_x},{y}'
pointer.stdin.write(f'{action} {normalized_x} {y}\n')
pointer.stdin.flush()
wait(lambda: marker in (run / 'pointer.log').read_text())
def command(value):
client.stdin.write(value + '\n')
client.stdin.flush()
wait(lambda: f'COMMAND {value}' in (run / 'client.log').read_text())
compositor_log = (run / 'compositor.log').open('w')
files.append(compositor_log)
compositor = None
try:
compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'],
env=compositor_env, stdout=compositor_log,
stderr=subprocess.STDOUT, start_new_session=True)
def sockets():
s = next(iter(runtime.glob('sway-ipc.*.sock')), None)
w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None)
return (s, w) if s and w else None
sock, way = wait(sockets)
env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name)
verified_outputs = verify_outputs()
injection_log = []
extent = (1600, 600)
pointer = spawn([str(LAB / 'pointer-daemon'), *map(str, extent)], 'pointer.log',
stdin=subprocess.PIPE)
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
ipc('workspace 1')
ipc('layout mosaic' if case == 'private_mosaic' else 'layout splith')
spawn(['kitty', '--class', 'A', '-o', 'background=#aa2222',
'/usr/bin/sleep', '90'], 'A.log')
wait(lambda: len(ws()['nodes']) >= 1)
client = spawn([str(LAB / 'nested-client'), placement], 'client.log',
debug=True, stdin=subprocess.PIPE)
wait(lambda: owner() and logged('TOPLEVEL MAP'))
for label in ('C', 'D'):
spawn(['kitty', '--class', label, '/usr/bin/sleep', '90'], label + '.log')
time.sleep(.35)
ipc('workspace 2')
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
ordinary = wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev'))
ordinary_rect = ordinary['rect']
ipc('workspace 1')
ipc('[app_id="org.mosaic.NestedGrabProbe"] focus')
if case == 'private_mosaic':
current = ws()['mosaic']['rows'][0]['scroll_x']
ipc(f'layout mosaic scroll {400-current:g}')
time.sleep(.4)
o = owner()
scroll = ws()['mosaic']['rows'][0]['scroll_x'] if case == 'private_mosaic' else 0
# CONTRACT: logical IPC X and camera displacement are distinct. A scene
# log and sampled pixels must corroborate this applied-content estimate.
rendered_x = o['rect']['x'] - scroll + o['window_rect']['x']
rendered_y = o['rect']['y'] + o['window_rect']['y']
top_x = int(max(origin_x + 40, min(output2_x - 40, rendered_x + 100)))
right_x = int(max(output2_x + 30, ordinary_rect['x'] + 30))
right_y = int(max(80, ordinary_rect['y'] + 30))
calibration = [(top_x, 150), (right_x, right_y),
(top_x + 5, 160), (right_x + 5, right_y + 5)]
for x, y in calibration:
inject('move', x, y)
time.sleep(.13)
capture('pointer-reference')
calibration_evidence = {'global_points': calibration,
'owner_motion_lines': [line for line in (run / 'client.log').read_text().splitlines()
if ('POINTER_MOTION target=TOPLEVEL' in line or
'POINTER_ENTER target=TOPLEVEL' in line)],
'ordinary_motion_lines': [line for line in (run / 'wev.log').read_text().splitlines()
if 'motion' in line.lower() or 'enter' in line.lower()]}
(run / 'pointer-validation.json').write_text(json.dumps(calibration_evidence, indent=2))
if len(calibration_evidence['owner_motion_lines']) < 2 or not calibration_evidence['ordinary_motion_lines']:
raise RuntimeError('pointer calibration did not show motion on both clients')
if scenario == 'pointer':
for action, x, y in [('right', top_x, 150), ('left', right_x, right_y),
('right', right_x + 5, right_y + 5),
('left', top_x + 5, 160)]:
inject(action, x, y)
time.sleep(.2)
capture('pointer-buttons')
print('POINTER_REFERENCE', calibration_evidence, flush=True)
else:
inject('left', top_x, 150)
wait(lambda: logged('POPUP_A MAP'))
a_cfg = configured('POPUP_A')
a_x = int(max(origin_x + 40, min(output2_x - 40, rendered_x + a_cfg[0] + 40)))
a_y = int(min(585, rendered_y + a_cfg[1] + 35))
inject('left', a_x, a_y)
wait(lambda: logged('POPUP_B MAP'))
b_cfg = configured('POPUP_B')
time.sleep(.3)
capture('open')
a_global = (rendered_x + a_cfg[0], rendered_y + a_cfg[1], a_cfg[2], a_cfg[3])
b_global = (a_global[0] + b_cfg[0], a_global[1] + b_cfg[1], b_cfg[2], b_cfg[3])
a_cross = a_global[0] < output2_x < a_global[0] + a_global[2]
b_cross = b_global[0] < output2_x < b_global[0] + b_global[2]
measurements = {'geometry': geometry, 'case': case, 'scenario': scenario,
'verified_outputs': verified_outputs, 'layout_origin': [origin_x, 0],
'layout_extent': extent, 'owner_id': o['id'], 'owner_rect': o['rect'],
'owner_window_rect': o['window_rect'], 'rendered_content_estimate': [rendered_x, rendered_y],
'scroll_x': scroll, 'ordinary_rect': ordinary_rect,
'popup_a_configure': a_cfg, 'popup_b_configure': b_cfg,
'popup_a_global_estimate': a_global, 'popup_b_global_estimate': b_global,
'popup_a_crossing_estimate': a_cross, 'popup_b_crossing_estimate': b_cross,
'sample_points': []}
def save():
(run / 'measurements.json').write_text(json.dumps(measurements, indent=2))
save()
print('CHAIN_OPEN', case, scenario, placement, 'owner_id', o['id'],
'owner_rect', o['rect'], 'scroll', scroll, 'a_configure', a_cfg,
'b_configure', b_cfg, 'a_global', a_global, 'b_global', b_global,
'a_cross', a_cross, 'b_cross', b_cross, flush=True)
if scenario == 'visible':
for purpose, rect in [('visible_a', a_global), ('visible_b', b_global)]:
x = int(max(origin_x + 20, min(output2_x - 20, rect[0] + 20)))
y = int(rect[1] + 20)
measurements['sample_points'].append({'purpose': purpose, 'global': [x, y]})
inject('right', x, y)
capture('visible-clicks')
elif scenario in ('b_cross', 'both_cross', 'a_cross'):
if not b_cross or (scenario in ('both_cross', 'a_cross') and not a_cross):
print('INCONCLUSIVE configured popup crossing absent', flush=True)
else:
left_b = (int(min(output2_x - 20, b_global[0] + 40)), int(b_global[1] + 30))
right_b = (int(max(output2_x + 20, ordinary_rect['x'] + 15,
b_global[0] + 20)), int(max(b_global[1] + 30,
ordinary_rect['y'] + 15)))
if not (b_global[0] <= left_b[0] < output2_x and
b_global[0] <= right_b[0] < b_global[0] + b_global[2] and
b_global[1] <= right_b[1] < b_global[1] + b_global[3] and
ordinary_rect['x'] <= right_b[0] < ordinary_rect['x'] + ordinary_rect['width']):
print('INCONCLUSIVE no verified popup/wev overlap', left_b, right_b, flush=True)
else:
actions = []
if scenario in ('both_cross', 'a_cross'):
left_a = (int(a_global[0] + 20), int(a_global[1] + 20))
right_a = (int(max(output2_x + 10, a_global[0] + a_global[2] - 15)),
int(a_global[1] + 20))
actions += [('right', *left_a, 'visible_a')]
actions += [('right', *left_b, 'visible_b')]
if scenario == 'a_cross':
actions += [('right', *right_a, 'crossing_a')]
else:
actions += [('right', *right_b, 'crossing_b')]
for action, x, y, purpose in actions:
measurements['sample_points'].append(
{'purpose': purpose, 'global': [x, y], 'action': action})
save()
inject(action, x, y)
time.sleep(.2)
capture(f'{purpose}-{action}')
elif scenario == 'outside':
outside = (int(max(output2_x + 30, ordinary_rect['x'] + 20)),
int(min(500, max(300, ordinary_rect['y'] + 200))))
measurements['sample_points'].append({'purpose': 'outside_click', 'global': outside})
save()
inject('left', *outside)
time.sleep(.2)
capture('outside')
elif scenario in ('hidden', 'hidden_click'):
ipc('layout mosaic scroll 880')
time.sleep(.5)
capture('hidden')
hidden_owner = owner()
hidden_scroll = ws()['mosaic']['rows'][0]['scroll_x']
hidden_x = hidden_owner['rect']['x'] - hidden_scroll + hidden_owner['window_rect']['x']
measurements['hidden_owner_applied_estimate'] = [hidden_x,
hidden_owner['rect']['y'] + hidden_owner['window_rect']['y'],
hidden_owner['window_rect']['width'], hidden_owner['window_rect']['height']]
measurements['hidden_scroll_x'] = hidden_scroll
save()
inject('move', origin_x + 100, 190)
time.sleep(.2)
capture('hidden-hover')
if scenario == 'hidden_click':
inject('right', origin_x + 100, 190)
time.sleep(.2)
capture('hidden-click')
ipc('layout mosaic scroll -880')
time.sleep(.4)
capture('returned')
elif scenario == 'toggle':
ipc('layout splith')
time.sleep(.4)
capture('standard')
standard_owner = owner()
standard_top_x = standard_owner['rect']['x'] + standard_owner['window_rect']['x']
standard_top_y = standard_owner['rect']['y'] + standard_owner['window_rect']['y']
toggle_x = int(max(output2_x + 20,
standard_top_x + a_cfg[0] + b_cfg[0] + 20))
toggle_y = int(standard_top_y + a_cfg[1] + b_cfg[1] + 30)
measurements['standard_owner_rect'] = standard_owner['rect']
measurements['sample_points'].append(
{'purpose': 'standard_popup_b', 'global': [toggle_x, toggle_y]})
save()
inject('right', toggle_x, toggle_y)
time.sleep(.2)
capture('standard-click')
print('FINAL', 'a_done', logged('POPUP_A POPUP_DONE'),
'b_done', logged('POPUP_B POPUP_DONE'),
'client_alive', client.poll() is None, flush=True)
finally:
for child in children:
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
if compositor is not None and compositor.poll() is None:
os.killpg(compositor.pid, signal.SIGTERM)
# CONTRACT: only process groups started by this runner are terminated.
# Reap them before removing their private socket directory.
for proc in [*children, *([compositor] if compositor is not None else [])]:
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=5)
(run / 'exit-status.json').write_text(json.dumps({
'compositor': None if compositor is None else compositor.returncode,
'children': [{'argv': proc.args, 'returncode': proc.returncode}
for proc in children]}, indent=2))
for file in files:
file.close()
shutil.rmtree(runtime)