From f7a94eb5fb6a11d836ce6c190729a69a450ce6af Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 12 Oct 2026 16:32:53 +0200 Subject: [PATCH] docs(mosaic): preserve rotated output probe evidence --- CHANGELOG.md | 2 + docs/ARCHITECTURE.md | 9 + docs/KNOWN_LIMITATIONS.md | 9 + docs/README.md | 8 + docs/ROADMAP.md | 8 + docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md | 431 ++++++++++++++++++ .../probes/step20c-rotated-output/README.md | 55 +++ .../probes/step20c-rotated-output/analyze.py | 262 +++++++++++ .../probes/step20c-rotated-output/campaign.py | 88 ++++ tooling/probes/step20c-rotated-output/run.py | 95 ++++ .../probes/step20c-rotated-output/scenario.py | 94 ++++ .../step20c-rotated-output/test-budget.py | 40 ++ 12 files changed, 1101 insertions(+) create mode 100644 docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md create mode 100644 tooling/probes/step20c-rotated-output/README.md create mode 100644 tooling/probes/step20c-rotated-output/analyze.py create mode 100644 tooling/probes/step20c-rotated-output/campaign.py create mode 100644 tooling/probes/step20c-rotated-output/run.py create mode 100644 tooling/probes/step20c-rotated-output/scenario.py create mode 100644 tooling/probes/step20c-rotated-output/test-budget.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa7502..91cd54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### STEP20C experimental evidence +- 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. - Preserved fractional-scale probe: actual 1.25 uniform/mixed and 1.5 mixed outputs, calibrated logical input, sampled root/nested popup and effects clipping, two exact camera returns and physical settled-damage coverage. PARTIALLY_FEASIBLE; opaque tiled controls, interior shadow, restored Mosaic hit and broader gates remain unqualified. No production/package change. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb0d4ce..75bed48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -261,3 +261,12 @@ Rotation is the next separate geometric gate; other open STEP20C gates persist. The freeze audit verified the original evidence manifest before an offline replay rewrote the temporary inventory; the report records that limitation. STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. + +The [rotated-output probe](design/STEP20C_ROTATED_OUTPUT_PROBE.md) is +**PARTIALLY_FEASIBLE** for sampled applied 90° geometry, logical PNG/pointer +calibration, fully hidden owner guard, physical damage coverage and R0 return. +The `grim` PNG is logical-oriented at 1000×750 while the R90 physical mode is +750×1000. Pristine R90 capture failed through a fixture IPC-direction error, +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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 03cbdee..f4f0e50 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -200,3 +200,12 @@ Rotation is the next separate geometric gate; other open STEP20C gates persist. The original temporary evidence manifest was verified before an offline analyzer rerun replaced it; its exact historical bytes are not retained. STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. + +The [rotation probe](design/STEP20C_ROTATED_OUTPUT_PROBE.md) established +the applied 90° mode/transform and logical PNG/pointer orientation in two +private runs, plus sampled fully-hidden owner and damage behavior. Its global +result is **PARTIALLY_FEASIBLE**, architectural partial-clipping conclusion +**UNDECIDED**: pristine R90 capture failed through fixture assertion, and the +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. diff --git a/docs/README.md b/docs/README.md index 7581dda..151a137 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,3 +112,11 @@ PARTIALLY_FEASIBLE for measured stable geometry/ownership, with explicit fixture and unexercised-input limitations. The original temporary evidence manifest was verified before an offline analyzer rerun replaced its bytes; the report records this provenance limit. + +[Rotated-output probe](design/STEP20C_ROTATED_OUTPUT_PROBE.md) and +[fixture](../tooling/probes/step20c-rotated-output/README.md): EXPERIMENTAL / +PARTIALLY_FEASIBLE for applied geometry, logical PNG/pointer, full-owner hide, +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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4b9d1cc..b26bad8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -204,3 +204,11 @@ Rotation is the next separate geometric gate; other open STEP20C gates persist. The original temporary evidence manifest passed verification before an offline analyzer rerun replaced its bytes; see the report for provenance. STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. + +The [rotated-output probe](design/STEP20C_ROTATED_OUTPUT_PROBE.md) is +**PARTIALLY_FEASIBLE** for sampled applied geometry, PNG/pointer calibration, +hidden-owner return and physical damage. Its partial-popup clipping conclusion +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**. diff --git a/docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md b/docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md new file mode 100644 index 0000000..789aaf7 --- /dev/null +++ b/docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md @@ -0,0 +1,431 @@ +# STEP20C rotated output viewport and input probe + +**EXPERIMENTAL evidence; PARTIALLY_FEASIBLE.** This probe follows the +published dynamic-output evidence at `2882f7415952d7f141da85f90c0fd113a17c1665`. +STEP20C remains NEXT and production multi-output NOT YET SUPPORTED. The source +fixture is [here](../../tooling/probes/step20c-rotated-output/README.md). +No production source, installed library, package, desktop session or live IPC +was changed. The separate partial-popup experiment remains a later gate. + +## Question and prior evidence + +The [fractional](STEP20C_FRACTIONAL_SCALING_PROBE.md) and +[dynamic-owner](STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md) probes sampled negative +origins, scale 1.25, nested valid XDG grabs, logical resize and workspace +migration, but every dynamic state used `transform=normal`. The dynamic probe +was PARTIALLY_FEASIBLE: two standard D5 reference calibrations failed, the +maximum camera clamp and migrated clipped-popup portion were not exercised. +This probe asks whether an output rotated 90° keeps the same **logical** Mosaic +viewport, clipping and input while pixels and damage use the changed physical +buffer coordinates. A failure is a hypothesis until measured against pristine +and private-standard controls. + +## Pinned inputs and exact source path + +SwayFX control `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, Mosaic base +`cd550382b311efdbfe3ed892f4e1d281d852e754`, SceneFX +`37ccd723bef49e6891156ffafce8f549f01446cc`, wlroots 0.20.2 source +`d783533489e1f75d6886c2ab5c5960090ef268f8`. Preserve prior patches +unchanged. Fresh private sources/builds, sockets and logs live under one LAB. + +Source audit before fixture implementation: + +- SwayFX `output_cmd_transform` accepts `90` and `270`, then swaps their + `WL_OUTPUT_TRANSFORM_*` values: Sway command directions are clockwise while + Wayland transform enum directions are anticlockwise. SwayFX's IPC encoder + converts the internal enum back to the user-facing direction. The first + pristine R90 action measured IPC `transform="90"`, physical 750×1000 and + logical 800×600. The initial fixture erroneously expected IPC `"270"` and + stopped before its R90 PNG; that action is consumed and is not replayed. + The applied IPC value, not the command string alone, decides the gate. `apply_output_configs` + prepares pending mode, transform, then scale (rounded to 1/120); scene output + build/backend commit precedes final output-layout update, `arrange_root` and + `transaction_commit_dirty`. It dirties optimized blur after output changes. + Thus IPC acceptance does not establish one atomic render/input generation. +- wlroots `wlr_output_transformed_resolution` swaps width/height for odd + transforms; `wlr_output_effective_resolution` divides those dimensions by + scale. `wlr_output_transform_invert` exchanges unflipped 90/270; + `wlr_output_transform_compose` combines transform values. `wlr_box_transform` + and `wlr_region_transform` map rectangles differently at 90/270, with width + and height arguments from the **source** coordinate space. `wlr_output_commit_state` + applies state before `events.commit`; `events.present` is a separate callback. +- SceneFX `scene_node_intersect_clip`/`scene_node_visibility` operate on logical + scene regions. `scene_entry_render` calls `logical_to_buffer_coords` for + regions and `transform_output_box` for boxes; both scale first, then apply + the inverse output transform with transformed physical dimensions. + `scale_region` expands fractional-scale coverage by one pixel when rounding + up. `output_to_buffer_coords` converts output damage with the inverse + transform. `scene_output_damage` stores buffer-local physical extents; + `wlr_scene_output_build_state` computes render damage and output commit state. + Shadow/blur rendering has distinct paths, so effect visibility alone cannot + validate optimized-cache invalidation or off-viewport sampling. +- Mosaic `workspace_tiling_scene_x` subtracts logical `scroll_x` from the + applied tiled-scene position; the private tiled and root-popup clips are + applied logical scene clips. `transaction_apply` copies pending state into + current state, then scene arrangement and cursor rebase follow. + `node_at_coords` and `seat_pointer_notify_button` use global logical pointer + coordinates; wlroots `wlr_cursor_absolute_to_layout_coords` maps normalized + pointer coordinates into the layout's logical bounding box. No physical + scale or output rotation is applied to injected global logical points. + +The source audit identifies paths, not proof that the measured buffer, PNG and +client events agree. It must be confronted with captures and event logs. + +## Geometry and coordinate predictions + +| State | H1 physical mode | Command transform | Expected applied logical H1 | H2 | +| --- | --- | --- | --- | --- | +| R0 | 1000×750 | normal | (-800,0,800,600), scale 1.25 | 800×600 at (0,0), normal, scale 1 | +| R90 | 750×1000 | 90 clockwise | (-800,0,800,600), scale 1.25 | unchanged | +| R270 optional | 750×1000 | 270 clockwise | (-800,0,800,600), scale 1.25 | unchanged | + +R0→R90 changes both mode dimensions and transform: no single-variable causal +claim is possible from that comparison alone. The target logical viewport is +800×600 in both states; H1's physical buffer is expected to use 1000×750 then +750×1000. Actual mode, transform, scale, global rectangle, scene owner and +capture dimensions are gates read from `get_outputs`, trace and PNG. If the +command is refused or remains normal, R90 is INCONCLUSIVE. + +Logical global H1 `(x,y)` becomes output-local `(x+800,y)`. The inverse +wlroots transform maps a scaled logical box into the physical buffer. +For a clockwise command `90`, the parser's applied enum is `270`; IPC reports +the user-facing `90`, while the inverse used by SceneFX is `90`. Distinguish +the two names and use the **internal enum** and actual +buffer dimensions in `wlr_box_transform`/`wlr_region_transform`, including +half-open rectangle bounds and fractional rounding. A simple hand-swapped +axis is insufficient near boundaries. Record both expected and measured +logical→buffer→PNG coordinates for every marker/ROI. + +Virtual-pointer normalization is provisionally `(sent_x,sent_y)=(global_x+800, +global_y)` over a 1600×600 logical union. Empirically calibrate on each output +with an ordinary surface, (+40,+20) local motion, left/right buttons and +crossing `x=0`. Relocate **both** ordinary references before scoring either; +confirm actual client enter/motion and non-overlap. A prior D5 reference +occlusion makes frame-only geometry an insufficient calibration. + +## Capture orientation gate + +`grim` screencopy may expose transformed pixels in a different orientation +from SceneFX's raw framebuffer. Before any graphics assertion, capture a +deliberately asymmetric field with five distinct markers: upper-left red +square, upper-right green triangle, lower-left blue circle, lower-right yellow +stripe and purple center cross. For each, store logical point/box, predicted +physical box using observed transform, PNG box, dimensions, delta and rounding +rule. Also record capture window `CLOCK_MONOTONIC` timestamps, mode, scale, +transform, physical/logical dimensions and file hash. Do not select ROIs after +seeing anomalous pixels. If marker orientation is not identifiable, dependent +pixel, popup and damage claims are INCONCLUSIVE. The marker source and +distribution mechanism must be documented before use. + +## Comparative scenarios and stop gates + +Use identical deterministic SHM toplevel, valid-parent root A and child B, +virtual pointer and ordinary references in: A pristine `splith`, B private +`splith`, C private `mosaic`. Preserve `TOPLEVEL→A→B`; each grab serial must +come from its own real button press, with configure/ack/map and legal anchors. +Do not infer clipping from no spill unless standard controls visibly spill. + +1. T0: R0 geometry, references, scene/clip, PNG markers and input calibration. +2. T1: one reserved R0→R90 per mode, then observed mode/transform/scale, + logical rectangle, identities, scene/clip, marker/PNG and input gates. +3. T2/T3: A and B border-spanning cases, a logical `x=-3..3` grid at + preselected popup y values, full adjacent physical strips, permitted and + forbidden pixels, scene hits and separate client delivery/dismissal. +4. T4: one global Mosaic camera hide/return pair only; same XDG object identity + and a new B button after return if no click has dismissed the stack. +5. T5: one reserved R90→R0 per mode; check restored mode, transform, scene, + camera, clip, PNG and input. A clamp may alter camera; its measured value + governs expected geometry. +6. T6: short shadow/blur FX0/FX2 contrasts only after geometry and PNG gates; + optimized cache and outside-source sampling remain NOT TESTED otherwise. +7. T7: measured physical damage extents versus changed physical pixels, including + exposed regions on both outputs. Do not double-scale physical extents or + infer every intermediate frame from settled comparisons. +8. T8: private Mosaic `layout splith`, then no residual clip, ordinary popup, + focus, effects and input before an exterior dismissal click. + +R270 is optional, static at startup, at most one run per mode and only after +R90 capture/pointer gates pass. Transforms 180/flipped, hotplug, all saved +buffers, complete cache invalidation, all frames, ABI/package and daily-driver +support remain NOT TESTED unless directly measured. + +## Strict budget, isolation and evidence + +One persistent locked ledger spans the campaign. Reserve **before** each +mode/transform action, retain failed reservations, forbid repeats and refuse +more than two actions per mode / six overall. One global Mosaic camera +hide/return pair maximum. R270 adds at most three static starts, no dynamic +actions. Test lock, persistence, duplicate/exhaustion and missing-ledger +rejection offline before any run. No second lab may bypass consumed actions. + +Every run has private 0700 `XDG_RUNTIME_DIR`, explicit private `SWAYSOCK` +and `swaymsg -s`, private `WAYLAND_DISPLAY`, headless backend and scoped child +cleanup. Never resolve the live IPC socket implicitly. Preserve all failure +exit codes, attempts and raw logs before owned cleanup; never kill by global +process name. Use passive patches only when prior traces are insufficient; +do not alter clipping, shaders, damage, callbacks, hit testing or ABI. + +Retain source/evidence SHA-256 manifests, raw events/PNGs, output/transform, +logical-to-buffer, screenshot, owner/clip, popup, pointer/input, pixel, damage, +timeline, budget, matrix and summary JSON under LAB. The local fixture keeps +only source and passive patch files; no generated binary or capture enters Git. + +Verdict: FEASIBLE only if the applied rotation, coordinate/PNG calibration, +standard spill controls, Mosaic visible/forbidden pixels and hits, valid grabs, +return clips and measured damage all pass in the tested matrix. +PARTIALLY_FEASIBLE requires valid principal measurements with named open cells. +INCONCLUSIVE covers un-applied transform or failed orientation/fixture gate. +BLOCKED requires a reproduced forbidden pixel/hit, wrong transform clip, crash +or blocking standard-layout regression. Even FEASIBLE is experimental evidence, +never production qualification. Architectural conclusion is separately +`ROTATED_OUTPUT_FEASIBLE_FOR_TESTED_MATRIX`, `ROTATED_OUTPUT_REQUIRES_REDESIGN` +or `UNDECIDED` according to measured support. + +## Measured private campaign — 2026-10-12 + +**EXPERIMENTAL / PARTIALLY_FEASIBLE overall; architectural conclusion +UNDECIDED.** The new laboratory is +`/tmp/swayfx-mosaic-nested-grab-TJ3AhbCE`. A fresh source-only preparer built +private SceneFX/Mosaic and pristine SwayFX from the same pins and compiled +the XRGB client and virtual pointer under C17 `-Werror`. It never used the +historical dynamic campaign's consumed actions. All three modes were attempted +once under one persistent locked rotation ledger. There are 111 retained PNGs: +16 pristine, 40 private standard and 52 private Mosaic, plus the per-run marker +source PNGs. No B source, report or result was committed/pushed. + +### Fixture incident and applied geometry + +The pristine `splith` run completed R0, two-output pointer calibration and a +two-level grabbed popup chain. It reserved and launched R90. `get_outputs` +then showed H1 physical **750×1000**, scale **1.25**, IPC transform **`90`**, +and logical **(-800,0,800,600)**. The initial fixture asserted IPC `270` by +mistaking the internal wlroots enum for SwayFX's user-facing IPC string, so +it failed before R90 capture. Its R90 action remains consumed. Pristine R90 +PNG, input and return are **NOT TESTED**; this is a fixture error, not a +demonstrated compositor rotation defect. The source assertion was corrected +before the two private runs; no pristine retry occurred. + +Both private modes completed exactly one R0→R90 and one R90→R0. H2 stayed +physical/logical 800×600, scale 1, normal at (0,0). H1 remained logical +(-800,0,800,600) in all measured states while changing as follows: + +| State | Physical mode | IPC transform | Internal enum trace | Scale | H1 PNG | +| --- | --- | --- | --- | --- | --- | +| R0 | 1000×750 | normal | 0 | 1.25 | 1000×750 | +| R90 | 750×1000 | 90 | 3 (`WL_OUTPUT_TRANSFORM_270`) | 1.25 | 1000×750 | +| Return R0 | 1000×750 | normal | 0 | 1.25 | 1000×750 | + +The mode's orientation and the transform both changed; the equal logical +viewport does not isolate their separate causal effects. An IPC success value +alone was never used as the applied-geometry proof. + +### PNG and pointer gates + +`grim -s 1.25` exposed a **logical-oriented** 1000×750 PNG even with the +750×1000 physical R90 mode. All five exact-color marker centers were identical +in private R0 and R90 screenshots: TL (37,37), TR (962,37), BL (37,712), +BR (962,712), center (499.5,374.5). The pristine R0 marker matched. +This identifies the PNG repère for this screencopy path, not the raw SceneFX +framebuffer. For the internal 270 transform, SceneFX uses inverse 90 on +scaled logical rectangles. A pixel center `(x,y)` in this logical PNG maps by +source formula to physical buffer `(749-y,x)`: TL approximately (712,37), +TR (712,962), BL (37,37), BR (37,962), center approximately (375,500). +These buffer coordinates are **calculated, not directly read back**. The exact +marker equality has zero measured PNG-center deviation; raw-buffer deviation +is NOT TESTED. Fractional edges use SceneFX's region expansion and box +rounding, so the center mapping is not a claim about every filtered edge. +The actual wallpaper used five distinct exact RGB colors and five asymmetric +positions, but drew every mark as a square. This does not meet the protocol's +distinct-shape preference; the unique color/position pairs made orientation +identifiable in the retained images, and no shape-based assertion is claimed. + +On each private mode, independent ordinary references received pointer +enter/motion, logical (+40,+20) local deltas and left/right buttons on H1/H2 +at R0 and R90. Their first local point was (20,30), second (60,50). +The injector used the observed logical union `min_x=-800`, extent 1600×600, +`sent_x=global_x+800`, `sent_y=global_y`, without multiplying by 1.25 or +rotating injection coordinates. It traversed `x=-3..3` at y=550; those moves +were **not scored against popup client events** and do not qualify clipped +popup boundary hit testing. Both references were relocated before either +calibration, avoiding the earlier D5 overlap incident. + +### Popups, clips and pixels + +All measured A/B grabs used real received button serials. Pristine R0 had +two valid grabs; each private mode had four valid grabs across its first and +reopened chains. The client retained `TOPLEVEL→A→B` parentage, configure, +ack, map and distinct new role lifetimes after closure. A's anchor (200,80) +lies within the 420×320 parent buffer; B's (220,60) lies within A's 300×170 +buffer. No protocol error was logged. One A/B `popup_done` pair appeared in +each private run during the explicit close/reopen sequence; the chain present +during R90 was captured before closure. This does not prove universal XDG +persistence through arbitrary transforms. + +Mosaic retained workspace ID 6, tiled owner ID 8, order [7,8,9,10] and camera +640 through R0, R90 and return. `DO_STATE` records transform 0→3→0. +Pre-capture applied traces give tiled scene origin (-1440,0), local clip +(640,0,800,600), derived viewport (-800,0,800,600) at R0/R90/return. +Owner-derived root-popup clip from global root (-798,25) and local +(-2,-25,800,600) yields the same viewport. Nested popup inheritance is +visible in scene traces. These are sampled applied states, not every frame or +a shared render/input transaction generation. + +The private-standard R90 screenshot has **15,860** exact B-color pixels on +H2, including 130/390/910 pixels in its first 1/3/7 physical columns. +Pristine R0 showed the same 15,860 H2 B pixels. Private Mosaic R90 has +zero A/B-color pixels on H2 and permitted A/B colors on H1. However, its +B-color box in the logical PNG is x=[528,903), y=[206,369), wholly inside +the H1 viewport x=[0,1000) in PNG coordinates. Standard B reaches x=1000 +and continues on H2. **The different standard/Mosaic layout positions mean +this campaign did not challenge a partially clipped Mosaic popup at R90.** +The zero H2 count is an observed no-spill state, not proof that a forbidden +popup portion was clipped. A crossing and simultaneous A/B crossing are also +NOT EXERCISED. The fixed x=-3..3/y=550 grid did not lie in a scored popup +region; invisible hit tests there remain NOT TESTED. + +The single reserved Mosaic camera hide/return pair moved scroll 640→1280→640 +under R90. At 1280, owner 8's applied scene x=-1440 with width 640 ends at +the viewport's x=-800, yielding an empty intersection. Its root popup clip +became (0,0,0,0); H1 captured zero owner/A/B-color pixels. The return +restored the same scene/clip and exact R90 image hash, and a new right button +on B at global (-343,195) reached that client after return. The second camera +reservation is the return half of this one pair, not a second pair. +The second chain's sampled popup roles (A 13, B 16 in this run) remained +mapped through that pair, without a new `popup_done`; their IDs are run +observations, not fixture constants. B also received a right button while +visible under R90 before the hide. + +R90→R0 restored mode, transform and clip in both private runs. Mosaic's +settled R0 popup screenshots were byte-identical to its R90 logical-oriented +popup screenshots and to its earlier R0 state. `layout splith` subsequently +removed the Mosaic clips; its left/right PNG hashes equaled the private +standard `splith` result, including the ordinary 15,860-pixel B overflow on +H2. This samples return and ordinary layout behavior, without proving all +transition frames or all focus/popup event orderings. + +### Damage, effects and budget + +SceneFX's traced `TEMPORAL_DAMAGE` extents are clipped to physical +buffer-local `output->width/height` after `output_to_buffer_coords` applies +the inverse transform. For equal-transform settled R90 Mosaic pairs, map +changed PNG pixels `(x,y)` to physical `(749-y,x)` before comparing extents. +The R90 visible→fully-hidden and hidden→restored H1 pairs each changed +569,789 logical-PNG pixels; 27 and 18 physical extents respectively covered +all changed pixels, **zero uncovered**. H2 changed zero in that pair. The +R0 Mosaic→standard return changed 442,712 H1 pixels; 89 available normal +extents covered them, zero uncovered. Full-output extents occurred, so neither +minimal damage nor complete intermediate-frame coverage is established. +Mode/transform transitions were not compared as equal physical buffers. +Commit/present callbacks were retained but no screenshot was tied to a native +buffer identity or atomic input generation. +Private Mosaic logged 221 native commit and 215 present callbacks; private +standard logged 188/182. The pristine control has no passive native trace. +These count differences include startup/teardown and are not themselves a +frame-loss finding. + +Shadow and blur were disabled in this campaign. T6, optimized cache +invalidation, off-viewport blur source sampling and physical raw-buffer +readback are **NOT TESTED**. R270 was not run: the main negative partial-popup +challenge is already absent, so another static direction would not close it. + +The durable ledger retains one failed control R90 action, private-standard +R90/R0 and private-Mosaic R90/R0: **5/6 mode/transform actions**. Mosaic used +**1/1 camera hide/return pair**; R270 static runs **0/3**. No reservation was +refunded or replayed, and no second lab was created to evade the budget. + +### Structured evidence and integrity + +The authoritative final analysis is `LAB/results-final/`; the prior sealed +`LAB/results/` and `LAB/results-corrected/` remain byte-for-byte preserved. The initial +analysis counted zero Mosaic spill but did not check the Mosaic B bounding +box, so its architectural conclusion was too strong. The final results +include outputs, transforms, logical-to-buffer mapping, marker coordinates, +workspace owner, scene clips, popup geometry, pointer/input, pixel/damage, +timeline, budget, matrix and summary JSON. All are recalculable from raw +events, logs and 111 retained run PNGs. + +The corrected v2 1,015-entry source manifest SHA-256 was +`8fe5bdec378ca200a94c589db867fc21c80ee9fe37762c38077746e4eb2d9ce6`; +its 282-entry evidence manifest was +`77e3d60621f14e46655779d09ce53cc92e48cc50b8aa2d72c5faa64432fdba51`. +The final v3 manifests and entry counts are recorded after sealing below. +Final v3 **1,015-entry source** manifest SHA-256: +`f17ee5999edec84885997ea5dfb193859f7c1d278bf4dda22f8ab20b1c5e0b8f`. +Final v3 **284-entry evidence** manifest SHA-256: +`261ff4cb12d7be3494d43f29ce1d24f980d8f19abdc558fef42599d50070747d`. +Both verify entry by entry. The final B box is derived from pixels as +`[528,206,903,369]` against PNG width 1000; owner ID is derived from each +run's tree state. Raw captures and private logs are unchanged across analyses. +The v1 evidence manifest +`02d7f47e1be9e3fb8d867e9a2d0d8d27ee358af43d96c9b217ab5435c7e5c4b8` +also still verifies; each later evidence manifest includes the prior manifest files. +The v1 source manifest recorded earlier fixture bytes and is historical after +the analysis correction; no raw run file was modified. + +An additive offline `LAB/results-roi/` annotation records fixed +logical/PNG/physical half-open boundary ROIs, exact A/B counts, comparable +previous-capture pixel changes, mode/scale/transform, hashes and monotonic +capture windows for all **108 run captures** (the three marker-source PNGs are +not run captures). At R90 the H1 PNG strip [995,1000) maps to physical-buffer +y=[995,1000), x=[0,750), while H2 uses its first four physical columns. +The private-standard R90 strip has 815 B pixels on H1 and 520 on H2; +Mosaic has zero in both strips because its B box ends at PNG x=903. +The 115-entry addendum manifest SHA-256 is +`426f40078ad6388b34219b09f576d833d99a8dec76ea286235bc34fa324f8f63` +and passed entry-by-entry verification. It chains the final analysis manifests +without changing any earlier sealed file. These strip counts are not popup +hit-delivery measurements. + +### Validation and isolation closeout + +The new ledger passed offline lock, durable reservations, duplicate, +exhaustion and missing-ledger tests without a compositor launch. The five +fixture modules and three generated runners passed Python AST; the inherited preparer's shell +syntax passed. The private SceneFX, Mosaic and pristine builds completed; +the Mosaic Meson model test passed 1/1. Both experimental C17 clients compiled +with `-Wall -Wextra -Werror`. The two dynamic-fixture patches retained their +published SHA-256 and reverse-applicability checks in the fresh private tree. +The forward patch sequence was exercised by the fresh preparer. A standalone +reverse check of the older SceneFX `effect-trace.patch` against the **fully +stacked** private tree failed at `wlr_scene.c:2117`; later overlapping patches +make that isolated reverse check inapplicable. No historical patch was edited; +the complete private SceneFX build passed. + +Markdown links and the complete local diff, including new files through a +temporary Git index, passed checks. The real Git index remains empty. +Production Mosaic sources, the cumulative patch, `PKGBUILD`, installed +SceneFX/wlroots, `/usr/bin/sway`, QuickShell and dotfiles were untouched. +Every experimental IPC call used an explicit private socket; no live SwayFX +IPC was accessed. All three per-run 0700 runtime directories were removed, +with no retained private socket or experimental process. No install, session +restart or STEP20D work occurred. This historical laboratory and its consumed +budget are closed; the separate static-R90 partial-popup campaign requires a +new ledger and private runtime. + +## Architectural answers and verdict + +| Question | Evidence-backed answer | +| --- | --- | +| Q1 viewport coherence | Sampled applied R90 tiled/root-popup clips still derive (-800,0,800,600); logical owner/camera stable. | +| Q2 SceneFX physical conversion | Source scales then inverse-transforms boxes/regions. Physical damage coverage is compatible at two settled camera transitions; raw framebuffer pixels were not read, so direct physical-render proof is partial. | +| Q3 PNG frame | Yes for this `grim -s 1.25` path: 1000×750 logical orientation with all five marker centers unchanged under R90. | +| Q4 visible and forbidden pixels | Permitted Mosaic owner/A/B pixels survive R90; zero exact A/B colors on H2. Partial Mosaic popup overflow was not created, so forbidden-pixel clipping is NOT EXERCISED. | +| Q5 root/child clips | Root clip and nested inheritance trace the owner viewport; fully hidden owner reaches empty clip. Partial border challenge NOT EXERCISED. | +| Q6 grabs | Two genuine grabs per completed chain, valid serial/configure/ack/map and no protocol error. | +| Q7 pointer | Two-output logical calibration passes R0/R90 in both private modes, without scale/rotation injection; popup border hit grid unscored. | +| Q8 damage | Available physical extents cover 569,789 changed pixels on each R90 camera leg, zero uncovered; no all-frame/minimal guarantee. | +| Q9 R0→R90 stale clip/cache | No stale clip in sampled traces/screenshots; effects disabled, optimized cache NOT TESTED. | +| Q10 R90→R0 | Private modes restore physical/logical geometry and normal transform; Mosaic restores sampled scene/clip and identical logical PNG. Pristine return NOT TESTED. | +| Q11 effects | Shadow/blur disabled; NOT TESTED under rotation. | +| Q12 algorithm redesign | No reproducible leak, invisible hit or crash demonstrated. Missing challenge does not justify production or private algorithm changes. | +| Q13 applied geometry contract | A future STEP20C contract must bind applied mode, transform, scale, logical viewport and input/damage generation explicitly. This probe proposes that requirement; it does not freeze a new contract. | +| Q14 open cases | Pristine R90 PNG/input/return; partial A/B Mosaic crossing; scored popup-border hits; effects/cache; raw physical readback; saved buffers; intermediate frames; R270; ABI/package. | +| Q15 next gate | Another **rotated geometry** probe with a Mosaic popup demonstrably crossing x=0 and legal grabs, plus a completed pristine R90 control, is the smallest decisive gate. Saved buffers, optimized cache and ABI/package remain later independent gates. | + +`SWAYFX_MOSAIC_ROTATED_OUTPUT_PROBE=PARTIALLY_FEASIBLE` + +Architectural conclusion: **UNDECIDED** for partial popup clipping under +physical rotation. The tested logical geometry, PNG/pointer calibration, +fully-hidden guard, damage coverage and return are useful evidence, but the +central negative clipping comparison is absent. STEP20C stays **NEXT**; +multi-output Mosaic stays **NOT YET SUPPORTED**. diff --git a/tooling/probes/step20c-rotated-output/README.md b/tooling/probes/step20c-rotated-output/README.md new file mode 100644 index 0000000..222b100 --- /dev/null +++ b/tooling/probes/step20c-rotated-output/README.md @@ -0,0 +1,55 @@ +# Rotated-output source fixture + +**EXPERIMENTAL / PARTIALLY_FEASIBLE evidence fixture.** The +[protocol](../../../docs/design/STEP20C_ROTATED_OUTPUT_PROBE.md) records the +scientific question, pinned source audit, geometry/capture gates, strict global +action budget and stopping criteria before dynamic execution. All generated +sources, builds, clients, sockets, captures, logs and manifests belong in one +private temporary LAB, never in Git or the running desktop session. + +The fixture must reuse the published source-only private patches unchanged. +The pristine SwayFX control remains free of Mosaic changes. No live IPC, +installed library, package or production algorithm may be modified. + +## Measured campaign and replay boundary + +Laboratory: `/tmp/swayfx-mosaic-nested-grab-TJ3AhbCE`. The source-only +preparer was the preserved `step20c-dynamic-output/prepare.sh`; it rebuilt +pristine SwayFX, private Mosaic/SceneFX and strict C17 clients in a new LAB. +The rotation runner generates a bounded child from the published applied-state +runner, with the corrected two-reference calibration and a five-marker +asymmetric wallpaper. `--source-only` checks generated Python without launch. +`test-budget.py` exercises the real lock/ledger offline. + +```sh +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-rotated-output/test-budget.py +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-rotated-output/run.py "$LAB" control --source-only +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-rotated-output/run.py "$LAB" private_standard --source-only +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-rotated-output/run.py "$LAB" private_mosaic --source-only +``` + +The actual three modes have already been attempted in this LAB. **Do not run +those mode commands again.** The pristine control's R90 reservation was +consumed before a fixture assertion incorrectly expected IPC `270`; the +applied output actually reported user-facing `90`, physical 750×1000 and +logical 800×600. No pristine R90 PNG or R0 return is available. The two +private modes completed R0→R90→R0. Their five-marker captures establish +that `grim -s 1.25` returns a logical-oriented 1000×750 PNG even while the +physical buffer is 750×1000. Private `splith` has 15,860 B-color overflow +pixels on H2; private Mosaic has zero A/B-color pixels there in the sampled +R90 states. The Mosaic B box ends at logical-PNG `x=903`, before the +`x=1000` boundary: the zero H2 count is **not** a partial-clip challenge. +One Mosaic camera hide/return pair and a subsequent B button delivery were +observed. The global result is PARTIALLY_FEASIBLE and the architectural +partial-clipping conclusion is UNDECIDED. + +The complete measured evidence and result caveats are in the report. The +analyzer reads raw events, screenshots and private logs offline. An initial +sealed `results/` analysis counted spill but missed the Mosaic-box geometry +confound. The second sealed `results-corrected/` analysis identified that +confound; a final `results-final/` analysis derives the owner ID and B box from +each run rather than using historical ID/box constants. Earlier directories +remain byte-for-byte preserved and chained by manifests. The analyzer refuses +to overwrite any sealed directory. +Original dynamic, fractional, SceneFX and other earlier fixture patches are +unchanged. No new passive instrumentation patch was needed. diff --git a/tooling/probes/step20c-rotated-output/analyze.py b/tooling/probes/step20c-rotated-output/analyze.py new file mode 100644 index 0000000..c23f4e0 --- /dev/null +++ b/tooling/probes/step20c-rotated-output/analyze.py @@ -0,0 +1,262 @@ +"""Offline analysis of retained rotation runs; never launches a compositor.""" +import argparse +import hashlib +import json +from pathlib import Path +import re + +import numpy as np +from PIL import Image + +from campaign import read + +COLORS = {'owner': (35, 68, 204), 'A': (224, 192, 32), + 'B': (32, 208, 160), 'background': (30, 30, 46)} +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 latest(traces, kind, before_ns, *, output=None, owner=None): + values = [t for t in traces if t['kind'] == kind and t['ns'] <= before_ns + and (output is None or t.get('output', t.get('observer')) == output) + and (owner is None or t.get('owner_id') == str(owner))] + return values[-1] if values else None + + +def parse_run(run): + events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()] + # CONTRACT: Sway node IDs are per-run observations, never fixture constants. + owner_id = next(e['owner']['id'] for e in events + if e['kind'] == 'state' and e.get('owner')) + text = (run / 'compositor.log').read_text(errors='replace') + traces = [] + for line in text.splitlines(): + match = TRACE.search(line) + if match: + item = {'kind': match.group(1), **fields(line)} + if item.get('ns', '').isdigit(): + item['ns'] = int(item['ns']) + traces.append(item) + damages = [(int(ns), output, *map(int, (x1, y1, x2, y2))) + for ns, output, x1, y1, x2, y2 in DAMAGE.findall(text)] + geometry = {(e['tag'], e['output']): e for e in events + if e['kind'] == 'capture_geometry'} + captures = {} + for e in events: + if e['kind'] != 'capture': + continue + g = geometry[(e['tag'], e['output'])] + path = run / e['path'] + image = np.asarray(Image.open(path).convert('RGB')) + counts = {} + color_boxes = {} + for name, color in COLORS.items(): + ys, xs = np.where(np.all(image == color, axis=2)) + counts[name] = len(xs) + color_boxes[name] = ([int(xs.min()), int(ys.min()), + int(xs.max())+1, int(ys.max())+1] + if len(xs) else None) + scene = latest(traces, 'PI_SCENE', e['before_ns'], output=e['output']) + state = latest(traces, 'DO_STATE', e['before_ns'], output=e['output']) + popup = latest(traces, 'DO_POPUP', e['before_ns'], owner=owner_id) + viewport = None + if state and scene and scene.get('clip_valid') == '1': + x, y = map(int, state['tile_global'].split(',')) + cx, cy, w, h = map(int, scene['clip'].split(',')) + viewport = [x+cx, y+cy, w, h] + captures[(e['tag'], e['output'])] = { + 'tag': e['tag'], 'output': e['output'], 'file': e['path'], + 'sha256': digest(path), 'capture_window_ns': [e['before_ns'], e['after_ns']], + 'physical_mode': g['mode'], 'scale': g['scale'], + 'ipc_transform': g['transform'], 'logical_rect': g['rect'], + 'png_dimensions': g['dimensions'], 'png_frame': 'logical-oriented', + 'colors': counts, 'color_boxes_png': color_boxes, + 'applied_viewport': viewport, + 'scene': scene, 'output_state': state, 'root_popup': popup, + } + client = (run / 'client.log').read_text(errors='replace') + grabs = [{'popup': popup, 'target': target, 'serial': int(serial), + 'used': int(used), + 'valid': serial == used and bool(re.search( + rf'POINTER_BUTTON target={target} serial={serial} button=272 state=1', client))} + for popup, target, serial, used in re.findall( + r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button ' + r'target=(\S+) serial=(\d+) used=(\d+)', client)] + return {'run': run.name, 'events': events, 'traces': traces, + 'damage': damages, 'captures': captures, 'grabs': grabs, + 'exit': json.loads((run / 'exit-status.json').read_text()), + 'popup_done': {'A': client.count('POPUP_A POPUP_DONE'), + 'B': client.count('POPUP_B POPUP_DONE')}, + 'client_protocol_errors': [line for line in client.splitlines() + if 'protocol error' in line.lower()]} + + +def damage_pair(item, first, second, output, rotated): + a = item['captures'][(first, output)] + b = item['captures'][(second, output)] + before = np.asarray(Image.open(Path(item['lab']) / 'runs' / item['run'] / a['file']).convert('RGB')) + after = np.asarray(Image.open(Path(item['lab']) / 'runs' / item['run'] / b['file']).convert('RGB')) + if before.shape != after.shape or a['ipc_transform'] != b['ipc_transform']: + return {'status': 'NOT COMPARABLE: physical mode/transform changed'} + changed = np.any(before != after, axis=2) + ys, xs = np.where(changed) + width, height = (750, 1000) if rotated else (after.shape[1], after.shape[0]) + mask = np.zeros((height, width), dtype=bool) + extents = [d for d in item['damage'] if d[1] == output + and a['capture_window_ns'][1] < d[0] <= b['capture_window_ns'][1]] + for _, _, x1, y1, x2, y2 in extents: + mask[max(0, y1):min(height, y2), max(0, x1):min(width, x2)] = True + # WHY: grim's rotated PNG is logical-oriented; SceneFX damage is already + # physical buffer-local. Internal WL transform 270 has inverse 90. + # CONTRACT: half-open pixel map (x,y) -> (749-y,x) for the measured R90. + px, py = (width-1-ys, xs) if rotated else (xs, ys) + uncovered = int(np.count_nonzero(~mask[py, px])) + return {'status': 'MEASURED', 'from': first, 'to': second, 'output': output, + 'png_changed_pixels': len(xs), 'physical_extents': [list(d[2:]) for d in extents], + 'uncovered_changed_pixels': uncovered, 'physical_dimensions': [width, height], + 'scope': 'settled PNG differences only; no minimal or all-frames claim'} + + +parser = argparse.ArgumentParser() +parser.add_argument('lab', type=Path) +args = parser.parse_args() +lab = args.lab.resolve() +old = lab / 'results' +# WHY: the initial sealed analysis counted zero spill without checking whether +# the Mosaic popup reached the border. Keep every old byte and path verifiable. +# CONTRACT: corrected scientific interpretation gets a separate sealed directory. +second = lab / 'results-corrected' +if (second / 'evidence-sha256.txt').exists(): + out = lab / 'results-final' +elif (old / 'evidence-sha256.txt').exists(): + out = second +else: + out = old +out.mkdir(exist_ok=True) +if (out / 'evidence-sha256.txt').exists(): + raise SystemExit('this analysis is already sealed; refuse another rewrite') +runs = [parse_run(p) for p in sorted((lab / 'runs').glob('*-rotated-*'))] +for item in runs: + item['lab'] = str(lab) +by_mode = {item['run'].split('-rotated-', 1)[0]: item for item in runs} +budget = read(lab) +write(out / 'campaign-budget.json', budget) +write(out / 'outputs.json', {mode: [e for e in item['events'] if e['kind'] == 'state'] + for mode, item in by_mode.items()}) +write(out / 'transforms.json', {mode: [e for e in item['events'] if e['kind'] == 'capture_geometry'] + for mode, item in by_mode.items()}) +write(out / 'logical-to-buffer.json', { + 'R0': {'logical_scaled': [1000, 750], 'physical': [1000, 750], 'map': '(x,y)->(x,y)'}, + 'R90': {'logical_scaled': [1000, 750], 'physical': [750, 1000], + 'ipc_transform': '90', 'internal_transform': '270', + 'inverse_map_pixel': '(x,y)->(749-y,x)', + 'source': 'pinned wlroots box/region transform and measured mode'}, +}) +write(out / 'screenshot-coordinates.json', { + mode: [e for e in item['events'] if e['kind'] == 'marker_calibration'] + for mode, item in by_mode.items()}) +write(out / 'workspace-owner.json', {mode: [e for e in item['events'] if e['kind'] == 'identity'] + for mode, item in by_mode.items()}) +write(out / 'scene-clips.json', {mode: [c for c in item['captures'].values() + if c['applied_viewport'] is not None] + for mode, item in by_mode.items()}) +write(out / 'popup-geometry.json', {mode: {'grabs': item['grabs'], + 'done': item['popup_done'], 'protocol_errors': item['client_protocol_errors'], + 'chains': [e for e in item['events'] if e['kind'] == 'chain']} + for mode, item in by_mode.items()}) +write(out / 'pointer-calibration.json', {mode: [e for e in item['events'] + if e['kind'] == 'pointer_calibration'] for mode, item in by_mode.items()}) +write(out / 'input-delivery.json', {mode: [e for e in item['events'] + if e['kind'] in ('owner_hit', 'restored_popup_hit', 'pointer_request')] + for mode, item in by_mode.items()}) +write(out / 'pixel-analysis.json', {mode: list(item['captures'].values()) + for mode, item in by_mode.items()}) +damage = {} +for mode, item in by_mode.items(): + damage[mode] = [] + if mode == 'private_mosaic': + for a, b, output, rotated in [ + ('R90-reopened', 'R90-hidden', 'HEADLESS-1', True), + ('R90-hidden', 'R90-camera-return', 'HEADLESS-1', True), + ('R90-reopened', 'R90-hidden', 'HEADLESS-2', False), + ('R0-return', 'standard-return', 'HEADLESS-1', False)]: + damage[mode].append(damage_pair(item, a, b, output, rotated)) +write(out / 'damage-analysis.json', damage) +write(out / 'transition-timeline.json', {mode: { + 'events': [e for e in item['events'] if e['kind'] in ('dynamic_launch', 'capture')], + 'native_commit_count': sum(t['kind'] == 'PI_OUTPUT_COMMIT' for t in item['traces']), + 'native_present_count': sum(t['kind'] == 'PI_OUTPUT_PRESENT' for t in item['traces'])} + for mode, item in by_mode.items()}) +mosaic_capture = by_mode['private_mosaic']['captures'][('R90-popups', 'HEADLESS-1')] +mosaic_b_box = mosaic_capture['color_boxes_png']['B'] +mosaic_boundary = mosaic_capture['png_dimensions'][0] +partial_crossing = mosaic_b_box is not None and mosaic_b_box[2] >= mosaic_boundary +matrix = { + 'control': {'R0': 'PASS', 'R90_geometry': 'PASS', 'R90_capture': 'NOT TESTED: fixture IPC direction assertion', + 'R0_return': 'NOT TESTED'}, + 'private_standard': {'R0': 'PASS', 'R90_geometry_png_pointer': 'PASS', + 'R90_popup_spill': 'PASS: 15860 B pixels on H2', 'R0_return': 'PASS'}, + 'private_mosaic': {'R0': 'PASS', 'R90_geometry_png_pointer': 'PASS', + 'R90_popup_no_spill': 'OBSERVED: 0 A/B pixels on H2; B PNG box and boundary recorded separately', + 'R90_B_png_box': mosaic_b_box, + 'R90_png_boundary': mosaic_boundary, + 'R90_partial_popup_clip': ('GEOMETRIC CHALLENGE PRESENT' if partial_crossing + else 'NOT EXERCISED: no Mosaic B portion crosses the output boundary'), + 'camera_hide_return': 'PASS: one pair', + 'restored_B_button': 'PASS', 'R0_return': 'PASS', + 'standard_return': 'PASS'}, + 'not_tested': ['pristine R90 PNG/input after fixture assertion', + 'Mosaic partial popup clipping at R90', + 'root A crossing', 'simultaneous A/B crossing', + 'scored popup boundary input grid', 'shadow and blur', + 'optimized blur cache', 'saved buffers', 'raw physical buffer readback', + 'every intermediate frame', 'R270', 'SceneFX ABI/package'], +} +write(out / 'matrix.json', matrix) +summary = {'result': 'PARTIALLY_FEASIBLE', + 'architectural_conclusion': 'UNDECIDED', + 'reason': 'applied geometry, logical PNG/pointer, hidden-owner and restoration pass; Mosaic popup never crosses R90 boundary and pristine R90 capture is missing', + 'measured_mosaic_B_png_box': mosaic_b_box, + 'measured_mosaic_png_boundary': mosaic_boundary, + 'runs': {mode: {'failure': item['exit']['failure'], + 'captures': len(item['captures']), + 'grabs_valid': all(g['valid'] for g in item['grabs'])} + for mode, item in by_mode.items()}, + 'budget': {'mode_transform_actions': sum(x['action'] in ('R90','R0') for x in budget['reservations']), + 'camera_hide_return_actions': sum(x['action'].startswith('camera') for x in budget['reservations'])}} +write(out / 'summary.json', summary) +source_paths = sorted(set(list((Path(__file__).parent).glob('*.py')) + + [Path(__file__).parent / 'README.md'] + + list(lab.glob('rotated-*.py')) + + [p for p in lab.iterdir() if p.is_file() and p.suffix in ('.c', '.h')] + + [p for root in ('mosaic', 'standard', 'scenefx', 'scenefx-git') + for p in (lab / root).rglob('*') if p.is_file() and '.git' not in p.parts] + + [p for p in (Path(__file__).parents[1]).glob('step20c-*/*.patch')])) +(out / 'source-sha256.txt').write_text(''.join(digest(p) + ' ' + str(p.resolve()) + '\n' + for p in source_paths)) +evidence_paths = sorted(set([p for p in (lab / 'runs').rglob('*') if p.is_file()] + + [p for p in out.iterdir() if p.is_file() and p.name != 'evidence-sha256.txt'] + + [lab / 'rotation-campaign-budget.json'] + + ([old / 'evidence-sha256.txt', old / 'source-sha256.txt'] + if out != old else []) + + ([second / 'evidence-sha256.txt', second / 'source-sha256.txt'] + if out != second and second.exists() else []))) +(out / 'evidence-sha256.txt').write_text(''.join(digest(p) + ' ' + str(p.resolve()) + '\n' + for p in evidence_paths)) +print(json.dumps({'summary': summary, 'source_entries': len(source_paths), + 'evidence_entries': len(evidence_paths), + 'source_manifest_sha256': digest(out / 'source-sha256.txt'), + 'evidence_manifest_sha256': digest(out / 'evidence-sha256.txt')})) diff --git a/tooling/probes/step20c-rotated-output/campaign.py b/tooling/probes/step20c-rotated-output/campaign.py new file mode 100644 index 0000000..35a5248 --- /dev/null +++ b/tooling/probes/step20c-rotated-output/campaign.py @@ -0,0 +1,88 @@ +"""Persistent global rotation budget; one lock covers launch and cleanup.""" +import fcntl +import json +import os +from pathlib import Path +import time + +MODES = ('control', 'private_standard', 'private_mosaic') +ACTIONS = ('R90', 'R0', 'camera_hide', 'camera_return') + + +def read(lab): + lab = Path(lab) + path = lab / 'rotation-campaign-budget.json' + if not path.exists(): + if any((lab / 'runs').glob('*-rotated-*')): + raise ValueError('missing ledger with retained runs') + return {'version': 1, 'runs': [], 'reservations': []} + state = json.loads(path.read_text()) + if state.get('version') != 1 or len(state.get('runs', [])) != len(set(state['runs'])): + raise ValueError('bad version or duplicate run') + if any(mode not in MODES for mode in state['runs']): + raise ValueError('bad mode') + seen = set() + for item in state.get('reservations', []): + key = (item.get('mode'), item.get('action')) + if key in seen or key[0] not in state['runs'] or key[1] not in ACTIONS: + raise ValueError('bad or duplicate reservation') + if key[1].startswith('camera') and key[0] != 'private_mosaic': + raise ValueError('camera action outside Mosaic') + seen.add(key) + return state + + +def write(lab, state): + # WHY: a failed command consumes its reserved global action. + # CONTRACT: the caller holds Campaign.lock across child lifetime. + # INVARIANT: durable replacement precedes every command launch. + path = Path(lab) / 'rotation-campaign-budget.json' + tmp = path.with_suffix('.tmp') + with tmp.open('w') as output: + output.write(json.dumps(state, indent=2) + '\n') + output.flush() + os.fsync(output.fileno()) + tmp.replace(path) + fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +class Campaign: + def __init__(self, lab): + self.lab = Path(lab) + self.lock = (self.lab / 'rotation-campaign.lock').open('a') + fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def begin(self, mode): + state = read(self.lab) + if mode not in MODES or mode in state['runs']: + raise ValueError('invalid or repeated run') + state['runs'].append(mode) + write(self.lab, state) + + +def reserve(lab, mode, action): + state = read(lab) + if mode not in state['runs'] or action not in ACTIONS: + raise ValueError('invalid mode/action') + if any(x['mode'] == mode and x['action'] == action for x in state['reservations']): + raise ValueError('duplicate action') + if action in ('R90', 'R0'): + if action == 'R0' and not any(x['mode'] == mode and x['action'] == 'R90' for x in state['reservations']): + raise ValueError('return without rotation') + if sum(x['action'] in ('R90', 'R0') for x in state['reservations']) >= 6: + raise ValueError('global rotation budget exhausted') + else: + if mode != 'private_mosaic': + raise ValueError('camera budget is Mosaic only') + if action == 'camera_return' and not any(x['action'] == 'camera_hide' for x in state['reservations']): + raise ValueError('camera return without hide') + if any(x['action'] == action for x in state['reservations']): + raise ValueError('global camera pair exhausted') + state['reservations'].append({'mode': mode, 'action': action, + 'reserved_ns': time.monotonic_ns()}) + write(lab, state) + return state diff --git a/tooling/probes/step20c-rotated-output/run.py b/tooling/probes/step20c-rotated-output/run.py new file mode 100644 index 0000000..d992ecb --- /dev/null +++ b/tooling/probes/step20c-rotated-output/run.py @@ -0,0 +1,95 @@ +"""EXPERIMENTAL adapter; inherited bounded process/socket cleanup stays intact.""" +import argparse +from pathlib import Path +import subprocess +import sys +from campaign import Campaign +from scenario import SCENARIO + +p = argparse.ArgumentParser() +p.add_argument('lab', type=Path) +p.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) +p.add_argument('--source-only', action='store_true', + help='generate and syntax-check the child without reserving a run') +a = p.parse_args() +lab = a.lab.resolve() +repo = Path(__file__).resolve().parents[3] +# WHY: one lock covers all modes, rotations and complete child cleanup. +# CONTRACT: repeated/failed runs cannot silently replenish campaign budgets. +# INVARIANT: no dynamic action is launched before its persistent reservation. +if not a.source_only: + campaign = Campaign(lab) + campaign.begin(a.mode) +s = (repo / 'tooling/probes/step20c-applied-state-input/run.py').read_text() +s = s.replace("f'{args.mode}-{args.kind}-asi{args.asi_mode}-{time.time_ns()}'", + "f'{args.mode}-rotated-{time.time_ns()}'") +s = s.replace('output HEADLESS-1 resolution 800x600 position -800 0', + 'output HEADLESS-1 resolution 1000x750 scale 1.25 position -800 0') +s = s.replace("Path('/usr/bin/sway')", "lab / 'standard-build/sway/sway'") +s = s.replace(" snapshot('startup')", " time.sleep(1.0)\n snapshot('startup')") +s = s.replace('import argparse', 'import argparse\nfrom PIL import Image, ImageDraw\nimport sys\nsys.path.insert(0, '+repr(str(Path(__file__).parent.resolve()))+')\nfrom campaign import reserve') +s = s.replace("run.mkdir(parents=True, mode=0o700)", """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) +""") +s = s.replace('env = os.environ.copy()', "(run / 'config').write_text((run / 'config').read_text() + f'output * bg {marker_path} fill\\n')\nenv = os.environ.copy()", 1) +start = s.index(" expected = {'HEADLESS-1':") +end = s.index(" for output, side in", start) +s = s[:start]+''' 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 = globals().get('rotation_stage') == 'R90' + 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) +'''+s[end:] +s = s.replace("['grim', '-o', output, str(path)]", "['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)]") +s = s.replace(" record('capture', tag=tag", """ 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""") +start=s.index('def inject(action, x, y):');end=s.index('\ndef client_command',start) +s=s[:start]+'''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) + +'''+s[end:] +start=s.index(" if args.kind == 'cycles':\n");end=s.index('except Exception as exc:',start) +scenario=SCENARIO +s=s[:start]+scenario+'\n'+s[end:] +generated=lab/f'rotated-{a.mode}.py' +generated.write_text(s) +if a.source_only: + import ast + ast.parse(s, filename=str(generated)) + print(f'SOURCE_ONLY_PASS={generated}') + raise SystemExit(0) +raise SystemExit(subprocess.call([sys.executable,str(generated),str(lab),a.mode,'cycles','--max-seconds','360','--max-captures','120','--max-bytes','70000000'], pass_fds=(campaign.lock.fileno(),))) diff --git a/tooling/probes/step20c-rotated-output/scenario.py b/tooling/probes/step20c-rotated-output/scenario.py new file mode 100644 index 0000000..99b5dff --- /dev/null +++ b/tooling/probes/step20c-rotated-output/scenario.py @@ -0,0 +1,94 @@ +"""Rotation scenario inserted into the inherited bounded runner try/finally.""" +from pathlib import Path + +# Reuse the published two-reference correction and real XDG-grab helpers. +# WHY: both references are positioned before either input calibration is scored. +# CONTRACT: the dynamic-output fixture source is read-only; no old campaign runs. +repo = Path(__file__).resolve().parents[3] +namespace = {} +exec((repo / 'tooling/probes/step20c-dynamic-output/scenario.py').read_text(), namespace) +PREFIX = namespace['SCENARIO'].split(" calibrate('D0')", 1)[0] +PREFIX = PREFIX.replace(" command('output * bg #1e1e2e solid_color')\n", '') + +SCENARIO = PREFIX + r''' + # 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 + + rotation_stage = 'R0' + marker_gate('startup', False) + calibrate('R0') + command('[app_id="org.mosaic.NestedGrabProbe"] focus') + stable('R0', 'HEADLESS-1'); owner_hit('R0') + open_chain('R0-chain'); stable('R0-popups', 'HEADLESS-1') + + reserve(lab, args.mode, 'R90') + record('dynamic_launch', action='R90', + command='output HEADLESS-1 mode 750x1000 scale 1.25 transform 90 position -800 0') + command('output HEADLESS-1 mode 750x1000 scale 1.25 transform 90 position -800 0') + rotation_stage = 'R90' + stable('R90-popups', 'HEADLESS-1') + command('workspace 3') + time.sleep(.5) + snapshot('R90-marker') + marker_gate('R90-marker', True) + command('workspace 1') + stable('R90-workspace-return', 'HEADLESS-1') + closed(); calibrate('R90') + command('[app_id="org.mosaic.NestedGrabProbe"] focus') + owner_hit('R90') + open_chain('R90-chain'); stable('R90-reopened', 'HEADLESS-1') + + if args.mode == 'private_mosaic': + initial_camera = workspace()['mosaic']['rows'][0]['scroll_x'] + reserve(lab,args.mode,'camera_hide'); record('dynamic_launch',action='camera_hide') + scroll(1280); stable('R90-hidden', 'HEADLESS-1') + reserve(lab,args.mode,'camera_return'); record('dynamic_launch',action='camera_return') + scroll(initial_camera); stable('R90-camera-return', 'HEADLESS-1') + a = configured('POPUP_A'); b = configured('POPUP_B'); o = owner() + sx = workspace()['mosaic']['rows'][0]['scroll_x'] + bx = int(o['rect']['x']-sx+o['window_rect']['x']+a[0]+b[0]+35) + by = int(o['rect']['y']+o['window_rect']['y']+a[1]+b[1]+30) + before = len(client_text()) + inject('move',bx,by); inject('right',bx,by) + record('restored_popup_hit',point=[bx,by], + delivered='POINTER_BUTTON target=POPUP_B' in client_text()[before:]) + + reserve(lab,args.mode,'R0') + record('dynamic_launch',action='R0', + command='output HEADLESS-1 mode 1000x750 scale 1.25 transform normal position -800 0') + command('output HEADLESS-1 mode 1000x750 scale 1.25 transform normal position -800 0') + rotation_stage = 'R0' + stable('R0-return', 'HEADLESS-1') + if args.mode == 'private_mosaic': + command('layout splith') + stable('standard-return', 'HEADLESS-1') +''' diff --git a/tooling/probes/step20c-rotated-output/test-budget.py b/tooling/probes/step20c-rotated-output/test-budget.py new file mode 100644 index 0000000..8447b45 --- /dev/null +++ b/tooling/probes/step20c-rotated-output/test-budget.py @@ -0,0 +1,40 @@ +"""Exercise the actual ledger without launching a compositor.""" +import subprocess +import sys +import tempfile +from pathlib import Path +from campaign import Campaign, read, reserve + +lab = Path(tempfile.mkdtemp(prefix='rotation-budget-test-')) +campaign = Campaign(lab) +campaign.begin('control') +contender = subprocess.run([sys.executable, '-c', + 'from campaign import Campaign; Campaign(' + repr(str(lab)) + ')'], + cwd=Path(__file__).parent, capture_output=True, text=True) +assert contender.returncode and 'BlockingIOError' in contender.stderr +for mode in ('control', 'private_standard', 'private_mosaic'): + if mode != 'control': + campaign.begin(mode) + reserve(lab, mode, 'R90') + reserve(lab, mode, 'R0') + try: + reserve(lab, mode, 'R90') + except ValueError: + pass + else: + raise AssertionError('duplicate transition accepted') +reserve(lab, 'private_mosaic', 'camera_hide') +reserve(lab, 'private_mosaic', 'camera_return') +state = read(lab) +assert len(state['reservations']) == 8 +campaign.lock.close() +assert read(lab) == state +old = lab / 'old' +(old / 'runs/control-rotated-failed').mkdir(parents=True) +try: + read(old) +except ValueError: + pass +else: + raise AssertionError('missing ledger accepted') +print('PASS lock, persistence, duplicate, exhaustion, missing ledger; no compositor launched')