Skip to content

Fix #5273: clamp Metal screen clips to the flush region (drawingRect)#5275

Merged
shai-almog merged 15 commits into
masterfrom
fix-5273-metal-partial-flush-clip-escape
Jun 25, 2026
Merged

Fix #5273: clamp Metal screen clips to the flush region (drawingRect)#5275
shai-almog merged 15 commits into
masterfrom
fix-5273-metal-partial-flush-clip-escape

Conversation

@shai-almog

@shai-almog shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes #5273iOS: Fixed Toolbar and North Container Become Blank While Scrolling a Separate Center Container.

Root cause

The iOS Metal backend renders into a persistent screenTexture (MTLLoadActionLoad) and only queues the per-frame diff. The legacy GL backend clamps every clip to the current flush region — ClipRect.m's drawingRect (the dirty bounding box passed to flushGraphics) — so a draw can never escape the flushed sub-region. The Metal rectangular-clip branch was missing that clamp.

During a partial repaint — e.g. an independently scrollable BorderLayout.CENTER scrolling under a fixed Toolbar / BorderLayout.NORTHpaintDirty() flushes only the dirty component's bounds, but a clip emitted during that flush can still extend past it into the fixed band. On Metal the unclamped fill overwrote the toolbar / NORTH region of the persistent texture, and it stayed blank until a full repaint (opening the overflow menu) rewrote it. Android, the simulator, and the GL iOS backend were unaffected because they clamp to drawingRect. Same family as #5263 / #5191.

Fix

Mirror the GL drawingRect clamp in the Metal branch of ClipRect.m, screen ops only (target == nil) so mutable-image draws — which run against their own encoder with their own framebuffer bounds — are untouched. For a full-screen flush drawingRect is the whole framebuffer, so the clamp is a no-op there; it only bites on partial flushes, which is exactly why no existing (full-form) screenshot test caught this.

Coverage

Adds a deterministic screenshot regression test graphics-partial-flush-clip-escape: a fixed red SOUTH band under a scrollable CENTER; the CENTER repaints (a real partial flush) with a clip reaching down past its bottom into SOUTH. A backend that clamps to drawingRect keeps the magenta fill inside the CENTER and SOUTH stays red; the regressed Metal backend lets it escape and SOUTH turns magenta.

SOUTH (not NORTH) is used because the simulator's title bar repaints every frame and paintDirty() unions that region upward into the flush box — never below the CENTER — so a band placed below the CENTER stays outside the flush region regardless of any top-bar animation. The fix is symmetric, so this guards the reported NORTH/toolbar case too.

Verification

Built the iOS port + hellocodenameone app from source and ran on the iOS Metal simulator (iPhone 16e and iPhone 16 Pro):

build SOUTH band
pre-fix (baseline ClipRect.m) magenta — escape reproduced
post-fix red — clamp confines the fill

Native trace confirms the mechanism — escape clip inClip=…,297,…,2532 under a partial drawRect=…,18,…,1863 (bottom = SOUTH top) is clamped to scissor=…,297,…,1584 (CENTER only).

Goldens

