docs(mosaic): preserve dynamic output ownership probe evidence

This commit is contained in:
fy59 2026-10-12 15:41:45 +02:00
parent 8345aff0f9
commit 2882f74159
15 changed files with 1020 additions and 0 deletions

View file

@ -4,6 +4,8 @@
### STEP20C experimental evidence
- 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.
- Added a source-only private effects/damage probe with a real translucent SHM buffer and striped background. Both standard controls show 27,594 shadow-overflow pixels on the neighboring output; private Mosaic excludes that sampled spill, renders a visibly filtered blur within its viewport and restores exact settled pixels after three camera pairs. Sampled private damage covers changed pixels, while interior shadow, optimized cache, popup effects and saved buffers remain unqualified. The result is PARTIALLY_FEASIBLE; no production or package files changed.

View file

@ -247,3 +247,17 @@ are retained. The two-pair global budget was respected. The next distinct gate
should test resize/migration of owner/output clips; rotation, saved buffers and
ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**,
production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**.
The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md)
is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace
migration H1→H2→H1 retain workspace/tiled identities and update sampled applied
tiled/root-popup clips. Vacated outputs show exact background after stabilization.
Two standard controls fail final reference calibration through fixture occlusion;
Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped
popup input, independent opaque tiled spill, effects and intermediate frames
remain unqualified. Budgets are exhausted at six reconfigurations and six
migrations, with zero additional camera pairs. No production change follows.
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**.

View file

@ -186,3 +186,17 @@ are retained. The two-pair global budget was respected. The next distinct gate
should test resize/migration of owner/output clips; rotation, saved buffers and
ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**,
production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**.
The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md)
is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace
migration H1→H2→H1 retain workspace/tiled identities and update sampled applied
tiled/root-popup clips. Vacated outputs show exact background after stabilization.
Two standard controls fail final reference calibration through fixture occlusion;
Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped
popup input, independent opaque tiled spill, effects and intermediate frames
remain unqualified. Budgets are exhausted at six reconfigurations and six
migrations, with zero additional camera pairs. No production change follows.
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**.

View file

@ -105,3 +105,10 @@ Preserved experimental evidence: [fractional scaling probe](design/STEP20C_FRACT
and [source fixture](../tooling/probes/step20c-fractional-scale/README.md).
PARTIALLY_FEASIBLE for the sampled static scale matrix, independently replayed
without new camera pairs; no production qualification.
Preserved experimental evidence: [dynamic output ownership report](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md)
and [source fixture](../tooling/probes/step20c-dynamic-output/README.md).
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.

View file

@ -190,3 +190,17 @@ are retained. The two-pair global budget was respected. The next distinct gate
should test resize/migration of owner/output clips; rotation, saved buffers and
ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**,
production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**.
The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md)
is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace
migration H1→H2→H1 retain workspace/tiled identities and update sampled applied
tiled/root-popup clips. Vacated outputs show exact background after stabilization.
Two standard controls fail final reference calibration through fixture occlusion;
Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped
popup input, independent opaque tiled spill, effects and intermediate frames
remain unqualified. Budgets are exhausted at six reconfigurations and six
migrations, with zero additional camera pairs. No production change follows.
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**.

View file

