From 20d58cf8885dd77cb93b55f674d1f047226c3e21 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 13:50:27 +0400 Subject: [PATCH] Report zero queued alpha for an entry the yield pass hid getQueuedOverlayEntryAlpha now returns 0 for a yielding entry that declutterYieldingEntries marked collisionHidden this frame. That pass runs before custom lanes, so they no longer read full opacity for an entry that will not paint. --- src/overlays/worldOverlay.js | 8 ++++--- src/overlays/worldOverlay.test.mjs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 71f4b849f..e0ca77038 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1047,9 +1047,9 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * Opacity an entry paints with this frame, or 0 when it is not queued. Custom - * lanes may call it before later lanes publish paint rects. Leaves out the - * Cyber sonar fade and yield-collision hiding. + * Opacity an entry paints with this frame, or 0 when it is not queued or the + * yield-collision pass hid it. Custom lanes may call it before later lanes + * publish paint rects. Leaves out the Cyber sonar fade. * @param {string} sourceId * @param {string} entryId * @returns {number} @@ -1059,6 +1059,8 @@ export function getQueuedOverlayEntryAlpha(sourceId, entryId) { const item = _queuedItemBySource.get(sourceId)?.get(entryId); // Pooled items are reassigned every frame: check the frame and the entry. if (!item || item.stamp !== _frameStamp) return 0; + // Declutter has already run when custom lanes call this. + if (item.collisionHidden) return 0; const record = item.record; if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0; return ( diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 1b8223bbc..30dd021c2 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1259,6 +1259,43 @@ test('a detection lane sees which cards this frame will paint before they publis env.cleanup(); }); +test('a detection lane reads zero for a yielding entry the declutter pass hid this frame', () => { + // Declutter runs before custom lanes, so a yielding entry it hid must not + // report its full queued alpha to them. + const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); + initWorldOverlay(env.viewer); + const seen = []; + registerWorldOverlayPaintLane('detection', () => { + seen.push({ + winner: getQueuedOverlayEntryAlpha('cards', 'ABOVE'), + yielder: getQueuedOverlayEntryAlpha('labels', 'YIELDER'), + }); + }, { id: 'detection', active: true, target: 'detection' }); + // Two cards take both vertical slots, so a vertical-only yielder has none. + const at = positionAtScreen(200, 150); + const card = (id, placement) => selectedEntry(id, { + position: at, + placement, + verticalOnly: true, + collisionGroup: 'shared-card', + }); + setOverlayEntries('cards', [card('ABOVE', 'above'), card('BELOW', 'below')]); + setOverlayEntries('labels', [selectedEntry('YIELDER', { + position: at, + verticalOnly: true, + collisionGroup: 'yielding-label', + yieldToCollisionGroup: 'shared-card', + })]); + + env.postRender.raise(); + assert.ok(getOverlayPaintRect('cards', 'ABOVE'), 'the winner painted'); + assert.equal(getOverlayPaintRect('labels', 'YIELDER'), null, 'the yielder was hidden'); + assert.ok(seen[0].winner > 0, 'the winner reads its queued alpha'); + assert.equal(seen[0].yielder, 0, 'the yielder hidden this frame reads zero'); + destroyWorldOverlay(); + env.cleanup(); +}); + test('custom-lane painter state is bracketed by host save/restore', () => { // A painter that leaks a clip or alpha must not corrupt later frames: the // host wraps every custom-lane painter in save/try/finally-restore. Pin the