The test is scoped to iPhone/iPad iOS (it does done() and takes no screenshot elsewhere): the transient escape on Android/JS/desktop/watch isn't the persistent user-visible bug, so capturing it there would only lock a misleading golden. Two goldens were seeded from this PR's CI at 1179×2556 (the iOS sim resolution under the CI toolchain, which local Xcode-26 sims don't match): scripts/ios/screenshots/ (GL) and scripts/ios/screenshots-metal/ (Metal). Both show the SOUTH band red (the clip clamped to the CENTER); they're byte-identical, as expected for solid-color fills.

Note: the build-ios-metal / -tv / -watch jobs have a pre-existing flaky hang (the suite occasionally stalls at the DrawImage test, unrelated to this change — confirmed by every captured test matching before the stall). A clean metal run captured the golden and passed; reruns may be needed when the flake recurs.


Also in this PR: fix the iOS Metal stale-frame screenshot capture race

While validating the new test on CI I hit the metal screenshot suite's pre-existing non-determinism and root-caused it: on the iPhone/iPad simulator the Metal capture fell through to drawViewHierarchyInRect:afterScreenUpdates:NO, which snapshots the CALayer's currently-presented drawable. That drawable lags the renderer's screenTexture (presentFramebuffer commits the screenTexture→drawable blit without waiting, and the CALayer composites it on a later CA transaction), so a screenshot taken right after a Form.show() could capture the previous form — e.g. DesktopModeScreenshotTest intermittently captured the prior test's form, and its committed golden was itself a stale capture.

Fix (IOSNative.m): enable the deterministic screenTexture readback (cn1_copyMetalScreenTextureImage — blit into a CPU-visible staging texture + waitUntilCompleted) on all Metal builds, not just Catalyst/TV. It always reflects the latest committed CN1 frame regardless of drawable-present / CALayer-composite timing. iOS (device + simulator) uses Shared storage like tvOS; only Catalyst keeps Managed + synchronizeResource. Peer/native views are still composited separately, unchanged.

It also imports UserNotifications when push is enabled — the push code uses it but the import was gated on a different macro and otherwise relied on clang's implicit module auto-import, which compiling the readback on iOS perturbs.

The readback is pixel-compatible with the existing iOS-metal goldens on the CI simulator (no reseed needed); it only removes the non-determinism. The metal CI job now passes with every test matching, and DesktopMode is deterministic.

Out of scope (pre-existing, unrelated): an intermittent DrawImage whole-suite hang (a ParparVM GC-safepoint/class-init deadlock when an EDT exception is logged during a concurrent GC) and the network-dependent GoogleWebMap flake — both also fail master and warrant separate work.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 139 screenshots: 139 matched.

Native Android coverage

  • 📊 Line coverage: 14.59% (8965/61438 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 11.83% (44129/372973), branch 5.23% (1834/35077), complexity 6.28% (2110/33604), method 10.88% (1709/15702), class 17.78% (396/2227)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 14.59% (8965/61438 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 11.83% (44129/372973), branch 5.23% (1834/35077), complexity 6.28% (2110/33604), method 10.88% (1709/15702), class 17.78% (396/2227)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
SIMD kernel backend scalar fallback (no native SIMD)
SIMD int-add (64K x300) java 157ms / native 65ms = 2.4x speedup
SIMD float-mul (64K x300) java 141ms / native 57ms = 2.4x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path gated to scalar (CPU autovectorizes scalar; explicit SIMD not beneficial here)
Base64 CN1 encode 234.000 ms
Base64 CN1 decode 217.000 ms
Base64 native encode 1259.000 ms
Base64 encode ratio (CN1/native) 0.186x (81.4% faster)
Base64 native decode 1151.000 ms
Base64 decode ratio (CN1/native) 0.189x (81.1% faster)
Image encode benchmark status skipped (SIMD unsupported)

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 131 screenshots: 131 matched.
✅ JavaScript-port screenshot tests passed.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 135 screenshots: 135 matched.
Native Windows port, REAL shipping pipeline: the hellocodenameone screenshot suite rendered by a binary CROSS-COMPILED on Linux (clang-cl + xwin, WebView2 linked) and RUN on a Windows x64 runner. Compared against the in-repo baseline in scripts/windows/screenshots.

Benchmark Results

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 65ms / native 4ms = 16.2x speedup
SIMD float-mul (64K x300) java 65ms / native 3ms = 21.6x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 native bridge unavailable (CN1 + SIMD + image benchmarks only)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path gated to scalar (CPU autovectorizes scalar; explicit SIMD not beneficial here)
Base64 CN1 encodecaptureWindowToPngBytes window target is not WIC-backed

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 137 screenshots: 137 matched.
Native Linux port (x64), GTK3/Cairo/Pango, ParparVM bytecode-to-C (no JVM): the hellocodenameone screenshot suite rendered by a native ELF built + run on the GitHub x64 runner. Baseline: scripts/linux/screenshots.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 137 screenshots: 137 matched.
Native Linux port (arm64), GTK3/Cairo/Pango, ParparVM bytecode-to-C (no JVM): the hellocodenameone screenshot suite rendered by a native ELF built + run on the GitHub arm64 runner. Baseline: scripts/linux/screenshots-arm.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 137 screenshots: 137 matched.
✅ Native Mac screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 192 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 53ms / native 3ms = 17.6x speedup
SIMD float-mul (64K x300) java 54ms / native 3ms = 18.0x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 285.000 ms
Base64 CN1 decode 215.000 ms
Base64 native encode 892.000 ms
Base64 encode ratio (CN1/native) 0.320x (68.0% faster)
Base64 native decode 689.000 ms
Base64 decode ratio (CN1/native) 0.312x (68.8% faster)
Base64 SIMD encode 54.000 ms
Base64 encode ratio (SIMD/CN1) 0.189x (81.1% faster)
Base64 SIMD decode 48.000 ms
Base64 decode ratio (SIMD/CN1) 0.223x (77.7% faster)
Base64 encode ratio (SIMD/native) 0.061x (93.9% faster)
Base64 decode ratio (SIMD/native) 0.070x (93.0% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 25.000 ms
Image createMask (SIMD on) 38.000 ms
Image createMask ratio (SIMD on/off) 1.520x (52.0% slower)
Image applyMask (SIMD off) 220.000 ms
Image applyMask (SIMD on) 195.000 ms
Image applyMask ratio (SIMD on/off) 0.886x (11.4% faster)
Image modifyAlpha (SIMD off) 191.000 ms
Image modifyAlpha (SIMD on) 114.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.597x (40.3% faster)
Image modifyAlpha removeColor (SIMD off) 116.000 ms
Image modifyAlpha removeColor (SIMD on) 175.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 1.509x (50.9% slower)

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 136 screenshots: 136 matched.
✅ Native Apple TV (tvOS, Metal) screenshot tests passed.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 138 screenshots: 138 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 339 seconds

Build and Run Timing

Metric Duration
Simulator Boot 97000 ms
Simulator Boot (Run) 1000 ms
App Install 25000 ms
App Launch 4000 ms
Test Execution 486000 ms

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 329ms / native 5ms = 65.8x speedup
SIMD float-mul (64K x300) java 353ms / native 6ms = 58.8x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 1508.000 ms
Base64 CN1 decode 707.000 ms
Base64 native encode 3342.000 ms
Base64 encode ratio (CN1/native) 0.451x (54.9% faster)
Base64 native decode 1364.000 ms
Base64 decode ratio (CN1/native) 0.518x (48.2% faster)
Base64 SIMD encode 211.000 ms
Base64 encode ratio (SIMD/CN1) 0.140x (86.0% faster)
Base64 SIMD decode 77.000 ms
Base64 decode ratio (SIMD/CN1) 0.109x (89.1% faster)
Base64 encode ratio (SIMD/native) 0.063x (93.7% faster)
Base64 decode ratio (SIMD/native) 0.056x (94.4% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 25.000 ms
Image createMask (SIMD on) 64.000 ms
Image createMask ratio (SIMD on/off) 2.560x (156.0% slower)
Image applyMask (SIMD off) 354.000 ms
Image applyMask (SIMD on) 708.000 ms
Image applyMask ratio (SIMD on/off) 2.000x (100.0% slower)
Image modifyAlpha (SIMD off) 635.000 ms
Image modifyAlpha (SIMD on) 474.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.746x (25.4% faster)
Image modifyAlpha removeColor (SIMD off) 490.000 ms
Image modifyAlpha removeColor (SIMD on) 474.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.967x (3.3% faster)

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 214 screenshots: 214 matched.
✅ Native Apple Watch (watchOS, Core Graphics) screenshot tests passed.

@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 134 screenshots: 134 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 316 seconds

Build and Run Timing

Metric Duration
Simulator Boot 111000 ms
Simulator Boot (Run) 0 ms
App Install 16000 ms
App Launch 3000 ms
Test Execution 492000 ms

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 120ms / native 18ms = 6.6x speedup
SIMD float-mul (64K x300) java 66ms / native 2ms = 33.0x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 506.000 ms
Base64 CN1 decode 289.000 ms
Base64 native encode 1044.000 ms
Base64 encode ratio (CN1/native) 0.485x (51.5% faster)
Base64 native decode 1104.000 ms
Base64 decode ratio (CN1/native) 0.262x (73.8% faster)
Base64 SIMD encode 66.000 ms
Base64 encode ratio (SIMD/CN1) 0.130x (87.0% faster)
Base64 SIMD decode 63.000 ms
Base64 decode ratio (SIMD/CN1) 0.218x (78.2% faster)
Base64 encode ratio (SIMD/native) 0.063x (93.7% faster)
Base64 decode ratio (SIMD/native) 0.057x (94.3% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 20.000 ms
Image createMask (SIMD on) 18.000 ms
Image createMask ratio (SIMD on/off) 0.900x (10.0% faster)
Image applyMask (SIMD off) 310.000 ms
Image applyMask (SIMD on) 204.000 ms
Image applyMask ratio (SIMD on/off) 0.658x (34.2% faster)
Image modifyAlpha (SIMD off) 253.000 ms
Image modifyAlpha (SIMD on) 171.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.676x (32.4% faster)
Image modifyAlpha removeColor (SIMD off) 244.000 ms
Image modifyAlpha removeColor (SIMD on) 152.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.623x (37.7% faster)

On iOS the Metal backend renders into a PERSISTENT screenTexture
(MTLLoadActionLoad) and only queues the per-frame diff. The legacy GL
backend clamps every clip to the current flush region (ClipRect.m's
drawingRect), so a draw can never escape the dirty sub-region. The Metal
rectangular-clip branch was missing that clamp.

During a partial repaint -- e.g. an independently scrollable
BorderLayout.CENTER scrolling under a fixed Toolbar / BorderLayout.NORTH
-- paintDirty() flushes only the dirty component's bounds, but a clip
emitted during that flush can still extend past it into the fixed band.
On Metal the unclamped fill overwrote the toolbar / NORTH region of the
persistent texture and it stayed blank until a full repaint (e.g. opening
the overflow menu) rewrote it. Android, the simulator and the GL iOS
backend were unaffected because they clamp to drawingRect (and they do a
full repaint right after, so even the transient escape is never seen).

Mirror the GL drawingRect clamp in the Metal branch, for screen ops only
(target == nil) so mutable-image draws -- which run against their own
encoder with their own framebuffer bounds -- are untouched. For a
full-screen flush drawingRect is the whole framebuffer, so the clamp is a
no-op there; it only bites on partial flushes, which is why no existing
(full-form) screenshot test exercised it.

Adds a deterministic screenshot regression test
(graphics-partial-flush-clip-escape): a fixed red SOUTH band under a
scrollable CENTER; the CENTER repaints (a real partial flush) with a clip
reaching down past its bottom into SOUTH. A backend that clamps to
drawingRect keeps the magenta fill inside the CENTER and SOUTH stays red;
the regressed Metal backend lets it escape and SOUTH turns magenta. SOUTH
(not NORTH) is used because the simulator's title bar repaints every
frame and paintDirty() unions that region upward into the flush box --
never below the CENTER -- so a band below the CENTER stays outside the
flush region regardless. The fix is symmetric, so this guards the
reported NORTH case too. The test is scoped to iOS: on other platforms a
full repaint immediately follows, so the transient escape is not the
user-visible bug (the reporter confirmed it does not reproduce on Android
or the simulator) and capturing it would only lock a misleading golden.

Verified on the iOS Metal simulator (iPhone 16e and iPhone 16 Pro):
pre-fix SOUTH = magenta (bug reproduced), post-fix SOUTH = red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shai-almog shai-almog force-pushed the fix-5273-metal-partial-flush-clip-escape branch from 0fbcc01 to b2fba15 Compare June 24, 2026 05:41
shai-almog and others added 5 commits June 24, 2026 08:43
Captured from the PR's build-ios (GL) CI job at 1179x2556. On the GL
backend the clip is clamped to drawingRect, so the SOUTH band stays red
(the escape is confined to the CENTER). The Metal golden follows once a
non-flaky build-ios-metal run captures it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Metal golden captured from a clean build-ios-metal CI run at 1179x2556:
the fix clamps the escaping clip to drawingRect, so the SOUTH band stays
red (byte-identical to the GL golden, as expected for solid fills).

Also narrows shouldTakeScreenshot()/runTest() to phone/tablet iOS
(excludes watchOS, whose Core Graphics backend the Metal fix does not
touch, and tvOS) so those jobs don't expect a golden and the guard stays
focused on the reported iPhone scenario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the iPhone/iPad simulator and device the Metal screenshot capture fell
through to drawViewHierarchyInRect:afterScreenUpdates:NO, which snapshots
the CALayer's currently-presented drawable. That drawable lags the
renderer's screenTexture: presentFramebuffer commits the screenTexture->
drawable blit without waiting and the CALayer composites it on a later CA
transaction, so a screenshot taken right after a Form.show() could capture
the PREVIOUS form. This is the stale-frame race that made the metal
screenshot suite non-deterministic (e.g. DesktopModeScreenshotTest
intermittently capturing the prior test's form).

Enable the deterministic screenTexture readback (cn1_copyMetalScreenTextureImage)
on all Metal builds, not just Catalyst/TV. It blits the persistent
screenTexture into a CPU-visible staging texture and waitUntilCompleted,
so the capture always reflects the latest committed CN1 frame regardless
of drawable-present / CALayer-composite timing. iOS (device + simulator)
uses Shared storage like tvOS; only Catalyst keeps Managed +
synchronizeResource. Peer/native components are still composited
separately by cn1_renderPeerComponents, unchanged.

Also import UserNotifications when push is enabled: the push code uses
UNUserNotificationCenter but the import was gated on a different macro and
otherwise relied on clang's implicit module auto-import, which compiling
the readback path on iOS perturbs.

The iOS-metal goldens are reseeded from CI in a follow-up commit (the
readback's pixels differ from the old drawViewHierarchyInRect capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ative golden

Clamping a clip to the current flush region is a portable correctness
property, not an iOS-Metal quirk -- a regression would bleed the fill past
the flushed sub-region on any backend. Remove the iPhone-only scoping
(which also wrongly excluded tvOS, which is Metal and gets the same fix)
so the guard runs on every screenshot pipeline. On a correct backend the
SOUTH band stays red; a backend that lets the clip escape turns it magenta.

Seeds the mac-native (Catalyst) golden, captured from CI. The remaining
per-platform goldens (watch, tv, android, javascript, linux x64/arm,
windows) are seeded from their CI artifacts in follow-up commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clip-escape reproduced by graphics-partial-flush-clip-escape is the same
portable bug on every renderer that draws screen ops into a persistent surface
without clamping a clip to the partial-flush region: iOS Metal (already fixed),
watchOS, and the Linux native port. iOS GL, Android, JavaScript, mac-native,
tvOS and Windows (Direct2D) already clamp and render correctly.

watchOS (ClipRect.m + CN1WatchViewController.m): the watch CG branch set the
clip with no drawingRect clamp, and the flush region handed to flushBuffer: was
discarded (drawFrame: always got CGRectZero). Thread the flush rect through to
drawFrame:, publish it via [ClipRect setDrawRect:], and add the same
flush-region clamp the Metal/GL branches use -- guarded to screen ops and to a
non-empty drawingRect so a clip is never collapsed to nothing.

Linux (Cairo, immediate mode): iOS clamps at flush time because it is retained;
Linux draws during paint, so it must know the flush region before painting. Add
a no-op setPaintDirtyRegionClip hook in CodenameOneImplementation.paintDirty
(reports each component's dirty region, full screen otherwise) that only the
Linux port overrides -- every other port is unaffected. LinuxNative.setFlushRect
records it on the window graphics and cn1LinuxApplyClip confines a rectangular
screen clip to it (window target only; mutable images keep flushW == 0).

cn1ss.sh: fail FAIL_ON_MISMATCH runs when a captured screenshot has no committed
golden (missing_expected), with CN1SS_ALLOWED_MISSING_EXPECTED tolerance, so a
new/ported test's golden can no longer be silently left unintegrated (the gap
that hid this test's mac-native reference). Skipped under the seeding bypass.

Seed the correct (magenta CENTER + red SOUTH) goldens for tvOS, Windows,
JavaScript and Android. watch + Linux goldens follow once CI confirms the fixes
produce the red SOUTH band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Cloudflare Preview

…nostic

The watch branch of ClipRect.execute used [self target], but ExecutableOp.target
is declared only under #ifdef CN1_USE_METAL, so build-ios-watch failed to
compile. The clamp is instead scoped to screen ops by drawFrame, which resets
drawingRect to CGRectZero after draining the screen op queue (a mutable-image
draw runs immediately outside drawFrame, where the non-empty guard no-ops the
clamp). Remove the [self target] check.

Linux still renders the escape (whole content magenta) with the clamp in place,
so add a temporary CN1DIAG print in cn1LinuxApplyClip for window clips taller
than 300px to reveal whether the flush region is the dirty sub-region or the
full screen when the escaping clip applies. Reverted once diagnosed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shai-almog

shai-almog commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 11 screenshots: 11 matched.
✅ JavaSE simulator integration screenshots matched stored baselines.

shai-almog and others added 8 commits June 25, 2026 00:04
…watch golden

The paintDirty hook clamped the immediate-mode clip to the component's DIRTY
region, but Component.repaint() nulls the dirty region (Component.java), so for
the test's center.repaint() the dirty region was null and the flush hint fell
back to the full screen -- no clamp, escape unchanged (Linux stayed all-magenta).
Clamp to getPaintableBounds(cmp) instead: the same region the retained ports
push to flushGraphics and clamp against, valid whether or not the dirty region
was nulled. Computed before paintComponent (paint does not move the component).
Remove the temporary CN1DIAG print.

watchOS now renders correctly with the earlier flush-rect fix (magenta CENTER
confined, red SOUTH preserved) -- seed its golden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moving getPaintableBounds before paintComponent (for the immediate-mode clip
hint) also changed the value used for the flushGraphics region, because
paintComponent can lay the component out and shift its bounds. That altered the
flushed rect the retained ports clamp/flush against -- regressing landscape
(iOS GL+Metal), graphics-draw-string (Android) and ValidatorLightweightPicker
(watch, whose CG clamp uses this exact rect). Recompute getPaintableBounds AFTER
paint for the flush region so it is pixel-identical to pre-#5273; the before-paint
value is kept only as the immediate-mode clip hint (setPaintDirtyRegionClip).

Linux now renders correctly (magenta CENTER + red SOUTH) with the
paintable-bounds clamp -- seed the x64 and arm64 goldens (musl shares x64).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The watch render queue swapped upcomingTarget into currentTarget and discarded
the previous set on each flush. When setNeedsDisplay coalesced several flushes
into one drawFrame, every batch but the last was dropped, leaving stale pixels
on the persistent CG bitmap. Without the #5273 clip clamp this was invisible --
the unclamped draws overpainted the stale area -- but with the clamp confining
each draw to its flushed region the staleness showed through and corrupted
multi-flush UI (ValidatorLightweightPicker rendered garbled overlapping frames).

Append each flush's ops to the not-yet-drawn set instead of swapping/discarding,
and union the flushed regions so the clamp covers the combined dirty area; draw
once and consume in drawFrame (the persistent bitmap keeps the pixels for a
forced re-present). No ops are lost, so the picker repaints cleanly and the
partial-flush clip-escape test still confines the escaping fill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The watch CG bitmap is never cleared, so a prior form/test's pixels survive
beneath any area the new frame's CLAMPED draws no longer overpaint -- the source
of the ValidatorLightweightPicker garble (stale text/shapes showing through).
Before the clip clamp the unclamped draws happened to cover them.

Clear the bitmap at the start of a full-screen flush: a full repaint repaints
the whole surface with the opaque form background, so clearing first is gap-safe
(nothing left transparent) and drops the stale underlay. Partial flushes are
left untouched (their ops need not cover the whole region, so clearing could
expose transparent gaps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-screen-only clear didn't help ValidatorLightweightPicker because the
picker repaints via PARTIAL flushes; its residue lives on the never-cleared
persistent bitmap under areas the clamped partial draws no longer overpaint.
Clear the flushed region (full or partial) before draining its ops: the
paintDirty batch repaints that region with the components' opaque backgrounds so
the ops refill what we clear, while pixels outside the flush region stay intact.
Same coordinate space as the draw ops, so a partial region clears correctly.
This also keeps the partial-flush clip-escape test correct (clear the CENTER,
refill magenta; SOUTH is outside the flush so its red is preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locally reproduced the ValidatorLightweightPicker garble on the watch simulator
(built the watch app, ran the suite filtered to the picker, logged every clamp).
The picker repaints via a tiny dirty/flush region (e.g. 196x37) but its
components legitimately set clips covering the whole dialog (208x248) -- a
translucent/cascade repaint painting a parent larger than its dirty sub-region.
The previous clamp chopped every such clip down to the dirty strip, so only the
strip updated and the rest of the dialog went stale (the garble). The escape and
this legitimate over-draw are mechanically identical at the clip level, so a
blanket clamp cannot tell them apart.

The distinguishing geometry: an escape EMERGES from within the flush region and
leaks out ONE side into a fixed band that won't be repainted (its opposite edge
is inside the flush region); legitimate over-draw either SURROUNDS the flush
region (both opposite edges outside) or sits WHOLLY to one side (a sibling's own
area). Clamp an overshooting edge only when the clip's opposite edge lies inside
the flush region. Verified locally: the picker now compares equal, and the
partial-flush clip-escape test's escape (top edge inside the flush, leaks down
into SOUTH) is still clamped.

Also drop the per-flush bitmap clear added earlier: with the clamp now confined
to genuine escapes there is no clamp residue to clear, and clearing risked
transparent gaps. Keep the coalesced-batch accumulation and the post-drain
drawingRect reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shai-almog shai-almog merged commit 9d9cc54 into master Jun 25, 2026
44 of 45 checks passed
@shai-almog shai-almog deleted the fix-5273-metal-partial-flush-clip-escape branch June 25, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: Fixed Toolbar and North Container Become Blank While Scrolling a Separate Center Container

1 participant