@ -0,0 +1,381 @@
# STEP20C dynamic output geometry and workspace ownership probe
**EXPERIMENTAL / PARTIALLY_FEASIBLE measured subset.** The protocol below
was recorded before dynamic runs; the measured campaign follows it.
STEP20A DONE, STEP20B EXPERIMENTAL, STEP20C NEXT, STEP20D–G PLANNED.
Production multi-output remains NOT YET SUPPORTED. No UX change, production
repair or package/dependency change follows from this probe. The later
dynamic-evidence freeze ticket authorizes publication of this report and its
source-only fixture, without publishing raw laboratory captures.
The fractional evidence freeze passed at `8345aff0f97d381b768233021fc5c881ec794094`
on local main, Forgejo and GitHub, with a clean worktree at the gate.
## Scope and historical evidence
The [fractional probe](STEP20C_FRACTIONAL_SCALING_PROBE.md) qualified sampled
static 800×600 logical boxes with scales 1/1.25/1.5, real nested grabs,
negative origins, boundary hovers, visible blur and excluded shadow spill.
Its result remains PARTIALLY_FEASIBLE. Independent opaque tiled spill,
interior shadow, restored camera hit, optimized caches, source sampling,
saved buffers and intermediate frames remain unqualified. This campaign
asks whether applied viewports and popup clips follow real output resize
and workspace migration. It does not infer atomicity from IPC success.
## Pinned source audit before measurements
SwayFX control: `fd71a6bdc061bd633b488ae7b83e8a6981d22f86` (0.6).
Private Mosaic base: `cd550382b311efdbfe3ed892f4e1d281d852e754` plus the
preserved popup/hidden-owner, temporal damage and presentation patches.
SceneFX: `37ccd723bef49e6891156ffafce8f549f01446cc` (0.5) plus its preserved
private inherited clip and damage trace. wlroots source:
`d783533489e1f75d6886c2ab5c5960090ef268f8` (0.20.2), matching linked version.
The private reconstruction uses the fractional preparer; no old /tmp lab is
a dependency. New instrumentation only reads state in existing callbacks.
| Path | Ownership and ordering established by source |
| --- | --- |
| sway/config/output.c, apply_output_configs | Pending mode/scale is built (scale rounded to 1/120), output states built then backend committed. finalize_output_config places each output in wlr_output_layout; a second pass output_update_position copies the actual logical layout box, then arrange_layers, arrange_root and transaction_commit_dirty run. Optimized blur layer is marked dirty. Input mappings/cursor images are reconfigured, pointer focus cleared and cursor_rebase called. Backend commit precedes final layout/scene arrangement: no atomic applied-scene claim. |
| wlroots types/output/output.c | effective_resolution divides transformed physical dimensions by scale into integer logical dimensions; normal 1200×750 at 1.25 yields 960×600. |
| sway/commands/output.c and output/mode.c | Normal output command parser supports mode/resolution, scale and position subcommands; the command and resulting IPC geometry must both be checked. |
| sway/commands/move.c | move workspace to output uses handler_context.workspace, detaches/reattaches it, arranges old/new outputs, focuses an inactive node within that workspace and considers pointer warp. The usual command/transaction path applies dirty state. |
| sway/tree/workspace.c, workspace_move_to_output | workspace_detach removes old list membership, clears pending owner and dirties workspace/output. output_add_workspace preserves the object ID and attaches the new pending owner. Moving the last old workspace creates a replacement workspace with a different ID. Destination's old empty workspace can be destroyed by workspace_consider_destroy. |
| sway/tree/arrange.c | arrange_workspace takes new owner usable_area and global origin; floating coordinates adjust separately. apply_mosaic_layout computes cells/gaps and clamps logical scroll to real row overflow. Four 640-wide cells without gaps occupy 2560; viewport 800/960 gives maximum 1760/1600. Initial camera 400 remains within both bounds; the clamp branch is not promised exercised. |
| sway/desktop/transaction.c private candidate | Current output/workspace lists reparent tiling layers; tiling node local x=usable x+gap-scroll. Its clip local x=scroll, width/height=usable area minus gaps. Thus scene global position plus clip origin gives viewport. arrange_root subsequently calls arrange_popups. |
| private root popup clip | Actual view container current workspace/output and current top tiled ancestor identify owner. Applied tiling clip/global scene coordinates are intersected with output box. Hidden-owner guard tests applied owner rectangle. Root popup clip becomes global viewport minus root-popup scene origin. No owner pointer is retained in clip state. |
| sway/desktop/xdg_shell.c | Nested popup_create inherits view; parent scene tree preserves hierarchy. Root descriptor uses view content_tree as relative node; configure/unconstrain uses pending owner on initial commit or explicit reposition. Destroy listeners free popup tree. Migration alone must not be assumed to reconfigure XDG geometry. |
| SceneFX types/scene/wlr_scene.c | set_position and reparent trigger scene_node_update. Cached visibility and new bounds determine affected output damage. set_clip updates inherited clip and scene visibility. node_coords sums ancestors; node_at checks inherited logical clip before surface input. Damage subtracts output origin then scales/expands/transforms into physical buffer-local bounds. Output commits/resizes can invalidate whole damage. |
| wlroots types/wlr_cursor.c | Unmapped virtual pointer normalizes into actual combined logical output-layout box. No physical scale in pointer coordinates. Focus/grab dismissal and scene hit remain separate observations. |
These paths identify pending owner mutation, output logical geometry update,
scroll clamp, transaction application, tiled reparent/position, global popup
rearrangement, clip calculation, pointer rebase and old/new damage. They do
not establish a universal generation ID or a distinct frame for every IPC.
## Geometry, scenarios and hypotheses
D0: H1 1000×750 scale 1.25 at (-800,0), logical 800×600; H2 800×600 scale 1
at (0,0), logical 800×600. Both normal. Workspace 1 starts on H1; workspace 2
contains an ordinary reference on H2. Retain workspace IDs, tiled IDs/order,
XDG map/grab serials, focus and output ownership throughout.
D1 requests `output HEADLESS-1 mode 1200x750 scale 1.25 position -960 0`.
Require physical 1200×750 and logical (-960,0,960,600), H2 unchanged.
D2 restores mode 1000×750 and position -800,0 at the same scale.
D3 moves the verified workspace 1 to H2; D4 observes its pre-opened A/B
hierarchy and any legitimate popup_done. If destroyed, distinct newly grabbed
objects may be created after migration, without asserting persistence.
D5 returns the same workspace to H1. Do not compare the replacement old
workspace with the moved workspace. No additional output/workspace transitions
are allowed for fixture repair after budget consumption.
Hypotheses: viewport follows applied usable area; scroll remains logical and
within recalculated extent; root clip follows current owner, nested clip is
inherited; old output redraws its new active workspace without Mosaic residue;
logical pointer calibration survives changed negative origin. A normal XDG
close or focus warp is not itself a clipping defect.
## References, input, stabilization and damage
Use stable dark background, deterministic red ordinary tiled client, distinct
SHM toplevel, yellow A and green B, plus ordinary wev references. Identify
opaque toplevel geometry/pixels explicitly; translucency from the historical
fractional client is not an opaque-overflow control. A new opaque client build
may replace only SHM alpha, without changing the compositor. Standard tiling
is not a negative opaque-spill control. Independent tiled overflow without
clip ablation remains PARTIAL if not exercised.
Before each capture retain get_outputs/get_workspaces/get_tree and current
private scene/clip trace. Require stable repeated output/workspace geometry,
then retain native accepted commit/present where available and capture twice
for settled equality; timestamps do not imply atomic input/frame identity.
Unestablished stability is INCONCLUSIVE. Compare the vacated old output to its
new active workspace/reference, not the pre-migration image.
Pointer normalization is recomputed from active output rectangles on every
injection: min_x/min_y, max right/bottom, extent and global-minus-min. The
injector accepts that logical extent per event; it never applies scale. After
resize/restoration/migration/return, empirically calibrate ordinary references
on both outputs using enter OR motion and independent (+40,+20) local delta;
retain button delivery and boundary crossing. Calibration failure invalidates
dependent input assertions. Visible owner, popup interior, clipped portion,
new/vacated viewport points and old/new output points use applied coordinates.
Scene hit and client delivery under grabs are distinct. Exterior click tests
are done after persistence observations so normal dismissal cannot obscure D4.
Damage traces are already physical buffer-local extents. Compare changes only
between matching physical-size settled images; for a changed-size buffer,
report new-area coverage separately. No second scale conversion. Optimized
blur, every intermediate frame, minimal damage, all effects and saved buffers
remain NOT TESTED unless directly exercised.
## Budgets and stopping rules
One persistent campaign ledger and exclusive flock spans all three modes.
Reserve each dynamic action before IPC launch; failed launched actions consume
the reservation, interrupted reservations remain consumed. Each mode gets at
most one D1, D2, D3 and D5: six resize/restoration actions and six migrations
globally. Camera maximum one pair globally; planned consumption zero pairs.
Initial camera setup 400 is not an extra hide/return pair. Never reset ledger
or use another lab to bypass counts. Failed modes remain in matrix. Static
analysis and offline budget tests need no dynamic reservation.
All private sources/builds/runs/PNGs/logs/manifests stay in one disposable LAB;
only source fixtures and passive patches live under tooling/probes.
No algorithm, shader, damage policy, callback order, hit testing, headless
backend or public ABI change. No commit/push in B.
FEASIBLE requires applied requested transitions, identity/owner/clip correctness,
settled pixel/hit agreement and compatible measured damage, with standard
controls passing. PARTIALLY_FEASIBLE leaves qualified transitions but open XDG,
effects/input/damage cells. INCONCLUSIVE means refused geometry or inadequate
fixture/state evidence. BLOCKED requires reproduced forbidden pixels/hits,
wrong-owner clip, corruption/crash or blocking ordinary-layout regression.
Preserve minimal reproducer and stop affected assertions; do not repair
production. A refused command, lawful popup_done, absent redundant enter,
normal focus change or correct camera clamp alone is not BLOCKED.
## Retained fixture incident before the Mosaic run
The ordinary control completed all four dynamic actions and retained D5 popup
captures, then timed out during D5 left-reference calibration. Both references
had migrated with workspace 1. The sequential adapter moved the left reference
to its target before moving the right reference away; the latter occluded the
former. The right log recorded the requested (20,30) enter while the left log
did not. This is reference-placement ambiguity, not evidence of a Mosaic input
failure. The private-standard run already launched with the same source is
retained. No dynamic action is replayed. The final fixture moves both references
before scoring either, and uniquely retained per-run sources distinguish the
versions. Earlier D5 input-dependent assertions remain NOT TESTED if unscored.
## Measured campaign — 2026-10-12
**CURRENT evidence: EXPERIMENTAL / PARTIALLY_FEASIBLE.** The protocol above
was written before measurement. Laboratory:
`/tmp/swayfx-mosaic-nested-grab-0zJrQnLj`. All sources were reconstructed from
pinned Git inputs, not copied from the historical fractional lab. Private
SceneFX/Mosaic and pristine SwayFX built; patched clients passed C17 -Werror.
Model Meson test passed 1/1. Runtime uses actual FX GLES 3.2 Mesa
26.2.4-arch1.1 / Radeon 780M; the inherited pixman environment request does not
identify the renderer actually selected by SwayFX.
Three attempted runs retained 230 PNGs: ordinary control 72, private splith 72,
private Mosaic 86. Both controls ended with the documented reference-occlusion
calibration failure after all four transitions and D5 captures. Their compositor
exit status was zero; they are **not** whole-run PASS. Mosaic completed with
failure=null. No failed action/run was replayed or removed. Per-run generated
source SHA-256: both controls
`795dd53d7bcc2ea40f35cba7d88c97af316a6057d7631f214fdbd1a38dc0da4c`;
Mosaic corrected-reference fixture
`63cff2a4521e06f1d7f9ba04f4a7049956134fcfe589e89e1352ee03d31e5096`.
### Actual geometry, identity and camera
| Stage | H1 actual physical / scale / logical global rect | Workspace 1 owner | Workspace ID / tiled ID |
| --- | --- | --- | --- |
| D0 | 1000×750 / 1.25 / (-800,0,800,600) | H1 | 6 / 8 |
| D1 | 1200×750 / 1.25 / (-960,0,960,600) | H1 | 6 / 8 |
| D2 | 1000×750 / 1.25 / (-800,0,800,600) | H1 | 6 / 8 |
| D3 | restored D0 geometry | H2 | 6 / 8 |
| D5 | restored D0 geometry | H1 | 6 / 8 |
H2 stayed physical 800×600 / scale 1 / (0,0,800,600), normal transform.
All three modes applied this matrix, confirmed by outputs/workspaces/tree,
physical PNG dimensions and retained repeated-state observations. Workspace
1's tiled order stayed [7,8,9,10]. In standard layouts its owner width changed
200→240→200; Mosaic remained four 640-wide cells, row extent 2560.
The base fixture initially set camera 400; calibration focus/temporary reference
insertion subsequently produced **observed camera 640** before D0 scoring.
No additional explicit camera scroll or hide/return pair was issued. The
camera stayed 640 in D0/D1/D2/D3/D5 scored snapshots. Actual maximum overflow
was 1760 at width 800 and 1600 at width 960, so conservation is valid and the
maximum-bound clamp branch is **NOT TESTED**. Do not claim scroll 400 persisted
or that a clamp event was experimentally demonstrated.
| Stage | Applied tiling global origin | Local tiled clip | Derived global viewport |
| --- | --- | --- | --- |
| D0 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) |
| D1 | (-1600,0) | (640,0,960,600) | (-960,0,960,600) |
| D2 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) |
| D3 | (-640,0) | (640,0,800,600) | (0,0,800,600) |
| D5 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) |
All 42 pre-capture tiled-clip observations matched their actual output rect.
DO_POPUP records for actual owner 8/workspace 6 derive the same three global
viewport rectangles from root position plus local clip, and change owner_output
H1→H2→H1. Ignore view-less popup-layer descriptors with owner_id=0 as tiled-XDG
ownership evidence. A callback output label is the observer, not necessarily
the popup's owner. No stale old-owner clip is observed in retained stable states.
The last H1 workspace's departure created workspace 3, ID 13, on H1. On return,
H2 acquired a replacement workspace 2 with **new ID 14**; its initial workspace
2 had ID 5. Workspace 1 remained ID 6 throughout. Calibration later moves the
ordinary floating references between these active workspaces; that is not a
workspace migration or a scientific camera action.
### Popups, input and pixels
Every recorded grab used the real unchanged received button serial: 10 grabs
in each control, 12 in Mosaic. A/B were pre-opened for each transition. Both
migrations retained the then-open root/nested hierarchy in sampled captures;
D3 had no popup_done. The D3 exterior right click later produced one A and one
B popup_done in all modes, then a distinct valid hierarchy was recreated before
D5. D5's cumulative done counts remained 1/1: no new migration dismissal was
observed. Wire IDs can be reused after explicit destruction; recreated chains
are distinct objects. Configure/ack/map, client logs and private root-tree
identities are retained; this does not promise universal popup persistence.
Mosaic ordinary-reference calibration passed on both outputs at D0/D1/D2/D3/D5,
with independent logical (+40,+20) local motion and left/right delivery. Pointer
normalization changed 1600×600→1760×600→1600×600 from applied output bounds,
with min_x -800→-960→-800, never physical scaling. Both standard controls passed
D0/D1/D2/D3; their final D5 calibration/hit is NOT TESTED because of the retained
occlusion incident. Mosaic owner right buttons arrived at all five stages,
and visible popup interactions used the new owner's applied coordinates.
Exterior (-20,190) on old H1 after D3 and (20,190) on H2 after D5 delivered no
Mosaic popup button. **These are outside points, not demonstrated clipped
popup portions**: at the measured camera 640 the Mosaic b_cross A/B boxes fit
inside the viewport. No extra camera pair was spent to force spill. Thus
migration clip reassociation is directly traced, but clipped-portion input
and a real Mosaic popup overflow challenge after migration remain NOT EXERCISED.
Controls did show real nested spill on H2 before migration: 15,860 green pixels
in D0/D2/D5 and 260 after D1. Do not substitute those differently arranged
standard boxes for a same-geometry negative Mosaic control.
Mosaic D3's vacated H1 was exactly 750,000 dark background pixels, with zero
owner/A/B colors; D5's vacated H2 was exactly 480,000 background pixels.
These full-image checks support absence of any stale rendered residue in those
settled samples, rather than checking only exact popup colors. The destination
showed owner and permitted yellow/green popup pixels: D3 H2 owner 264,148,
A 42,200, B 39,000; D5 H1 owner 412,930, A 66,075, B 61,125. All 24 Mosaic
capture-pair comparisons were identical. All 72 corresponding standard/private-standard PNGs were pixel-identical,
including their retained final D5 captures.
The restored opaque XRGB SHM owner is actually visible and interactive, unlike
using the historical translucent S1 name as opaque proof. Its source buffer
stays 420×320 while configure sizes differ; compositor applied container,
surface and rendering sizes must remain distinct. Ordinary wev references also
retain their 640×480 surface despite smaller requested floating frames. No
independent unclipped **tiled opaque spill** control was exercised. That
assertion remains PARTIAL; a private clip ablation was not performed. Newly
included/vacated viewport-specific hit points were not independently scored.
Shadows/blur were disabled here; dynamic effects, optimized cache correctness,
saved buffers and intermediate frames remain NOT TESTED.
### Damage, commit/present and boundedness
Private splith recorded 1,760 damage events; Mosaic recorded 1,585.
Mosaic native traces contained 412 accepted commit identities, 404 present
identities, 404 matching pairs. Private splith recorded 362 / 354 / 354.
The terminal unpresented commits include teardown; counts alone are not a
render defect. Ordinary control has no native damage/commit instrumentation.
No frame identity is inferred for a screenshot or input event.
Physical extents covered every measured equal-size settled pixel difference.
For Mosaic migration D3, H1/H2 changed 625,428 / 370,119 pixels; D5 changed
578,428 / 400,119. Each had zero changed pixels outside available extents.
For resize, compare common buffer-local pixels and all new-buffer area rather
than asserting equal-sized images: D1 adds 150,000 physical pixels and changes
223,000 common pixels; D2 changes 52,974 common pixels. Coverage outside the
available physical extent union was zero. Retired old-only buffer area, minimal
damage and each intermediate frame are not qualified by this calculation.
Broad/full-output invalidation is compatible with these observations.
The persistent locked ledger contains exactly D1/D2/D3/D5 once per mode:
**reconfiguration 6/6, migration 6/6, camera pairs 0/1**. Both failed controls
consume all their launched actions. Offline tests exercised the actual lock,
persistence, duplicate/exhaustion and missing-ledger rejection. The inherited
lock descriptor also stays open in the generated child. No automatic refunds,
reset, additional lab or dynamic replay was used.
All three runtime directories were removed and all owned client/compositor
processes terminated. No production files, historical patches, cumulative
patch, PKGBUILD, UX, AGENTS, system libraries, desktop session or dotfiles
changed. No live IPC, install, STEP20D, commit or push occurred in B.
### Architectural answers and remaining gate
| Question | Evidence-backed answer |
| --- | --- |
| Q1 viewport after logical resize | Yes in measured D1/D2: 800→960→800 logical width with corresponding origins. |
| Q2 camera | Conserved 640 within real maxima 1760/1600; maximum clamp branch NOT TESTED. |
| Q3 tiled clip | Yes, local clip width and global scene translation yield each applied output rectangle. |
| Q4 popup output owner | Yes for mapped owner-8 root/nested hierarchies in the sampled states; current owner follows workspace H1→H2→H1. |
| Q5 stale clip | None in stable retained traces/captures; no every-intermediate-frame proof. |
| Q6 workspace identity | Workspace 6/name 1 and tiled 8 survive both migrations; order/layout retained. |
| Q7 last old workspace | Departure creates workspace 13/name 3 on H1; return creates workspace 14/name 2 on H2. These are not workspace 6. |
| Q8 old pixels | Vacated outputs are exactly their new empty workspace's dark background before reference calibration. |
| Q9 new rendering/effects | Owner and permitted A/B pixels appear at the new scale; effects disabled and NOT TESTED. |
| Q10 hit/viewport | Calibrated visible owner/popups and exterior points agree; newly included points and clipped-popup portions NOT EXERCISED. |
| Q11 negative-origin pointer | Yes, measured logical extent changes to 1760 and both reference deltas/buttons pass in Mosaic. |
| Q12 damage coverage | Available physical extent unions cover settled changes/new area; no minimal, retired-buffer or per-frame guarantee. |
| Q13 ordinary rules | Cursor rebase/focus changes, replacement workspaces, explicit popup destruction/recreation and exterior-click popup_done are ordinary paths, not clip defects. |
| Q14 redesign | No reproduced wrong-owner clip, invisible hit, crash or persistent pixel leak requires redesign here. Incomplete assertions are not a proof of complete correctness. |
| Q15 next gate | Rotation is the next distinct geometric gate: this campaign changes owner and scale/origin/width but uses only normal transforms. Before production, separately close opaque overflow, maximum-clamp, migrated clipped-popup input, remaining effects/saved buffers and SceneFX ABI/package gates. Repair the reference fixture before future comparisons; do not replay this exhausted campaign. |
**DYNAMIC_OUTPUT_OWNERSHIP_FEASIBLE_FOR_TESTED_MATRIX**, limited to the stable
normal-transform geometry/ownership subset actually measured. Overall probe
**PARTIALLY_FEASIBLE** due to the standard D5 fixture failures, unexercised
maximum clamp/migrated popup spill and effects/input/damage scope limits.
STEP20C remains NEXT; production multi-output NOT YET SUPPORTED.
`SWAYFX_MOSAIC_DYNAMIC_OUTPUT_OWNERSHIP_PROBE=PARTIALLY_FEASIBLE`
### Final provenance and technical validation
The 916-entry source manifest `results/source-sha256.txt` has SHA-256
`a4071f6bf3d9a0825954c1b0c504502a3d4c007d83eab2bae8de51fb58594ee4`.
The 529-entry complete raw-evidence manifest `results/evidence-sha256.txt`
has SHA-256
`48d8126b84a000ca2ac6ca3e7e7d1b465b6e63bda18c168a128fe6fe660c695a`.
Both verified entry by entry. Source inventory includes the complete pinned
wlroots 0.20.2 source archive used for audit; replay depends on pinned sources
and installed matching wlroots, not on an old temporary lab.
The subsequent freeze audit verified all 529 original evidence-manifest entries
and the expected manifest SHA-256 before invoking the offline analyzer. That
analyzer rewrote `results/evidence-sha256.txt` in the temporary lab, replacing
the original 529-entry file with a 528-entry inventory of the files present at
replay time (SHA-256 `3da3a499d5e398fbc85e85c0bd07bf0bf2bc9778e111b4ac7bae2e036e8af6a2`).
The raw runs and their 230 PNGs were not replayed or removed. The original
manifest bytes were not retained separately; its pre-replay verification is
recorded by the audit transcript and the closeout digest above. This is an
audit-tool side effect and a provenance limitation, not a new measurement or
a reason to upgrade the scientific verdict.
The source manifest likewise verified before this documentation freeze. Its
historical fixture-README entry records SHA-256
`0b35a83a1fb92e83be5d27c9acf9a38a4d017dc5a1018bf3a706b76b52f695f4`;
the published README now documents the later freeze and therefore no longer
matches that one historical source entry. The scripts and experimental patches
were not changed.
Final adapter reconstruction reproduced the measured corrected Mosaic generated
runner byte-for-byte offline, without reservation or launch. New private patches
reverse-applied then forward-applied in isolated files and reproduced measured
source bytes. All Python modules and retained generated runners passed AST;
shell syntax, C17 -Werror, private/pristine builds and model 1/1 passed.
Documentation links and ordinary git diff --check passed. Complete source-only
diff evidence is retained outside Git; new unified patch context whitespace
must be distinguished from whitespace in C/Python source additions.
Phase A publication remains `8345aff0f97d381b768233021fc5c881ec794094` on
local main, Forgejo origin/main and GitHub github/main. The gate worktree was
clean. B changes are confined to this report, its source fixture, README index,
Architecture, Known Limitations, Roadmap and Changelog, with an empty Git index.
No B commit or push occurred. All source/patch/package/session invariants above
remain in force.
A post-publication recheck of the historical fractional source manifest still
matches its recorded SHA-256 and verifies 38/39 paths against the current tree.
The sole changed path is the fractional fixture README: its historical bytes
(`6e800abfa2d48c047640fde787747fb9a6e6021152cb3718aafa7abea09f3bf5`)
remain in the independent freeze clone, while the published README includes
later freeze replay instructions. The historical 1,986-entry evidence manifest
and both independent freeze manifests verify completely. No historical
manifest was rewritten and no measurement source was changed by this README
update.
`SWAYFX_MOSAIC_FRACTIONAL_SCALING_FREEZE=PASS`
`SWAYFX_MOSAIC_STEP20C_FRACTIONAL_FREEZE_AND_DYNAMIC_OUTPUT_OWNERSHIP_V1=PARTIALLY_FEASIBLE`

