docs(mosaic): preserve rotated partial popup clipping evidence

This commit is contained in:
fy59 2026-10-12 17:20:06 +02:00
parent f7a94eb5fb
commit 89567ba246
17 changed files with 1162 additions and 0 deletions

View file

@ -4,6 +4,8 @@
### STEP20C experimental evidence
- Local static-R90 partial-popup probe: a legal nested B popup crossed the Mosaic viewport by 72 logical pixels; 46,455 B pixels and a real button survived on H1, while H2 was pixel-identical to its reference and delivered no B button. Both standard controls showed 35,360 B pixels and a B button on H2. A crossed visually, but its forbidden-side input was not independently clicked. PARTIALLY_FEASIBLE for the tested matrix; no production change or phase-B commit/push.
- Preserved rotated-output probe: actual 90° output keeps an 800×600 logical viewport while the physical mode becomes 750×1000 and `grim` remains logical-oriented 1000×750. Private standard shows 15,860 B-popup pixels on H2; private Mosaic shows zero, but its B popup ends before the boundary, so partial clipping is unchallenged. Fully hidden owner, pointer, measured physical damage and R0/standard returns pass sampled checks. Pristine R90 capture stopped on a fixture IPC-direction assertion; no replay. PARTIALLY_FEASIBLE, architectural conclusion UNDECIDED; no production change.
- Preserved dynamic-output ownership probe: real 800→960→800 logical resize and H1→H2→H1 workspace migration preserve IDs and sampled applied clips. Exact vacated-output backgrounds and measured physical damage are compatible; two standard-control final calibrations fail through reference occlusion. PARTIALLY_FEASIBLE; maximum clamp, migrated clipped-popup input, opaque spill and effects remain open. Six reconfigurations/six migrations, zero extra camera pairs; no production change. The original temporary evidence manifest was verified before an offline analyzer rerun replaced it; the report records the provenance limit.

View file

