diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cd54d..abdbb1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 75bed48..d9b54d6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index f4f0e50..232ae64 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -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**. diff --git a/docs/README.md b/docs/README.md index 151a137..9ade9cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b26bad8..f233496 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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**. diff --git a/docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md b/docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md new file mode 100644 index 0000000..9f621e5 --- /dev/null +++ b/docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md @@ -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. diff --git a/tooling/probes/step20c-rotated-partial-popup/README.md b/tooling/probes/step20c-rotated-partial-popup/README.md new file mode 100644 index 0000000..bb8fef3 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/README.md @@ -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. diff --git a/tooling/probes/step20c-rotated-partial-popup/__pycache__/analyze.cpython-314.pyc b/tooling/probes/step20c-rotated-partial-popup/__pycache__/analyze.cpython-314.pyc new file mode 100644 index 0000000..88796c5 Binary files /dev/null and b/tooling/probes/step20c-rotated-partial-popup/__pycache__/analyze.cpython-314.pyc differ diff --git a/tooling/probes/step20c-rotated-partial-popup/__pycache__/budget.cpython-314.pyc b/tooling/probes/step20c-rotated-partial-popup/__pycache__/budget.cpython-314.pyc new file mode 100644 index 0000000..a5bf278 Binary files /dev/null and b/tooling/probes/step20c-rotated-partial-popup/__pycache__/budget.cpython-314.pyc differ diff --git a/tooling/probes/step20c-rotated-partial-popup/__pycache__/run.cpython-314.pyc b/tooling/probes/step20c-rotated-partial-popup/__pycache__/run.cpython-314.pyc new file mode 100644 index 0000000..fa00325 Binary files /dev/null and b/tooling/probes/step20c-rotated-partial-popup/__pycache__/run.cpython-314.pyc differ diff --git a/tooling/probes/step20c-rotated-partial-popup/__pycache__/test-budget.cpython-314.pyc b/tooling/probes/step20c-rotated-partial-popup/__pycache__/test-budget.cpython-314.pyc new file mode 100644 index 0000000..79393fa Binary files /dev/null and b/tooling/probes/step20c-rotated-partial-popup/__pycache__/test-budget.cpython-314.pyc differ diff --git a/tooling/probes/step20c-rotated-partial-popup/analyze.py b/tooling/probes/step20c-rotated-partial-popup/analyze.py new file mode 100644 index 0000000..71c30c4 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/analyze.py @@ -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))}') diff --git a/tooling/probes/step20c-rotated-partial-popup/budget.py b/tooling/probes/step20c-rotated-partial-popup/budget.py new file mode 100644 index 0000000..f9f3531 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/budget.py @@ -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 diff --git a/tooling/probes/step20c-rotated-partial-popup/prepare.sh b/tooling/probes/step20c-rotated-partial-popup/prepare.sh new file mode 100644 index 0000000..598429c --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/prepare.sh @@ -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" diff --git a/tooling/probes/step20c-rotated-partial-popup/rot-cross-client.patch b/tooling/probes/step20c-rotated-partial-popup/rot-cross-client.patch new file mode 100644 index 0000000..f31acb4 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/rot-cross-client.patch @@ -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; diff --git a/tooling/probes/step20c-rotated-partial-popup/run.py b/tooling/probes/step20c-rotated-partial-popup/run.py new file mode 100644 index 0000000..ea83845 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/run.py @@ -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']<=xbefore) + + +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) diff --git a/tooling/probes/step20c-rotated-partial-popup/test-budget.py b/tooling/probes/step20c-rotated-partial-popup/test-budget.py new file mode 100644 index 0000000..6f87aa9 --- /dev/null +++ b/tooling/probes/step20c-rotated-partial-popup/test-budget.py @@ -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')