View file

@ -0,0 +1,74 @@
# Dynamic output ownership source fixture
**EXPERIMENTAL / preserved source-only fixture.** Read the
[protocol and source audit](../../../docs/design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md).
No production support or UX contract follows. Reconstruct in a full clone
containing `8345aff0f97d381b768233021fc5c881ec794094` and copy this local fixture.
Old temporary laboratories are not dependencies. No install or live IPC.
The preparer reuses pinned source-only fractional dependencies, builds private
SceneFX/Mosaic and pristine SwayFX 0.6, then compiles the per-event logical-extent
pointer and opaque SHM client with C17. Passive output traces preserve callback
order and clip/render decisions. All artifacts stay in its printed LAB.
Run each mode once in the same campaign. Never delete/reset the ledger or
create another lab to evade failed-action consumption. The runner reserves
D1/D2/D3/D5 before IPC; camera pairs are zero. Each run owns private runtime,
Wayland/IPC sockets and children, which its finally block removes.
```sh
sh tooling/probes/step20c-dynamic-output/prepare.sh
python tooling/probes/step20c-dynamic-output/test-budget.py
python tooling/probes/step20c-dynamic-output/run.py "$LAB" control
python tooling/probes/step20c-dynamic-output/run.py "$LAB" private_standard
python tooling/probes/step20c-dynamic-output/run.py "$LAB" private_mosaic
python tooling/probes/step20c-dynamic-output/analyze.py "$LAB"
```
Every IPC uses the private socket. Pointer coordinates derive from current
get_outputs logical bounds per event. No physical scaling in injection.
Failures remain scientific exclusions, not automatic clipping defects.
## Observed campaign
The one measured campaign at `/tmp/swayfx-mosaic-nested-grab-0zJrQnLj` applied
D1/D2/D3/D5 in all three modes: logical H1 width 800→960→800, workspace 6 and
tiled 8 preserved, H1→H2→H1 ownership and applied clips reassociated. It retained
230 PNGs. Mosaic completed; both controls completed transitions but failed
final calibration through temporary reference occlusion. Their generated
sources remain in their run directories; the final fixture relocates both
references before testing either. No failed dynamic action was replayed.
Result PARTIALLY_FEASIBLE. Stable vacated outputs are exact background;
Mosaic owner hits and both-output calibration pass at all scored stages.
Observed camera 640 remains within recalculated bounds; max-clamp is NOT TESTED.
Mapped popup boxes fit inside the Mosaic viewport at that camera: exterior
points are not proof of a clipped portion after migration. Opaque tiled spill
without a negative clip control, effects (disabled), newly included point hits,
intermediate frames, saved buffers and ABI/package remain unqualified.
Consumed budgets: six reconfigurations, six migrations, zero camera pairs.
The ledger is exhausted for dynamic operations: do not run any mode again in
this campaign. Only offline analysis/validation can be repeated. New campaign
measurements require a separately authorized scope and budget.
`scenario.py` stores source text as a Python constant; `run.py` inserts it into
the inherited bounded runner. Python AST checks cover the modules and all
retained generated runners. Clients compile under C17 -Werror. New patch hashes:
- pointer-dynamic-extents.patch: `b07b1549f541cc55e1170737ea7aa4b869cd97f3411c1b103be1b96b21971eef`
- ownership-trace.patch: `4a4a48566bd5d5fe8edf6146548cbcf4f93500cc62421780b75cd1dcb82abcb6`
The report distinguishes passive callback observation, settled capture and
client delivery. Raw runs, source/evidence manifests and structured analyses
remain outside Git. The original 529-entry evidence manifest and its expected
SHA-256 were verified before an offline analyzer rerun replaced that temporary
manifest with a 528-entry replay inventory. See the report's provenance note;
the original manifest bytes are no longer present in the lab.
For an independent wlroots source audit, fetch the authoritative wlroots Git
repository and inspect commit `d783533489e1f75d6886c2ab5c5960090ef268f8`
(tag 0.20.2). The campaign retains its complete source archive outside Git as
`wlroots-audited-source.tar`; replay binaries link installed wlroots 0.20.2,
without rebuilding or modifying that system library. pkg-config names are
`wlroots-0.20`, `scenefx-0.5` and `wayland-client`.