@ -270,3 +270,12 @@ The `grim` PNG is logical-oriented at 1000×750 while the R90 physical mode is
and the Mosaic B popup never reached the boundary, so partial popup clipping
under rotation remains **UNDECIDED**. No applied-geometry generation contract
or production behavior is accepted by this evidence.
The separate [static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
measured a real Mosaic B box crossing x=0 by 72 logical pixels, with B
visible/clickable inside H1 and absent visually and from client delivery on
H2. Its applied root clip and nested inheritance matched the H1 owner
viewport in the sampled state; pristine and private-standard controls both
drew and handled the crossed portion. Result **PARTIALLY_FEASIBLE** because
the clipped-away part of A was not independently clicked and no atomic
render/input generation was proved. The architecture remains experimental.

View file

@ -209,3 +209,13 @@ result is **PARTIALLY_FEASIBLE**, architectural partial-clipping conclusion
Mosaic popup stayed wholly inside the viewport despite standard control spill.
Scored border hits, rotated shadow/blur/cache and raw buffer readback are
**NOT TESTED**. No production support claim follows.
The subsequent [static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
closed the missing B geometry challenge for one legal XDG placement:
Mosaic B crossed by 72 logical pixels; sampled H1 visual/button input
survived and H2 matched its prior reference with no B delivery. Its
overall status is **PARTIALLY_FEASIBLE**. The A root popup also crossed,
but independent input on its forbidden portion remains **NOT TESTED**.
Saved buffers, optimized blur caches, inner shadows, intermediate frames,
atomic generation and SceneFX/wlroots ABI/package remain unqualified.
Multi-output stays **NOT YET SUPPORTED**.

View file

@ -120,3 +120,11 @@ sampled damage and return. Pristine R90 capture and a Mosaic popup portion
actually crossing the boundary were not exercised; architectural clipping
conclusion UNDECIDED. STEP20C remains NEXT; production multi-output remains
NOT YET SUPPORTED.
[Static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
and [fixture](../tooling/probes/step20c-rotated-partial-popup/README.md):
EXPERIMENTAL / PARTIALLY_FEASIBLE. Mosaic B crossed x=0 by 72 logical pixels;
its H1 pixels and real button remained, while H2 matched its reference and
delivered no B button. Both standard controls drew and handled the overflow.
Independent clipped-away A input and saved buffers remain open. STEP20C is
NEXT; production multi-output is NOT YET SUPPORTED.

View file

@ -212,3 +212,12 @@ is **UNDECIDED** because the Mosaic popup never crossed the R90 boundary and
the pristine R90 capture was lost to a fixture assertion. The next distinct
geometric gate should force a legal grabbed Mosaic popup portion across that
boundary with a completed pristine control. STEP20C remains **NEXT**.
The [static R90 partial-popup gate](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
then measured exactly that B crossing and completed the pristine control.
The bounded result is **PARTIALLY_FEASIBLE**: B's sampled pixels/input obeyed
the owner clip, while the independent clipped-away A input point and broader
temporal/effect cases remain open. Next prioritize saved transaction buffers
against the same owner-derived viewport before optimized blur cache,
interior shadow and ABI/package maintenance. STEP20C remains **NEXT**;
production multi-output remains **NOT YET SUPPORTED**.

View file

@ -0,0 +1,207 @@
# STEP20C static R90 partial popup clipping probe
**EXPERIMENTAL protocol, before execution.** STEP20C is NEXT; production
multi-output Mosaic is NOT YET SUPPORTED. The preceding
[rotation probe](STEP20C_ROTATED_OUTPUT_PROBE.md) was PARTIALLY_FEASIBLE:
private standard displayed 15,860 B pixels on H2, but Mosaic B ended at
PNG x=903, short of the x=1000 boundary. Its pristine R90 fixture stopped
after applying transform `90` because it incorrectly expected IPC `270`.
This independent campaign tests only a static R90 startup, with no dynamic
transform, resize, migration, camera hide/return, effects or algorithm change.
## Fixed geometry and model
H1 starts at global (-800,0), physical 750×1000, IPC transform `90`, scale
1.25, logical 800×600. H2 starts at (0,0), physical/logical 800×600, normal
at scale 1. Read `get_outputs` before scoring. The expected `grim -s 1.25`
H1 PNG is logically oriented 1000×750 according to the earlier probe; its
actual dimensions and five distinct markers must be measured anew. The
internal Wayland transform enum `270`, physical buffer and logical PNG are
different coordinate systems. Pointer injection uses the measured logical
union (-800..800, 0..600), with sent x = global x + 800.
Use one fixed XDG placement `rot_cross`: TOPLEVEL 420×320; A 480×170 with
anchor rect (360,80,1,1); B 300×130 with anchor rect (210,60,1,1).
Both anchors fit their parent dimensions. Positioners use TOP_LEFT anchor,
BOTTOM_RIGHT gravity and no constraint adjustment requested by the client.
Actual `xdg_popup.configure` x/y/w/h, ack, map and scene placement must be
recorded; compositor unconstrain may change them. No alternate placement is
authorized in this campaign.
The prior applied Mosaic owner plus camera 640 placed B's right edge near
global -78 (PNG x=903). Moving A's anchor +160 and B's -10 predicts B's
right edge near +72, enough for a discriminating H2 portion. A's width
adds 180, predicting an A right edge near +42. These are **predictions**, not
observations. The original `(390,80)` candidate would shift B by +180: the
earlier standard B left edge near -178 would move to +2, removing its H1
portion. This single pre-run variant shifts B by +150 and predicts standard
B left near -28, retaining 28 logical pixels on H1. The owner, workspace,
applied camera and popup root scene
coordinates must be measured separately per mode. Compute each global popup
box from the applied owner/scene origin and configured XDG offsets, then
intersect with H1 [-800,0)×[0,600) and H2 [0,800)×[0,600).
Partial clipping is exercised only when left < 0 < right and both intersections
are nonempty. A crossing less than 16 logical pixels is reported with its
actual width before qualification. A box wholly within H1 is NOT EXERCISED,
even when H2 contains zero popup pixels.
## Runs, observations and decisions
One run each: C0 pristine pinned SwayFX `splith`, C1 private `splith`, C2
same private compositor `mosaic`. Static output config is applied at startup.
Each run uses one 0700 runtime and explicit private IPC socket, at most two
distinct A/B grab chains, 16 captures and 120 seconds. The entire campaign
allows at most 3 runs and 50 captures; failed reservations count. A persistent
exclusive ledger is tested offline and reserved before each launch. A single
initial Mosaic scroll to 640 is permitted before popup creation, logged once.
Capture a five-marker H1 background and stable H2 reference before popup
creation. Record capture monotonic windows, SHA-256, mode/scale/transform,
logical rect, PNG dimensions, popup boxes and clips. Compare H2's entire
forbidden intersection to its predeclared reference as well as exact A/B
colors, without post-hoc ROI exclusions. At least one standard control must
show B pixels on both outputs and a real B button on H2. Mosaic must show
its own geometric B crossing, B pixels and a delivered button inside H1,
zero B contribution and no B delivery on H2. Derive boundary y from the
applied popup box and inspect x=-3..3. Keep hover probes in one chain; use
a second chain for an exterior click that may dismiss the grab. A is evaluated
separately at points outside B when its own box crosses.
The client must log a real button serial on TOPLEVEL before A's grab and on
A before B's grab, each configure/ack/map and protocol error. The runner
records applied tiled/root/child clips, owner output, damage in buffer-local
physical coordinates, commits/presents and actual client delivery. Damage
from the inherited `TEMPORAL_DAMAGE` path is already physical: do not apply
scale twice. Settled PNG differences do not prove every intermediate frame
or an atomic render/input generation.
FEASIBLE requires valid crossing B in Mosaic, standard overflow controls,
retained H1 pixels/input, excluded H2 pixels/input, correct owner clips and
completed pristine R90. PARTIALLY_FEASIBLE preserves a crossing with missing
secondary proof. INCONCLUSIVE covers absent crossing, invalid fixture or
incomparable controls. BLOCKED requires a reproducible forbidden pixel/hit,
wrong owner clip, crash or standard regression. A confirmed leak stops the
affected campaign; no clipping algorithm is changed to obtain a pass.
R270, saved buffers, optimized blur cache, inner shadow, raw framebuffer,
all intermediate frames and ABI/package maintenance remain NOT TESTED.
The [source fixture](../../tooling/probes/step20c-rotated-partial-popup/README.md)
retains only reviewed scripts and the client placement patch. Generated
builds, binaries, captures, sockets, logs and structured results stay in a
private LAB.
## Measured static-R90 campaign — 2026-10-12
**EXPERIMENTAL / PARTIALLY_FEASIBLE.** LAB:
`/tmp/swayfx-mosaic-nested-grab-PioJ5X43`. The new preparer rebuilt the pinned
pristine SwayFX, private Mosaic/SceneFX and strict C17 clients from the prior
published source chain, then applied only the dedicated `rot_cross` client
patch. The clipping algorithm and installed libraries were untouched. One
main run each of pristine `splith`, private `splith` and private `mosaic`
completed normally, with 14 captures and about 14.6 seconds per run. The
new locked ledger consumed **3/3** runs; dynamic transitions, migrations,
resize, camera hide/return and R270 each consumed **0**. Mosaic made one
pre-popup static camera placement to scroll 640, recorded in the raw events.
All three `get_outputs` states reported H1 physical 750×1000, IPC transform
`90`, scale 1.25 and logical (-800,0,800,600). H2 remained 800×600,
normal/scale 1 at (0,0). Every H1 `grim -s 1.25` PNG was 1000×750 and the
five marker centers established the logical PNG orientation. The control
pristine therefore completed the R90 capture and input matrix missed by the
historical fixture; no dynamic R90→R0 action occurred. Its two grabs used
real button serials 121/126, configured, acked, mapped, and exited without
protocol error. Private standard used 123/128; Mosaic used 105/110. The
configured XDG offsets were A (360,80,480,170) and B (210,60,300,130)
in all modes, matching the fixed legal 1×1 anchors. The actual source scene
and owner positions produced these global half-open boxes:
| Mode | Applied owner x / camera | A global | A∩H1 / A∩H2 widths | B global | B∩H1 / B∩H2 widths |
| --- | --- | --- | --- | --- | --- |
| Pristine `splith` | -600 / 0 | [-238,242)×[105,275) | 238 / 242 | [-28,272)×[165,295) | 28 / 272 |
| Private `splith` | -600 / 0 | [-238,242)×[105,275) | 238 / 242 | [-28,272)×[165,295) | 28 / 272 |
| Private Mosaic | -160 / 640 | [-438,42)×[105,275) | 438 / 42 | [-228,72)×[165,295) | 228 / 72 |
The Mosaic owner current rect was x=-160 with width 640, while its applied
scene origin was x=-800 after scroll 640. Its content/window x offset was 2,
giving root-popup global (-798,25). A starts there plus configured (360,80),
B starts at A plus configured (210,60). The sampled `DO_STATE` reported
internal transform 3 (`WL_OUTPUT_TRANSFORM_270`), physical 750×1000 and
logical 800×600. `PI_SCENE` held tile global -1440 and local clip
(640,0,800,600), hence viewport [-800,0)×[0,600). `DO_POPUP` identified
owner 8 on H1 and local root clip (-2,-25,800,600), which yields the same
global viewport. B remained a scene descendant of A and inherited that
root clip. The scene and configured XDG offsets agree at the sampled applied
state; this does not establish an atomic generation over all frames.
The predefined reference was each mode's `right-reference.png`, captured
before A/B creation. At the mapped-B capture, pristine and private standard
each had **5,705 exact B pixels on H1** and **35,360 on H2**; their H2
full-image difference from reference contained **49,880 pixels**, with box
[0,105,272,295). Mosaic retained **46,455 B pixels on H1**, including
163/489/815 pixels in its last 1/3/5 PNG columns, and **zero B pixels on
H2**. Its H2 whole PNG was **pixel-identical** to the
predeclared reference: zero differing pixels, not merely zero exact SHM
colors. A itself also crossed: Mosaic showed 77,181 A pixels on H1 and
zero on H2; the standards showed 58,431 on H1 and 14,520 on H2. The A
count includes occlusion by B where their rectangles overlap; it is not a
full theoretical A-area count. The measured B geometry, unlike the old
probe, genuinely challenged the clip by 72 logical pixels on H2.
For B's y=230 boundary grid, the pristine and private-standard clients
reported B local motions x=25..31 for global x=-3..3 and delivered right
buttons on B at (-14,230) and (30,230). The Mosaic client reported B local
motions x=225,226,227 at global x=-3,-2,-1, then a B leave at x=0; no
B enter/motion followed for x=0..3. A right button at (-30,230) was delivered
to B with serial 119. At forbidden (30,230), B received **no button**;
the valid grab produced `popup_done` for B and A. The button on visible A
that created B was genuinely delivered (serial 110). An independent click
on the clipped-away portion of **A** was not made; P2 root-popup input is
therefore PARTIAL even though A's geometry and pixels cross. No universal
click-through contract is inferred from the dismissal.
Available `TEMPORAL_DAMAGE` extents for the settled reference→mapped-B
Mosaic H1 pair covered all **123,636 changed logical-PNG pixels** after
mapping `(x,y)` to physical `(749-y,x)`: 10 physical extents, zero uncovered.
H2 changed zero pixels. The private-standard pair likewise had zero
uncovered changed pixels on both outputs (10 H1 and 6 H2 extents). The
pristine control had no passive damage trace; its nonzero screenshot diff
cannot be scored against absent extents. These broad damage observations
cover only the settled pair, not minimal invalidation, all intermediate
frames, saved buffers or render/input generation identity. Private Mosaic
logged 126 native commits and 124 presents during the whole run; private
standard logged 119/117. Startup, pointer and teardown activity are included.
`LAB/results/` retains geometry, positions/intersections, output transform,
grab, applied clip, input, pixels, damage, matrix and summary JSON. Raw
client/protocol, compositor, pointer, `get_outputs`/tree, PNG and capture
windows remain under each `LAB/runs/` directory. The final source and
evidence manifest entry counts and SHA-256 values are recorded below after
documentation synchronization: **910 source entries**,
`3e6930c46dd9abf50aa2720357ccbdd47ac3fdd0f1106a47734383cb281b41a5`;
**146 evidence entries**,
`e3c13e4fc02f6b66b59b87407ef7b91f5b7cd67430cc1142252098ef3885a949`.
The dedicated placement patch SHA-256 is
`0f45d48625c062ca05f36849068aedecd02e4480a98f3e0bafe98c1f1c529089`,
based on nested client SHA-256
`2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d`.
Both manifest files passed entry-by-entry verification after the fixture
README was synchronized and the CLI limits were made non-overridable above
120 seconds / 16 captures / 30 MB. Each run retains its executed
`fixture-source.py`; that exact source precedes this post-run limit hardening.
The hardening was tested offline with an over-limit argument and caused no
fourth run or ledger change. The ledger is separate
from the historical 5/6 rotation and 1/1 camera budget, which were not
reopened. Every run used a private 0700 runtime and explicit private IPC;
exit status and cleanup were retained. No production, package or UX change
was made.
**Verdict:** `SWAYFX_MOSAIC_ROTATED_PARTIAL_POPUP_CLIP_PROBE=PARTIALLY_FEASIBLE`.
The central B crossing/visible/clipped/input distinction passed this one
static-R90 matrix, including a complete pristine control. The scored
clipped-away A input point, all-frame damage and applied generation are
still open. Architectural conclusion:
`ROTATED_PARTIAL_POPUP_CLIP_FEASIBLE_FOR_TESTED_MATRIX`, without production
STEP20C qualification. Saved transaction buffers are the next priority:
they can reintroduce pixels from an earlier scene at the same viewport even
when the live popup tree clips correctly. Optimized blur cache, interior
shadow and SceneFX/wlroots ABI/package maintenance remain separate gates.

View file

@ -0,0 +1,45 @@
# Static R90 partial popup fixture
**EXPERIMENTAL / PARTIALLY_FEASIBLE.** See the
[scientific report](../../../docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md).
The fixed placement is `rot_cross`: A (360,80), 480×170; B (210,60),
300×130; toplevel 420×320. It was fixed before the three runs.
Use a new private LAB and a locked ledger; the previous rotation laboratory
and its consumed budget are read-only. The runner must use startup R90 config,
one run per mode, explicit private sockets, at most 16 captures per run and
120 seconds. No production source, clipping algorithm, installed library or
daily session changes are permitted. Generated files remain outside Git.
The completed LAB is `/tmp/swayfx-mosaic-nested-grab-PioJ5X43`; its ledger
has consumed all three runs. **Do not run those modes again or reset the
ledger.** Source-only preparation in another authorized campaign uses:
```sh
sh tooling/probes/step20c-rotated-partial-popup/prepare.sh
```
This rebuilds pinned private sources and applies `rot-cross-client.patch`
to the [nested client](../step20c-nested-grab/nested-client.c), whose base
SHA-256 is `2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d`.
The patch preserves earlier placements and adds only `rot_cross`.
`prepare.sh` does not launch a compositor.
The completed campaign used these one-run commands, in order:
```sh
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/test-budget.py
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" control
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" private_standard
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" private_mosaic
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/analyze.py "$LAB"
```
`LAB/results/` contains structured analysis; `LAB/runs/` contains raw
captures, client/protocol events, applied traces and per-run exit codes.
The source manifest covers 910 files and the evidence manifest 146 files;
their final SHA-256 values are recorded in the scientific report.
Mosaic B crossed by 72 logical pixels, remained visible/clickable on H1
and was absent from H2 pixels/client delivery. Both standards visibly
crossed. Independent forbidden-side A input was not tested, so the verdict
stays PARTIALLY_FEASIBLE.

View file

@ -0,0 +1,213 @@
"""Offline, single-seal analysis of the static R90 runs and their raw evidence."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import numpy as np
from PIL import Image
COLORS = {'A': (224, 192, 32), 'B': (32, 208, 160)}
TRACE = re.compile(r'\b(DO_STATE|DO_POPUP|PI_SCENE|PI_OWNER|PI_OUTPUT_COMMIT|PI_OUTPUT_PRESENT)\b')
DAMAGE = re.compile(r'TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) '
r'extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)')
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def write(path, obj):
path.write_text(json.dumps(obj, indent=2, sort_keys=True) + '\n')
def fields(line):
return dict(re.findall(r'(\w+)=([^\s]+)', line))
def bbox(mask):
yy, xx = np.where(mask)
return [int(xx.min()), int(yy.min()), int(xx.max()) + 1, int(yy.max()) + 1] if len(xx) else None
def latest(traces, kind, before, **constraints):
found = [x for x in traces if x['kind'] == kind and x['ns'] <= before and
all(x.get(k) == v for k, v in constraints.items())]
return found[-1] if found else None
def image(path):
return np.asarray(Image.open(path).convert('RGB'))
def colors(path):
im = image(path)
return {name: {'pixels': int(mask.sum()), 'box': bbox(mask)}
for name, color in COLORS.items()
for mask in [np.all(im == color, axis=2)]}
def damage_coverage(run, a, b, output, damages):
before, after = image(run / a['path']), image(run / b['path'])
changed = np.any(before != after, axis=2)
yy, xx = np.where(changed)
width, height = (750, 1000) if output == 'HEADLESS-1' else (800, 600)
mask = np.zeros((height, width), dtype=bool)
extents = [d for d in damages if d[1] == output and
a['after_ns'] < d[0] <= b['after_ns']]
for _, _, x1, y1, x2, y2 in extents:
mask[max(0, y1):min(height, y2), max(0, x1):min(width, x2)] = True
# WHY: grim H1 is logical-oriented; inherited damage extents already use
# physical buffer-local coordinates after SceneFX inverse rotation.
# CONTRACT: measured static R90 maps PNG (x,y) to buffer (749-y,x).
px, py = (width - 1 - yy, xx) if output == 'HEADLESS-1' else (xx, yy)
return {'from': a['tag'], 'to': b['tag'], 'output': output,
'changed_png_pixels': int(len(xx)), 'changed_box_png': bbox(changed),
'physical_extents_count': len(extents),
'uncovered_changed_pixels': int(np.count_nonzero(~mask[py, px])),
'physical_extents': [list(d[2:]) for d in extents],
'scope': 'settled PNG pair only; no intermediate-frame claim'}
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
args = parser.parse_args()
lab = args.lab.resolve()
out = lab / 'results'
if out.exists():
raise SystemExit('sealed results exist; refusing overwrite')
runs = sorted((lab / 'runs').glob('*-static-r90-*'))
if len(runs) != 3:
raise SystemExit('exactly three retained runs required')
budget = json.loads((lab / 'partial-campaign-budget.json').read_text())
if sorted(budget['runs']) != ['control', 'private_mosaic', 'private_standard']:
raise SystemExit('campaign ledger incomplete')
outputs, geometry, intersections, grabs, clips = {}, {}, {}, {}, {}
hits, delivery, pixels, damages_out, matrix, summary_runs = {}, {}, {}, {}, {}, {}
source_paths = []
for tree in (lab / 'mosaic', lab / 'scenefx', lab / 'standard'):
source_paths.extend(p for p in tree.rglob('*') if p.is_file())
source_paths.extend(p for p in lab.glob('*.c'))
source_paths.extend(p for p in lab.glob('*.h'))
source_paths.extend(p for p in Path(__file__).parent.glob('*') if p.is_file())
for run in runs:
mode = run.name.split('-static-r90-', 1)[0]
events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()]
by_kind = lambda kind: [e for e in events if e['kind'] == kind]
geom = by_kind('applied_popup_geometry')[-1]
gate = by_kind('geometry_gate')[-1]
client = (run / 'client.log').read_text(errors='replace')
log = (run / 'compositor.log').read_text(errors='replace')
traces = []
for line in log.splitlines():
m = TRACE.search(line)
if m:
item = {'kind': m[1], **fields(line)}
if item.get('ns', '').isdigit():
item['ns'] = int(item['ns'])
traces.append(item)
damage = [(int(ns), output, *map(int, (x1, y1, x2, y2)))
for ns, output, x1, y1, x2, y2 in DAMAGE.findall(log)]
captures = {(e['tag'], e['output']): e for e in by_kind('capture')}
capture_geometry = {(e['tag'], e['output']): e for e in by_kind('capture_geometry')}
outputs[mode] = {'states': by_kind('state'), 'capture_geometry': by_kind('capture_geometry'),
'markers': by_kind('marker_calibration'),
'pointer_calibration': by_kind('pointer_calibration')}
geometry[mode] = geom
intersections[mode] = {k: geom[k] for k in ('a_h1', 'a_h2', 'b_h1', 'b_h2')}
parsed_grabs = [{'popup': p, 'target': t, 'serial': int(s), 'used': int(u),
'real_button': bool(re.search(rf'POINTER_BUTTON target={t} serial={s} button=272 state=1', client))}
for p, t, s, u in re.findall(
r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\S+) serial=(\d+) used=(\d+)', client)]
grabs[mode] = {'requests': parsed_grabs,
'role_configures': re.findall(r'(POPUP_[AB]) ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)', client),
'xdg_configure_acks': re.findall(r'(POPUP_[AB]) XDG_CONFIGURE serial=(\d+) ack', client),
'maps': re.findall(r'(POPUP_[AB]) MAP wl=(\d+) xdg=(\d+)', client),
'protocol_errors': [line for line in client.splitlines() if 'protocol error' in line.lower()]}
capture = captures[('rot-cross-S2-B', 'HEADLESS-1')]
before = capture['before_ns']
clips[mode] = {'output_state': latest(traces, 'DO_STATE', before, observer='HEADLESS-1'),
'tiled': latest(traces, 'PI_SCENE', before, output='HEADLESS-1'),
'root_popup': latest(traces, 'DO_POPUP', before, owner_id=str(geom['owner_id'])),
'owner': latest(traces, 'PI_OWNER', before, id=str(geom['owner_id'])),
'native_commits': sum(t['kind'] == 'PI_OUTPUT_COMMIT' for t in traces),
'native_presents': sum(t['kind'] == 'PI_OUTPUT_PRESENT' for t in traces)}
hits[mode] = {'border_hovers': by_kind('border_hover'),
'visible_b_button': by_kind('visible_b_button'),
'forbidden_b_button': by_kind('forbidden_b_button')}
delivery[mode] = {'client_grabs': grabs[mode],
'visible_b_events': by_kind('visible_b_button')[-1]['client_events'],
'forbidden_b_events': by_kind('forbidden_b_button')[-1]['client_events'],
'popup_done_A': client.count('POPUP_A POPUP_DONE'),
'popup_done_B': client.count('POPUP_B POPUP_DONE'),
'client_log': str(run / 'client.log')}
pair = {}
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
a, b = captures[('reference', output)], captures[('rot-cross-S2-B', output)]
before_im, popup_im = image(run / a['path']), image(run / b['path'])
diff = np.any(before_im != popup_im, axis=2)
pair[output] = {'reference': {'path': a['path'], 'sha256': digest(run / a['path']),
'window_ns': [a['before_ns'], a['after_ns']]},
'popup': {'path': b['path'], 'sha256': digest(run / b['path']),
'window_ns': [b['before_ns'], b['after_ns']]},
'png_dimensions': list(popup_im.shape[1::-1]),
'output': capture_geometry[('rot-cross-S2-B', output)],
'reference_diff_pixels': int(diff.sum()), 'reference_diff_box': bbox(diff),
'colors': colors(run / b['path'])}
pixels[mode] = pair
damages_out[mode] = [damage_coverage(run, captures[('reference', output)],
captures[('rot-cross-S2-B', output)], output, damage)
for output in ('HEADLESS-1', 'HEADLESS-2')]
status = json.loads((run / 'exit-status.json').read_text())
matrix[mode] = {'run_failure': status['failure'], 'captures': status['captures'],
'duration_seconds': status['duration_seconds'],
'geometry_gate': gate['result'], 'B_box': geom['b_box'],
'A_box': geom['a_box'], 'grab_count': len(parsed_grabs),
'H1_B_pixels': pair['HEADLESS-1']['colors']['B']['pixels'],
'H2_B_pixels': pair['HEADLESS-2']['colors']['B']['pixels'],
'H2_full_reference_diff': pair['HEADLESS-2']['reference_diff_pixels']}
summary_runs[mode] = {'run': str(run), 'exit': status}
if not all(matrix[m]['geometry_gate'] == 'CROSSING' for m in matrix):
verdict = 'INCONCLUSIVE'
elif matrix['private_mosaic']['H2_full_reference_diff'] or matrix['private_mosaic']['H2_B_pixels']:
verdict = 'BLOCKED'
elif any(matrix[m]['run_failure'] for m in matrix):
verdict = 'INCONCLUSIVE'
else:
# P2 root-popup input was not independently clicked in the one-chain run.
verdict = 'PARTIALLY_FEASIBLE'
out.mkdir()
write(out / 'geometry.json', geometry)
write(out / 'popup-positions.json', {m: {'A': x['a_box'], 'B': x['b_box']} for m, x in geometry.items()})
write(out / 'popup-intersections.json', intersections)
write(out / 'output-transforms.json', outputs)
write(out / 'client-grabs.json', grabs)
write(out / 'scene-clips.json', clips)
write(out / 'pointer-hits.json', hits)
write(out / 'client-delivery.json', delivery)
write(out / 'pixel-differences.json', pixels)
write(out / 'damage.json', damages_out)
write(out / 'budget.json', budget)
write(out / 'matrix.json', matrix)
write(out / 'summary.json', {'result': verdict, 'architectural_conclusion':
'ROTATED_PARTIAL_POPUP_CLIP_FEASIBLE_FOR_TESTED_MATRIX'
if verdict in ('FEASIBLE', 'PARTIALLY_FEASIBLE') else 'UNDECIDED',
'runs': summary_runs, 'unexercised': ['separate A input and exterior hit',
'raw framebuffer', 'intermediate frames', 'saved buffers',
'optimized blur cache', 'inner shadow', 'ABI/package']})
write(out / 'source-provenance.json', {'pinned_swayfx': 'fd71a6bdc061bd633b488ae7b83e8a6981d22f86',
'mosaic_private_base': 'cd550382b311efdbfe3ed892f4e1d281d852e754',
'scenefx': '37ccd723bef49e6891156ffafce8f549f01446cc',
'client_base_sha256': '2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d',
'client_patch_sha256': digest(Path(__file__).parent / 'rot-cross-client.patch')})
(out / 'source-sha256.txt').write_text(''.join(f'{digest(p)} {p.resolve()}\n' for p in sorted(set(source_paths))))
evidence = [p for p in sorted(run_file for run in runs for run_file in run.rglob('*') if run_file.is_file())]
evidence += [lab / 'partial-campaign-budget.json', lab / 'partial-build-log.txt']
evidence += [p for p in out.iterdir() if p.is_file() and p.name != 'evidence-sha256.txt']
(out / 'evidence-sha256.txt').write_text(''.join(f'{digest(p)} {p.resolve()}\n' for p in sorted(set(evidence))))
print(f'RESULT={verdict} LAB={lab} SOURCES={len(set(source_paths))} EVIDENCE={len(set(evidence))}')

View file

@ -0,0 +1,45 @@
"""Persistent one-run-per-mode budget for the independent static-R90 campaign."""
import fcntl
import json
import os
from pathlib import Path
MODES = ('control', 'private_standard', 'private_mosaic')
class Budget:
def __init__(self, lab):
self.lab = Path(lab)
self.lock = (self.lab / 'partial-campaign.lock').open('a')
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
self.path = self.lab / 'partial-campaign-budget.json'
def read(self):
if not self.path.exists():
if (self.lab / 'runs').exists() and any((self.lab / 'runs').iterdir()):
raise ValueError('missing ledger with retained run')
return {'version': 1, 'runs': []}
state = json.loads(self.path.read_text())
runs = state.get('runs')
if state.get('version') != 1 or not isinstance(runs, list) or len(runs) != len(set(runs)) or any(r not in MODES for r in runs):
raise ValueError('invalid ledger')
return state
def reserve(self, mode):
state = self.read()
if mode not in MODES or mode in state['runs'] or len(state['runs']) >= 3:
raise ValueError('run budget exhausted or duplicate mode')
state['runs'].append(mode)
tmp = self.path.with_suffix('.tmp')
with tmp.open('w') as output:
json.dump(state, output, indent=2)
output.write('\n')
output.flush()
os.fsync(output.fileno())
tmp.replace(self.path)
fd = os.open(self.lab, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
return state

View file

@ -0,0 +1,23 @@
#!/bin/sh
# EXPERIMENTAL source-only preparation; never launches a compositor.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
fixture="$repo/tooling/probes/step20c-rotated-partial-popup"
base="$repo/tooling/probes/step20c-nested-grab/nested-client.c"
test "$(sha256sum "$base" | cut -d ' ' -f 1)" = 2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d
log=$(mktemp /tmp/rot-partial-prepare-XXXXXXXX)
if ! sh "$repo/tooling/probes/step20c-dynamic-output/prepare.sh" > "$log" 2>&1; then
printf 'BUILD_FAILED log=%s\n' "$log" >&2
tail -40 "$log" >&2
exit 1
fi
lab=$(sed -n 's/^LAB=//p' "$log")
test -n "$lab" && test -d "$lab"
cp "$base" "$lab/nested-client.c"
patch -d "$lab" -p1 --dry-run < "$fixture/rot-cross-client.patch" >> "$log" 2>&1
patch -d "$lab" -p1 < "$fixture/rot-cross-client.patch" >> "$log" 2>&1
cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \
"$lab/nested-client.c" "$lab/xdg-shell-protocol.c" -o "$lab/nested-client" \
$(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1
cp "$log" "$lab/partial-build-log.txt"
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log"

View file

@ -0,0 +1,13 @@
--- a/nested-client.c
+++ b/nested-client.c
@@ -211,6 +211,10 @@
} else if (!strcmp(placement,"both_cross")) {
a.anchor_x=350; a.anchor_y=80; a.width=350;
b.anchor_x=160; b.anchor_y=60; b.width=300;
+ } else if (!strcmp(placement,"rot_cross")) {
+ /* CONTRACT: both 1x1 anchors stay inside their parent buffers. */
+ a.anchor_x=360; a.anchor_y=80; a.width=480;
+ b.anchor_x=210; b.anchor_y=60; b.width=300;
} else if (!strcmp(placement,"b_cross")) {
a.anchor_x=200; a.anchor_y=80; a.width=300;
b.anchor_x=220; b.anchor_y=60; b.width=300;

View file

@ -0,0 +1,538 @@
"""EXPERIMENTAL applied-state/input headless campaign. See README."""
import argparse
from PIL import Image, ImageDraw
import sys
from budget import Budget
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic'))
parser.add_argument('--cycles', type=int, default=3)
parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0)
parser.add_argument('--test-hold', action='store_true')
parser.add_argument('--test-timeout', action='store_true')
parser.add_argument('--moves', type=int, default=18)
parser.add_argument('--max-seconds', type=int, default=120)
parser.add_argument('--max-captures', type=int, default=16)
parser.add_argument('--max-bytes', type=int, default=30_000_000)
args = parser.parse_args()
if not (1 <= args.max_seconds <= 120 and 1 <= args.max_captures <= 16 and
1 <= args.max_bytes <= 30_000_000):
parser.error('static R90 run bounds exceeded')
if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40:
parser.error('cycle/move bounds exceeded')
lab = args.lab.resolve()
if not (lab / 'nested-client').is_file():
parser.error('prepared LAB required')
budget = Budget(lab)
budget.reserve(args.mode)
run = lab / 'runs' / f'{args.mode}-static-r90-{time.time_ns()}'
run.mkdir(parents=True, mode=0o700)
(run / 'fixture-source.py').write_text(Path(__file__).read_text())
# WHY: five asymmetric markers identify whether PNG uses logical or buffer axes.
# CONTRACT: predeclared logical centers and distinct exact RGB codes.
marker_path = run / 'marker.png'
marker = Image.new('RGB', (800, 600), (30, 30, 46))
draw = ImageDraw.Draw(marker)
for name, box, color in (
('TL', (15, 15, 44, 44), (255, 32, 32)),
('TR', (755, 15, 784, 44), (32, 255, 32)),
('BL', (15, 555, 44, 584), (32, 32, 255)),
('BR', (755, 555, 784, 584), (255, 255, 32)),
('C', (385, 285, 414, 314), (192, 32, 255))):
draw.rectangle(box, fill=color)
marker.save(marker_path)
runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp'))
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
raise RuntimeError('IPC socket path exceeds sockaddr_un')
(run / 'runtime-path').write_text(str(runtime) + '\n')
(run / 'config').write_text(
'output HEADLESS-1 resolution 750x1000 scale 1.25 transform 90 position -800 0\n'
'output HEADLESS-2 resolution 800x600 position 0 0\n'
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
'focus_follows_mouse no\n')
(run / 'config').write_text((run / 'config').read_text() + f'output * bg {marker_path} fill\n')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'):
env.pop(key, None)
compositor_env = env.copy()
compositor_env['ASI_MODE'] = str(args.asi_mode)
if args.test_hold or args.test_timeout:
compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1'
readback_dir = None
if args.asi_mode:
readback_dir = lab / 'buffers' / run.name
readback_dir.mkdir(parents=True, mode=0o700)
compositor_env['BI_OUTPUT_DIR'] = str(readback_dir)
(run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode,
'readback_dir': str(readback_dir) if readback_dir else None}) + '\n')
binary = lab / 'standard-build/sway/sway' if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic'
if args.mode != 'control':
compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build')
files, children, events = [], [], []
started = time.monotonic()
capture_count = 0
capture_bytes = 0
def record(kind, **fields):
entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields}
events.append(entry)
with (run / 'events.jsonl').open('a') as out:
out.write(json.dumps(entry) + '\n')
return entry
def spawn(argv, log_name, *, stdin=False, debug=False):
log = (run / log_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=subprocess.PIPE if stdin else None,
stdout=log, stderr=subprocess.STDOUT,
start_new_session=True, text=stdin)
children.append(proc)
return proc
def wait(predicate, seconds=12):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if time.monotonic() - started > args.max_seconds:
raise RuntimeError('run duration bound exceeded')
result = predicate()
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'pointer exited {pointer.returncode}')
if 'client' in globals() and client.poll() is not None:
raise RuntimeError(f'client exited {client.returncode}')
time.sleep(.04)
raise RuntimeError('timeout waiting for event')
def ipc(*argv):
completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env,
text=True, capture_output=True, check=True, timeout=8)
result = json.loads(completed.stdout)
record('ipc', argv=list(argv), result=result)
return result
def command(value):
result = ipc(value)
if not isinstance(result, list) or not result or not all(x.get('success') for x in result):
raise RuntimeError(f'IPC command rejected: {value}: {result}')
def find(node, predicate):
if isinstance(node, dict):
if predicate(node):
return node
for child in node.get('nodes', []) + node.get('floating_nodes', []):
found = find(child, predicate)
if found:
return found
return None
def tree():
return ipc('-t', 'get_tree')
def workspace():
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 client_text():
return (run / 'client.log').read_text()
def count(marker):
return client_text().count(marker)
def configured(name):
matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)',
client_text())
if not matches:
raise RuntimeError(f'no {name} role configure')
return tuple(map(int, matches[-1]))
def snapshot(tag):
global capture_count, capture_bytes
t = tree()
outputs = ipc('-t', 'get_outputs')
(run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n')
(run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n')
expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}
actual = {o['name']: tuple(o['rect'][k] for k in ('x','y','width','height')) for o in outputs if o['active']}
rotated = True
h1 = next(o for o in outputs if o['name'] == 'HEADLESS-1')
h2 = next(o for o in outputs if o['name'] == 'HEADLESS-2')
wanted_mode = (750, 1000) if rotated else (1000, 750)
if (actual != expected or h1['scale'] != 1.25 or h2['scale'] != 1 or
h2['transform'] != 'normal' or h1['transform'] != ('90' if rotated else 'normal') or
(h1['current_mode']['width'], h1['current_mode']['height']) != wanted_mode):
raise RuntimeError(f'applied rotation geometry gate failed: {actual} {h1}')
record('state', tag=tag, workspaces=ipc('-t','get_workspaces'), workspace=workspace(), owner=owner(), outputs=outputs)
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
if capture_count >= args.max_captures:
raise RuntimeError('capture count bound exceeded')
path = run / f'{side}-{tag}.png'
before = time.monotonic_ns()
subprocess.run(['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)], env=env, check=True,
stdout=subprocess.DEVNULL, timeout=10)
after = time.monotonic_ns()
capture_count += 1
capture_bytes += path.stat().st_size
if capture_bytes > args.max_bytes:
raise RuntimeError('capture byte bound exceeded')
out = next(o for o in outputs if o['name']==output)
dimensions = list(Image.open(path).size)
record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode'], physical_expected=[out['current_mode']['width'],out['current_mode']['height']])
record('capture', tag=tag, output=output, path=path.name,
before_ns=before, after_ns=after, size=path.stat().st_size)
def inject(action, x, y):
outputs = [o for o in ipc('-t','get_outputs') if o['active']]
min_x = min(o['rect']['x'] for o in outputs)
min_y = min(o['rect']['y'] for o in outputs)
max_x = max(o['rect']['x']+o['rect']['width'] for o in outputs)
max_y = max(o['rect']['y']+o['rect']['height'] for o in outputs)
if not any(o['rect']['x']<=x<o['rect']['x']+o['rect']['width'] and o['rect']['y']<=y<o['rect']['y']+o['rect']['height'] for o in outputs):
raise ValueError('global point is outside applied outputs')
sent_x,sent_y = int(x-min_x),int(y-min_y)
width,height = max_x-min_x,max_y-min_y
marker=f'INJECT_DONE {action} {sent_x},{sent_y}'
before=(run/'pointer.log').read_text().count(marker)
record('pointer_request',action=action,global_point=[x,y],sent=[sent_x,sent_y],extent=[width,height],minimum=[min_x,min_y])
pointer.stdin.write(f'{action} {sent_x} {sent_y} {width} {height}\n');pointer.stdin.flush()
wait(lambda: (run/'pointer.log').read_text().count(marker)>before)
def client_command(value):
marker = f'COMMAND {value} DONE'
before = count(marker)
record('client_request', value=value)
client.stdin.write(value + '\n')
client.stdin.flush()
wait(lambda: count(marker) > before)
def scroll(target):
current = workspace()['mosaic']['rows'][0]['scroll_x']
command(f'layout mosaic scroll {target-current:g}')
wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target)
record('camera_observed', target=target, owner_id=owner()['id'])
def open_chain(cycle):
a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100)
inject('move', top_x, 150)
inject('left', top_x, 150)
wait(lambda: count('POPUP_A MAP') > a_before)
snapshot(f'{cycle}-S1-A')
a_cfg = configured('POPUP_A')
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35)
inject('move', a_x, a_y)
inject('left', a_x, a_y)
wait(lambda: count('POPUP_B MAP') > b_before)
if readback_dir:
(readback_dir / 'enable').touch()
snapshot(f'{cycle}-S2-B')
b_cfg = configured('POPUP_B')
b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35)
b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30)
inject('move', b_x, b_y)
inject('right', b_x, b_y)
time.sleep(.12)
snapshot(f'{cycle}-S3-visible')
record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x,
a_configure=a_cfg, b_configure=b_cfg,
top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y])
def cycle_once(number):
label = f'cycle-{number:03d}'
record('cycle_start', cycle=number)
snapshot(f'{label}-S0-reference')
open_chain(label)
if args.mode == 'private_mosaic':
scroll(1280)
snapshot(f'{label}-S4-hidden-immediate')
time.sleep(.18)
snapshot(f'{label}-S4-hidden-stable')
inject('move', -700, 190)
time.sleep(.1)
snapshot(f'{label}-S4-hidden-hover')
scroll(400)
snapshot(f'{label}-S5-return-immediate')
time.sleep(.18)
snapshot(f'{label}-S5-return-stable')
client_command('close_b')
snapshot(f'{label}-S6-close-B-immediate')
time.sleep(.18)
snapshot(f'{label}-S6-close-B-stable')
a_cfg = configured('POPUP_A')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20)
before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text()))
inject('move', a_x, a_y)
inject('right', a_x, a_y)
wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1',
client_text())) > before_a)
snapshot(f'{label}-S6-A-interaction')
client_command('close_a')
snapshot(f'{label}-S7-close-A-immediate')
time.sleep(.18)
snapshot(f'{label}-S7-close-A-stable')
record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None)
compositor_log = (run / 'compositor.log').open('w')
files.append(compositor_log)
compositor = None
failure = 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)
time.sleep(1.0)
snapshot('startup')
pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True)
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
command('workspace 1')
command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith')
spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0',
'-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log')
wait(lambda: len(workspace()['nodes']) >= 1)
client = spawn([str(lab / 'nested-client'), 'rot_cross'], 'client.log', stdin=True, debug=True)
wait(lambda: owner() and 'TOPLEVEL MAP' in client_text())
for name in ('C', 'D'):
spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0',
'/usr/bin/sleep', '900'], f'{name}.log')
time.sleep(.25)
command('workspace 2')
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev'))
command('workspace 1')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
if args.mode == 'private_mosaic':
scroll(640)
record('initial_camera', value=640)
time.sleep(.25)
# EXPERIMENTAL fragment inserted inside the inherited bounded runner's try.
# WHY: persistence observations precede reference clicks that can dismiss grabs.
# CONTRACT: four reserved dynamic actions per mode; no additional camera pair.
dynamic_stage = 'D0'
initial_ws_id = workspace()['id']
initial_owner_id = owner()['id']
ref_right = find(tree(), lambda n: n.get('app_id') == 'wev')['id']
command('workspace 1')
left_proc = spawn(['stdbuf','-oL','wev'], 'wev-left.log')
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right))
ref_left = find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)['id']
command('shadows disable'); command('blur disable')
def closed():
client_command('close_a')
time.sleep(.12)
def calibrate(tag):
outputs = {o['name']: o for o in ipc('-t','get_outputs') if o['active']}
active = {w['output']:w for w in ipc('-t','get_workspaces') if w['visible']}
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
name = active[out]['name']
command(f'[con_id={con_id}] move container to workspace {name}')
command(f'[con_id={con_id}] floating enable')
command(f'[con_id={con_id}] border none')
command(f'[con_id={con_id}] resize set 200 150')
ox=outputs[out]['rect']['x']+50; oy=400
command(f'[con_id={con_id}] move absolute position {ox} {oy}')
time.sleep(.25)
# WHY: after return both references can temporarily share workspace 1.
# CONTRACT: relocate BOTH before scoring input, avoiding mutual occlusion.
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
ox=outputs[out]['rect']['x']+50; oy=400
text=lambda: (run/logname).read_text()
# Force a different surface, then accept actual enter or motion.
inject('move', outputs['HEADLESS-2' if out=='HEADLESS-1' else 'HEADLESS-1']['rect']['x']+10,10)
before=len(text());inject('move',ox+20,oy+30)
pattern=r'(?:enter:[^\n]*|motion:[^\n]*)x, y: ([\d.]+), ([\d.]+)'
wait(lambda: re.search(pattern,text()[before:]))
ex,ey=map(float,re.findall(pattern,text()[before:])[-1])
if ex != 20 or not 0 <= ey < 100:
raise RuntimeError(f'calibration interior mismatch {tag} {out}: {ex},{ey}')
before=len(text());inject('move',ox+60,oy+50)
wait(lambda: f'x, y: {ex+40:.6f}, {ey+20:.6f}' in text()[before:])
before=len(text())
inject('left',ox+60,oy+50);inject('right',ox+60,oy+50)
wait(lambda: 'button: 272' in text()[before:] and 'button: 273' in text()[before:])
record('pointer_calibration',tag=tag,output=out,con_id=con_id,global_point=[ox+60,oy+50],local_expected=[ex+40,ey+20],surface_origin=[ox+20-ex,oy+30-ey],passed=True)
for x in (-2,-1,0,1,2):inject('move',x,550)
inject('move',outputs['HEADLESS-1']['rect']['x']+110,450)
command('workspace 1')
def stable(tag, expected_owner):
# CONTRACT: repeated geometry and paired settled captures are evidence,
# not proof that intermediate configurations each had a presentation.
time.sleep(.35)
before=ipc('-t','get_outputs')
time.sleep(.12)
after=ipc('-t','get_outputs')
if before != after:
raise RuntimeError(f'unstable output IPC at {tag}')
ws=workspace();o=owner()
current=next(w for w in ipc('-t','get_workspaces') if w['id']==initial_ws_id)
if ws['id']!=initial_ws_id or o['id']!=initial_owner_id or current['output']!=expected_owner:
raise RuntimeError(f'workspace/owner identity mismatch at {tag}')
snapshot(tag)
time.sleep(.16);snapshot(tag+'-repeat')
record('identity',tag=tag,workspace_id=ws['id'],owner_id=o['id'],output=current['output'],layout=ws['layout'],order=[n['id'] for n in ws['nodes']],mosaic=ws.get('mosaic'),focused=current['focused'])
def owner_hit(tag):
o=owner();ws=workspace()
scroll_x=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
point=[int(o['rect']['x']-scroll_x+o['window_rect']['x']+100),150]
before=len(client_text());inject('move',*point);inject('right',*point)
delivered='POINTER_BUTTON target=TOPLEVEL' in client_text()[before:]
record('owner_hit',tag=tag,point=point,delivered=delivered,events=client_text()[before:])
if not delivered:raise RuntimeError(f'visible owner input failed {tag}')
# WHY: exact colors identify PNG orientation before interpreting output pixels.
# CONTRACT: marker positions/colors were fixed before the first transition.
colors = {'TL':(255,32,32),'TR':(32,255,32),'BL':(32,32,255),
'BR':(255,255,32),'C':(192,32,255)}
def marker_gate(tag, rotated):
im = Image.open(run / f'left-{tag}.png').convert('RGB')
points = {name: [] for name in colors}
reverse = {v:k for k,v in colors.items()}
for y in range(im.height):
for x in range(im.width):
name = reverse.get(im.getpixel((x,y)))
if name: points[name].append((x,y))
centers = {}
for name, positions in points.items():
if len(positions) < 100:
raise RuntimeError(f'{tag}: marker {name} absent ({len(positions)})')
centers[name] = [sum(p[0] for p in positions)/len(positions),
sum(p[1] for p in positions)/len(positions)]
logical = (im.size == (1000,750) and
centers['TL'][0] < centers['TR'][0] and
centers['TL'][1] < centers['BL'][1])
physical = (im.size == (750,1000) and
centers['TL'][0] > centers['BL'][0] and
centers['TL'][1] < centers['TR'][1])
frame = 'logical-oriented PNG' if logical else 'physical-buffer-oriented PNG' if physical else 'unknown'
record('marker_calibration', tag=tag, dimensions=list(im.size),
centers=centers, counts={k:len(v) for k,v in points.items()},
png_frame=frame)
if (rotated and not (logical or physical)) or (not rotated and not logical):
raise RuntimeError(f'{tag}: PNG frame not established')
return frame
# CONTRACT: static R90 from compositor startup; no transform command.
marker_gate('startup', True)
calibrate('static-R90')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
snapshot('reference')
owner_hit('static-R90')
open_chain('rot-cross')
o=owner(); ws=workspace(); a_cfg=configured('POPUP_A'); b_cfg=configured('POPUP_B')
sx=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
base_x=o['rect']['x']-sx+o['window_rect']['x']
base_y=o['rect']['y']+o['window_rect']['y']
a_box=[base_x+a_cfg[0],base_y+a_cfg[1],base_x+a_cfg[0]+a_cfg[2],base_y+a_cfg[1]+a_cfg[3]]
b_box=[a_box[0]+b_cfg[0],a_box[1]+b_cfg[1],a_box[0]+b_cfg[0]+b_cfg[2],a_box[1]+b_cfg[1]+b_cfg[3]]
record('applied_popup_geometry',owner_id=o['id'], owner_rect=o['rect'],window_rect=o['window_rect'],
camera=sx,a_configure=a_cfg,b_configure=b_cfg,a_box=a_box,b_box=b_box,
a_h1=[max(-800,a_box[0]),a_box[1],min(0,a_box[2]),a_box[3]],
a_h2=[max(0,a_box[0]),a_box[1],min(800,a_box[2]),a_box[3]],
b_h1=[max(-800,b_box[0]),b_box[1],min(0,b_box[2]),b_box[3]],
b_h2=[max(0,b_box[0]),b_box[1],min(800,b_box[2]),b_box[3]])
if not (b_box[0] < 0 < b_box[2] and b_box[2]>=16):
record('geometry_gate',result='NOT EXERCISED',box=b_box)
else:
record('geometry_gate',result='CROSSING',box=b_box)
# WHY: evaluate a shared y within B and scan both sides of the boundary.
y=int((max(0,b_box[1])+min(600,b_box[3]))/2)
for x in range(-3,4):
before=len(client_text()); inject('move',x,y)
record('border_hover',point=[x,y],client_events=client_text()[before:])
inside_x=max(-30,int((max(-800,b_box[0])+min(0,b_box[2]))/2))
before=len(client_text()); inject('move',inside_x,y); inject('right',inside_x,y)
record('visible_b_button',point=[inside_x,y],client_events=client_text()[before:])
snapshot('after-visible-button')
# A left/right roles may dismiss on exterior click. Preserve it last.
if b_box[2]>0:
outside_x=min(30,int(b_box[2]/2))
before=len(client_text()); inject('move',outside_x,y); inject('right',outside_x,y)
record('forbidden_b_button',point=[outside_x,y],client_events=client_text()[before:])
time.sleep(.15)
snapshot('after-forbidden-button')
except Exception as exc:
failure = repr(exc)
record('failure', error=failure)
finally:
for proc in children + ([compositor] if compositor is not None else []):
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
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({
'failure': failure, 'compositor': None if compositor is None else compositor.returncode,
'children': [{'argv': p.args, 'returncode': p.returncode} for p in children],
'captures': capture_count, 'capture_bytes': capture_bytes,
'duration_seconds': time.monotonic() - started}, indent=2) + '\n')
for file in files:
file.close()
shutil.rmtree(runtime)
record('cleanup', runtime_removed=not runtime.exists())
print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}')
if failure:
raise SystemExit(1)

View file

@ -0,0 +1,40 @@
"""Offline ledger tests; no compositor or campaign reservation."""
from pathlib import Path
import tempfile
from budget import Budget
with tempfile.TemporaryDirectory() as directory:
lab = Path(directory)
budget = Budget(lab)
assert budget.reserve('control')['runs'] == ['control']
try:
budget.reserve('control')
except ValueError:
pass
else:
raise AssertionError('duplicate accepted')
assert budget.reserve('private_standard')['runs'][-1] == 'private_standard'
assert budget.reserve('private_mosaic')['runs'][-1] == 'private_mosaic'
try:
budget.reserve('control')
except ValueError:
pass
else:
raise AssertionError('exhaustion bypass')
try:
Budget(lab)
except BlockingIOError:
pass
else:
raise AssertionError('lock bypass')
with tempfile.TemporaryDirectory() as directory:
lab = Path(directory)
(lab / 'runs').mkdir()
(lab / 'runs' / 'failed').mkdir()
try:
Budget(lab).read()
except ValueError:
pass
else:
raise AssertionError('missing ledger accepted')
print('PASS persistence, duplicate, exhaustion, lock, missing-ledger')