View file

@ -0,0 +1,119 @@
"""Offline full-image, applied ownership, clip and physical damage evidence."""
import argparse
import hashlib
import json
from pathlib import Path
import re
from PIL import Image, ImageChops, ImageDraw
from campaign import read, ACTIONS
COLORS={'owner':(35,68,204),'A':(224,192,32),'B':(32,208,160),'background':(30,30,46)}
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def nz(im):
r,g,b=im.split();return ImageChops.lighter(ImageChops.lighter(r,g),b)
def difference(a,b):
if a.size!=b.size:return {'status':'DIFFERENT_PHYSICAL_BUFFERS','before':list(a.size),'after':list(b.size)}
mask=nz(ImageChops.difference(a,b))
return {'status':'COMPARED','changed_pixels':a.width*a.height-mask.histogram()[0],'bbox':mask.getbbox()}
def fields(line):
# Contract: retain raw traces as well as parsed passive state, no inferred frames.
return dict(re.findall(r'(\w+)=([^\s]+)',line))
def analyze(run):
events=[json.loads(x) for x in (run/'events.jsonl').read_text().splitlines()]
log=(run/'compositor.log').read_text(errors='replace')
traces=[{'kind':k,**fields(line),'raw':line} for line in log.splitlines() for k in ('DO_STATE','DO_POPUP','PI_SCENE','PI_OWNER','PI_POPUP','PI_OUTPUT_COMMIT','PI_OUTPUT_PRESENT') if k+' ' in line]
damages=[{'ns':int(ns),'output':out,'extent':list(map(int,box))} for ns,out,*box in re.findall(r'TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)',log)]
geoms={(e['tag'],e['output']):e for e in events if e['kind']=='capture_geometry'}
captures=[];previous={};images={};transitions=[];stable=[];clip_checks=[]
for e in events:
if e['kind']!='capture':continue
g=geoms[(e['tag'],e['output'])];im=Image.open(run/e['path']).convert('RGB');images[(e['tag'],e['output'])]=im
assert list(im.size)==g['dimensions']==[g['mode']['width'],g['mode']['height']]
counts={name:sum(v==col for v in im.get_flattened_data()) for name,col in COLORS.items()}
observations={k:[t for t in traces if t['kind']==k and t.get('observer',t.get('output'))==e['output'] and int(t.get('ns',0))<=e['before_ns']][-1:] for k in ('DO_STATE','PI_SCENE')}
observation={'tag':e['tag'],'output':e['output'],'sha256':sha(run/e['path']),'geometry':g,'colors':counts,'scene':observations}
captures.append(observation)
state=observations['DO_STATE'];scene=observations['PI_SCENE']
if state and scene and state[0]['workspace']==scene[0]['workspace'] and scene[0].get('clip_valid')=='1':
sx,sy=map(int,state[0]['tile_global'].split(','));cx,cy,cw,ch=map(int,scene[0]['clip'].split(','))
clip_checks.append({'tag':e['tag'],'output':e['output'],'workspace':scene[0]['workspace'],'applied_global_viewport':[sx+cx,sy+cy,cw,ch],'output_rect':g['rect'],'matches_output': [sx+cx,sy+cy,cw,ch]==[g['rect'][k] for k in ('x','y','width','height')],'scope':'last observed pre-capture commit callback, no atomic capture identity'})
if e['output'] in previous:
older,oldim=previous[e['output']];d=difference(oldim,im)
intervals=[v for v in damages if v['output']==e['output'] and older['after_ns']<v['ns']<=e['after_ns']]
uncovered=None
if d['status']=='COMPARED' and damages:
mask=Image.new('L',im.size,0);draw=ImageDraw.Draw(mask)
for v in intervals:
x1,y1,x2,y2=v['extent']
if x2>x1 and y2>y1:draw.rectangle([x1,y1,x2-1,y2-1],fill=255)
changed=nz(ImageChops.difference(oldim,im)).point(lambda v:255 if v else 0)
uncovered=ImageChops.subtract(changed,mask).histogram()[255]
resized = None
if d['status']=='DIFFERENT_PHYSICAL_BUFFERS' and damages:
# WHY: a resized physical buffer cannot be compared as equal-sized PNGs.
# CONTRACT: compare common buffer-local pixels; score all new-area pixels.
# INVARIANT: retired old-only pixels are not part of the new buffer.
width,height=min(oldim.width,im.width),min(oldim.height,im.height)
changed=Image.new('L',im.size,255)
common=nz(ImageChops.difference(oldim.crop((0,0,width,height)),im.crop((0,0,width,height)))).point(lambda v:255 if v else 0)
changed.paste(common,(0,0))
mask=Image.new('L',im.size,0);draw=ImageDraw.Draw(mask)
for v in intervals:
x1,y1,x2,y2=v['extent']
if x2>x1 and y2>y1:draw.rectangle([x1,y1,x2-1,y2-1],fill=255)
resized={'common_buffer_local_pixels':width*height,
'new_area_pixels':im.width*im.height-width*height,
'changed_common_pixels':width*height-common.histogram()[0],
'uncovered_changed_or_new_pixels':ImageChops.subtract(changed,mask).histogram()[255],
'scope':'buffer-local common pixel delta and all new area, no global-world equivalence or retired-buffer claim'}
transitions.append({'output':e['output'],'from':older['tag'],'to':e['tag'],**d,'physical_damage_extents':intervals,'uncovered_changed_pixels':uncovered,'resized_buffer_coverage':resized})
previous[e['output']]=(e,im)
if e['tag'].endswith('-repeat'):
base=e['tag'][:-7]
if (base,e['output']) in images:stable.append({'tag':base,'output':e['output'],**difference(images[(base,e['output'])],im)})
client=(run/'client.log').read_text(errors='replace')
grabs=[{'popup':name,'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 name,target,serial,used in re.findall(r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\S+) serial=(\d+) used=(\d+)',client)]
popup_checks=[]
for t in traces:
if t['kind']=='DO_POPUP' and t.get('clip_valid')=='1' and t.get('global_valid')=='1':
px,py=map(int,t['global'].split(','));cx,cy,cw,ch=map(int,t['clip'].split(','))
applicable=[e for e in events if e['kind']=='state' and e['monotonic_ns']>=int(t['ns'])]
popup_checks.append({'ns':int(t['ns']),'owner_id':t['owner_id'],'workspace':t['workspace'],'owner_output':t['owner_output'],'tree':t['tree'],'global_clip':[px+cx,py+cy,cw,ch],'local_clip':[cx,cy,cw,ch]})
commits={(t['output'],t['commit_seq']) for t in traces if t['kind']=='PI_OUTPUT_COMMIT'}
presents={(t['output'],t['commit_seq']) for t in traces if t['kind']=='PI_OUTPUT_PRESENT'}
return {'run':run.name,'source_sha256':sha(run/'fixture-source.py'),'exit_status':json.loads((run/'exit-status.json').read_text()),'states':[e for e in events if e['kind']=='state'],'identities':[e for e in events if e['kind']=='identity'],'calibrations':[e for e in events if e['kind']=='pointer_calibration'],'inputs':[e for e in events if e['kind'] in ('owner_hit','forbidden_click','chain','pointer_request')],'popup_lifecycle':[e for e in events if e['kind']=='popup_lifecycle'],'grabs':grabs,'captures':captures,'stability':stable,'transitions':transitions,'tiled_clip_checks':clip_checks,'popup_clip_checks':popup_checks,'traces':traces,'damage_events':len(damages),'native_events':{'commits':len(commits),'presents':len(presents),'paired':len(commits&presents),'scope':'native private headless identity, no captured buffer identity'},'client_events':re.findall(r'^(?:POPUP_|POINTER_|TOPLEVEL)[^\n]*',client,re.M),'cleanup':[e for e in events if e['kind']=='cleanup']}
p=argparse.ArgumentParser();p.add_argument('lab',type=Path);a=p.parse_args();lab=a.lab.resolve();out=lab/'results';out.mkdir(exist_ok=True)
results=[analyze(r) for r in sorted((lab/'runs').glob('*-dynamic-*')) if (r/'exit-status.json').exists()]
state=read(lab);budget={'ledger':state,'consumed':{k:sum(ACTIONS[r['action']]==k for r in state['reservations']) for k in ('reconfiguration','migration')},'camera_pairs':0}
assert budget['consumed']['reconfiguration']<=6 and budget['consumed']['migration']<=6
mapping={'outputs-before-after':'states','workspaces':'identities','owner-identities':'identities','camera-states':'identities','popup-ownership':'popup_lifecycle','scene-clips':'tiled_clip_checks','pointer-calibration':'calibrations','input-delivery':'inputs','damage-analysis':'transitions','pixel-analysis':'captures','transition-timeline':'traces'}
(out/'analysis.json').write_text(json.dumps(results,indent=2)+'\n')
for filename,key in mapping.items():(out/(filename+'.json')).write_text(json.dumps([{'run':r['run'],'observations':r[key]} for r in results],indent=2)+'\n')
(out/'campaign-budget.json').write_text(json.dumps(budget,indent=2)+'\n')
summary={'runs':len(results),'failed_fixtures':sum(bool(r['exit_status']['failure']) for r in results),'budget':budget,'not_tested':['max-scroll clamp branch','optimized blur','all effects','clip ablation opaque overflow','every intermediate frame','atomic applied generation','rotation','saved buffers','ABI/package']}
matrix=[]
for r in results:
geometry={}
for tag in ('D0','D1-popup','D2-popup','D3-popup','D5-popup'):
samples=[e for e in r['states'] if e['tag']==tag]
if not samples:geometry[tag]='NOT TESTED';continue
e=samples[-1];h1=next(o for o in e['outputs'] if o['name']=='HEADLESS-1')
wanted={'x':-960,'y':0,'width':960,'height':600} if tag=='D1-popup' else {'x':-800,'y':0,'width':800,'height':600}
geometry[tag]='PASS' if h1['rect']==wanted and h1['current_mode']['width']==(1200 if tag=='D1-popup' else 1000) else 'INCONCLUSIVE'
matrix.append({'run':r['run'],'applied_geometry':geometry,'identities_preserved':len(set(e['workspace_id'] for e in r['identities']))==1 and len(set(e['owner_id'] for e in r['identities']))==1,
'settled_captures_identical':all(s.get('changed_pixels')==0 for s in r['stability']),
'D5_pointer_calibration':'PASS' if len([e for e in r['calibrations'] if e['tag']=='D5'])==2 else 'NOT TESTED / fixture occlusion',
'all_grabs_valid':all(g['valid'] for g in r['grabs']),
'max_scroll_clamp_branch':'NOT TESTED','newly_included_point_hit':'NOT TESTED','popup_clipped_portion_after_migration':'NOT EXERCISED: measured Mosaic camera fully contains popup rectangles',
'opaque_owner_visibility':'sampled XRGB pixels and owner hits; independent unclipped tiled spill control NOT TESTED',
'effects':'NOT TESTED: shadows and blur disabled'})
summary['result']='PARTIALLY_FEASIBLE' if len(results)==3 and all(all(v=='PASS' for v in m['applied_geometry'].values()) and m['identities_preserved'] and m['settled_captures_identical'] and m['all_grabs_valid'] for m in matrix) else 'INCONCLUSIVE'
summary['architectural_conclusion']='DYNAMIC_OUTPUT_OWNERSHIP_FEASIBLE_FOR_TESTED_MATRIX' if summary['result']=='PARTIALLY_FEASIBLE' else 'UNDECIDED'
summary['not_tested'] += ['clipped-popup portion after migration at measured camera','newly included viewport point hit','D5 standard-control calibration due reference occlusion']
(out/'matrix.json').write_text(json.dumps(matrix,indent=2)+'\n')
(out/'summary.json').write_text(json.dumps(summary,indent=2)+'\n')
files=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']+list(lab.glob('dynamic-*.py'))+[lab/'dynamic-campaign-budget.json',lab/'dynamic-build-log.txt']))
manifest=out/'evidence-sha256.txt';manifest.write_text(''.join(sha(p)+' '+str(p)+'\n' for p in files))
print(json.dumps({'runs':len(results),'summary':summary,'entries':len(files),'manifest_sha256':sha(manifest)}))

View file

@ -0,0 +1,79 @@
"""Persistent finite campaign budget; only caller holding Campaign.lock may mutate."""
import fcntl
import json
import os
from pathlib import Path
MODES = ('control', 'private_standard', 'private_mosaic')
ACTIONS = {'D1': 'reconfiguration', 'D2': 'reconfiguration',
'D3': 'migration', 'D5': 'migration'}
LIMITS = {'reconfiguration': 6, 'migration': 6, 'camera_pair': 1}
def read(lab):
path = Path(lab) / 'dynamic-campaign-budget.json'
if not path.exists():
if any((Path(lab) / 'runs').glob('*-dynamic-*')):
raise ValueError('missing ledger in existing campaign; no reset')
return {'version': 1, 'runs': [], 'reservations': []}
state = json.loads(path.read_text())
if state['version'] != 1 or len(state['runs']) != len(set(state['runs'])):
raise ValueError('invalid campaign runs/version')
if any(m not in MODES for m in state['runs']):
raise ValueError('invalid mode')
seen = set()
for r in state['reservations']:
key = (r['mode'], r['action'])
if key in seen or r['mode'] not in state['runs'] or r['action'] not in ACTIONS:
raise ValueError('invalid/duplicate persisted reservation')
seen.add(key)
return state
def write(lab, state):
# WHY: reservations survive launch failure and restart; never refund.
# CONTRACT: lock is held across write, launch, cleanup; atomic replacement.
# INVARIANT: preserve every old entry, persist before IPC is launched.
path = Path(lab) / 'dynamic-campaign-budget.json'
tmp = path.with_suffix('.tmp')
with tmp.open('w') as f:
f.write(json.dumps(state, indent=2) + '\n')
f.flush()
os.fsync(f.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 / 'dynamic-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('mode already attempted or invalid; no dynamic replay')
state['runs'].append(mode)
write(self.lab, state)
def reserve(lab, mode, action):
# CONTRACT: generated child runs under its parent's exclusive campaign lock.
state = read(lab)
if mode not in state['runs'] or action not in ACTIONS:
raise ValueError('unregistered mode/action')
if any(r['mode'] == mode and r['action'] == action for r in state['reservations']):
raise ValueError('action budget exhausted for mode')
kind = ACTIONS[action]
if sum(ACTIONS[r['action']] == kind for r in state['reservations']) >= LIMITS[kind]:
raise ValueError('campaign budget exhausted')
import time
state['reservations'].append({'mode': mode, 'action': action,
'reserved_ns': time.monotonic_ns()})
write(lab, state)
return state

View file

@ -0,0 +1,43 @@
--- a/sway/desktop/output.c
+++ b/sway/desktop/output.c
@@ -384,6 +384,20 @@
output->wlr_output->name, presentation_probe_now_ns());
return;
}
+ // WHY: correlate actual scene coordinates with current workspace ownership.
+ // CONTRACT: passive callback reads only; no state or callback-order changes.
+ // INVARIANT: output label is the observing callback, owner is current.output.
+ int tile_x = 0, tile_y = 0;
+ bool tile_valid = wlr_scene_node_coords(&ws->layers.tiling->node, &tile_x, &tile_y);
+ struct wlr_output *wlr = output->wlr_output;
+ sway_log(SWAY_INFO, "DO_STATE ns=%" PRIu64 " observer=%s workspace=%zu owner=%s"
+ " origin=%d,%d logical=%d,%d physical=%d,%d scale=%.2f transform=%d"
+ " tile_valid=%d tile_global=%d,%d",
+ presentation_probe_now_ns(), wlr->name, ws->node.id,
+ ws->current.output ? ws->current.output->wlr_output->name : "none",
+ output->lx, output->ly, output->width, output->height,
+ wlr->width, wlr->height, wlr->scale, wlr->transform,
+ tile_valid, tile_x, tile_y);
struct wlr_box clip = {0};
bool has_clip = wlr_scene_tree_get_clip(ws->layers.tiling, &clip);
double scroll = -1;
@@ -422,6 +436,19 @@
struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node);
struct wlr_box box = {0};
bool valid = wlr_scene_tree_get_clip(tree, &box);
+ struct sway_popup_desc *desc = scene_descriptor_try_get(node, SWAY_SCENE_DESC_POPUP);
+ struct sway_container *owner = desc && desc->view ? desc->view->container : NULL;
+ struct sway_workspace *owner_ws = owner ? owner->current.workspace : NULL;
+ int popup_x = 0, popup_y = 0;
+ bool popup_valid = wlr_scene_node_coords(node, &popup_x, &popup_y);
+ // CONTRACT: tree identity is process-local; view/current owner is authoritative.
+ sway_log(SWAY_INFO, "DO_POPUP ns=%" PRIu64 " tree=%p owner_id=%zu workspace=%zu"
+ " owner_output=%s global_valid=%d global=%d,%d clip_valid=%d clip=%d,%d,%d,%d",
+ presentation_probe_now_ns(), (void *)tree,
+ owner ? owner->node.id : 0, owner_ws ? owner_ws->node.id : 0,
+ owner_ws && owner_ws->current.output ? owner_ws->current.output->wlr_output->name : "none",
+ popup_valid, popup_x, popup_y, valid, box.x, box.y, box.width, box.height);
+
sway_log(SWAY_INFO, "PI_POPUP output=%s ns=%" PRIu64
" local_index=%u tree=%p enabled=%d clip_valid=%d clip=%d,%d,%d,%d",
output->wlr_output->name, presentation_probe_now_ns(),

View file

@ -0,0 +1,24 @@
--- a/pointer-daemon.c
+++ b/pointer-daemon.c
@@ -46,13 +46,18 @@
char line[128];
uint32_t stamp=1000;
while (fgets(line,sizeof(line),stdin)) {
- char action[16];int x,y;
- if (sscanf(line,"%15s %d %d",action,&x,&y)!=3 ||
+ /* WHY: output layout bounds can change without recreating the pointer.
+ * CONTRACT: per-event extents are logical, supplied from applied IPC.
+ * INVARIANT: reject invalid dimensions before unsigned conversion. */
+ char action[16];int x,y,event_width,event_height;
+ if (sscanf(line,"%15s %d %d %d %d",action,&x,&y,&event_width,&event_height)!=5 ||
+ event_width <= 0 || event_height <= 0 ||
(strcmp(action,"move") && strcmp(action,"left") && strcmp(action,"right")) ||
- x < 0 || y < 0 || (uint32_t)x >= width || (uint32_t)y >= height) {
+ x < 0 || y < 0 || x >= event_width || y >= event_height) {
fprintf(stderr,"INVALID_INJECTION %s",line);fflush(stderr);
return 3;
}
+ width = (uint32_t)event_width; height = (uint32_t)event_height;
fprintf(stderr,"INJECT %s %d,%d extent=%u,%u\n",action,x,y,width,height);
fflush(stderr);
zwlr_virtual_pointer_v1_motion_absolute(pointer,stamp++,x,y,width,height);

View file

@ -0,0 +1,30 @@
#!/bin/sh
# EXPERIMENTAL: disposable sources only; caller owns successful LAB.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
fixture="$repo/tooling/probes/step20c-dynamic-output"
log=$(mktemp /tmp/dynamic-output-prepare-XXXXXXXX)
if ! sh "$repo/tooling/probes/step20c-fractional-scale/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"
# CONTRACT: reversing the glass fixture yields an opaque XRGB owner;
# no compositor algorithm or historical patch is changed.
(cd "$lab" && git apply --check -R "$repo/tooling/probes/step20c-effects-damage/effect-glass.patch" &&
git apply -R "$repo/tooling/probes/step20c-effects-damage/effect-glass.patch" &&
git apply --check "$fixture/pointer-dynamic-extents.patch" &&
git apply "$fixture/pointer-dynamic-extents.patch")
(cd "$lab/mosaic" && git apply --check "$fixture/ownership-trace.patch" && git apply "$fixture/ownership-trace.patch")
meson compile -C "$lab/mosaic-build" -j 8 >> "$log" 2>&1
for client in nested-client pointer-daemon; do
protocol=xdg-shell
test "$client" != pointer-daemon || protocol=virtual-pointer
cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \
"$lab/$client.c" "$lab/$protocol-protocol.c" -o "$lab/$client" \
$(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1
done
cp "$log" "$lab/dynamic-build-log.txt"
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log"

View file

@ -0,0 +1,66 @@
"""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'))
a = p.parse_args()
lab = a.lab.resolve()
repo = Path(__file__).resolve().parents[3]
# WHY: one lock covers all modes, dynamic reservations and complete child cleanup.
# CONTRACT: repeated/failed runs cannot silently replenish campaign budgets.
# INVARIANT: no hide/return camera pair is generated by this adapter.
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}-dynamic-{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('import argparse', 'import argparse\nfrom PIL import Image\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)\n(run / 'fixture-source.py').write_text(Path(__file__).read_text())")
start = s.index(" expected = {'HEADLESS-1':")
end = s.index(" for output, side in", start)
s = s[:start]+''' expected = {'HEADLESS-1': (-960, 0, 960, 600) if globals().get('dynamic_stage') == 'D1' else (-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']}
if actual != expected or any(o['transform'] != 'normal' or o['scale'] != (1.25 if o['name']=='HEADLESS-1' else 1) for o in outputs if o['active']):
raise RuntimeError(f'output geometry/scale/transform mismatch: {actual}')
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)
if dimensions != [out['current_mode']['width'],out['current_mode']['height']]:
raise RuntimeError('physical PNG/mode mismatch')
record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode'])
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']<=x<o['rect']['x']+o['rect']['width'] and o['rect']['y']<=y<o['rect']['y']+o['rect']['height'] for o in outputs):
raise ValueError('global point is outside applied outputs')
sent_x,sent_y = int(x-min_x),int(y-min_y)
width,height = max_x-min_x,max_y-min_y
marker=f'INJECT_DONE {action} {sent_x},{sent_y}'
before=(run/'pointer.log').read_text().count(marker)
record('pointer_request',action=action,global_point=[x,y],sent=[sent_x,sent_y],extent=[width,height],minimum=[min_x,min_y])
pointer.stdin.write(f'{action} {sent_x} {sent_y} {width} {height}\\n');pointer.stdin.flush()
wait(lambda: (run/'pointer.log').read_text().count(marker)>before)
'''+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'dynamic-{a.mode}.py'
generated.write_text(s)
raise SystemExit(subprocess.call([sys.executable,str(generated),str(lab),a.mode,'cycles','--max-seconds','240','--max-captures','100','--max-bytes','60000000'], pass_fds=(campaign.lock.fileno(),)))

View file

@ -0,0 +1,121 @@
"""Private scenario source inserted inside the bounded runner try/finally."""
SCENARIO = r''' # 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')
command('output * bg #1e1e2e solid_color')
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}')
calibrate('D0')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
stable('D0','HEADLESS-1');owner_hit('D0')
open_chain('D0-chain')
stable('D0-popup','HEADLESS-1')
reserve(lab,args.mode,'D1')
record('dynamic_launch',action='D1')
dynamic_stage='D1'
command('output HEADLESS-1 mode 1200x750 scale 1.25 position -960 0')
stable('D1-popup','HEADLESS-1')
closed();calibrate('D1');owner_hit('D1')
open_chain('D1-chain');stable('D1-reopened','HEADLESS-1')
reserve(lab,args.mode,'D2');record('dynamic_launch',action='D2')
dynamic_stage='D0'
command('output HEADLESS-1 mode 1000x750 scale 1.25 position -800 0')
stable('D2-popup','HEADLESS-1')
closed();calibrate('D2');owner_hit('D2')
open_chain('D2-chain');stable('D3-before','HEADLESS-1')
# Context is explicitly verified before moving the target workspace.
if not next(w for w in ipc('-t','get_workspaces') if w['name']=='1')['focused']:
raise RuntimeError('workspace 1 is not migration context')
reserve(lab,args.mode,'D3');record('dynamic_launch',action='D3')
command('move workspace to output HEADLESS-2')
stable('D3-popup','HEADLESS-2')
record('popup_lifecycle',tag='D3-before-calibration',done_a=count('POPUP_A POPUP_DONE'),done_b=count('POPUP_B POPUP_DONE'),client_events=client_text())
closed();calibrate('D3');owner_hit('D3')
open_chain('D3-new-chain');stable('D3-reopened','HEADLESS-2')
# New-owner forbidden region is on the old H1, not historical x=20.
before=len(client_text());inject('move',-20,190);inject('right',-20,190)
record('forbidden_click',tag='D3',global_point=[-20,190],events=client_text()[before:],delivered=bool(re.search(r'POINTER_BUTTON target=POPUP_[AB]',client_text()[before:])))
closed();open_chain('D3-return-chain');stable('D5-before','HEADLESS-2')
reserve(lab,args.mode,'D5');record('dynamic_launch',action='D5')
command('move workspace to output HEADLESS-1')
stable('D5-popup','HEADLESS-1')
record('popup_lifecycle',tag='D5-before-calibration',done_a=count('POPUP_A POPUP_DONE'),done_b=count('POPUP_B POPUP_DONE'),client_events=client_text())
closed();calibrate('D5');owner_hit('D5')
open_chain('D5-new-chain');stable('D5-reopened','HEADLESS-1')
before=len(client_text());inject('move',20,190);inject('right',20,190)
record('forbidden_click',tag='D5',global_point=[20,190],events=client_text()[before:],delivered=bool(re.search(r'POINTER_BUTTON target=POPUP_[AB]',client_text()[before:])))
closed();stable('D5-closed','HEADLESS-1')
'''

View file

@ -0,0 +1,32 @@
"""Offline execution of actual campaign budget; no dynamic output operations."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
from campaign import Campaign, reserve, read
root=Path(tempfile.mkdtemp(prefix='dynamic-budget-test-'))
c=Campaign(root);c.begin('control');reserve(root,'control','D1')
# A second process must not launch a contradicting campaign while lock is held.
p=subprocess.run([sys.executable,'-c','from campaign import Campaign; Campaign('+repr(str(root))+')'],cwd=Path(__file__).parent,capture_output=True,text=True)
assert p.returncode and 'BlockingIOError' in p.stderr
for mode in ('control','private_standard','private_mosaic'):
if mode != 'control':c.begin(mode)
for action in ('D1','D2','D3','D5'):
if mode=='control' and action=='D1':continue
reserve(root,mode,action)
try:reserve(root,mode,'D1')
except ValueError:pass
else:raise AssertionError('duplicate accepted')
try:c.begin(mode)
except ValueError:pass
else:raise AssertionError('run reset accepted')
state=read(root);assert len(state['reservations'])==12
# Persisted reservations survive caller failure; no refund or ledger reset.
c.lock.close();assert read(root)==state
old=root/'old';(old/'runs/control-dynamic-failed').mkdir(parents=True)
try:read(old)
except ValueError:pass
else:raise AssertionError('missing ledger accepted')
print('PASS lock/persistence/duplicate/exhaustion/failed-run/missing-ledger; zero compositor launches')