Compare commits

..
9 Commits
Author SHA1 Message Date
Bitsy 3b420a7bad docs(CLAUDE): document the parity harness + how to read the %differ
Explains running scripts/parity, the one-process JVM / per-screen native
mechanics, the build/parity outputs, and — the load-bearing part — what
the number means: it's a font-drift-inflated ranking signal, not a
pass/fail score. Ghosted text + dark shapes = normal; a solid bright
block or one-sided shape = regression. Baseline gradient ~2%..32%; known
SDL gaps (solid gradients, effects) inflate specific screens.
2026-07-14 17:09:52 +02:00
Bitsy 2dad6c9bcc fix(parity): use Path.replace so re-runs overwrite prior native PNGs on Windows
Path.rename raised FileExistsError mid-sweep when a screen's
<Name>.native.png already existed from an earlier run (Windows rename
won't overwrite). replace() is an atomic overwrite everywhere.

Full 56-screen sweep now completes: smooth 2%%(Counter)..32%%(Tabs)
distribution, no catastrophic outlier — confirms this session's renderer
changes (lazy clips, batch flush, TLS fix) left no screen blank or
structurally broken. Text density + known SDL parity gaps (solid
gradients, effect differences) explain the top of the ranking.
2026-07-14 17:08:44 +02:00
Bitsy 723f80c8af feat(parity): native-vs-jvm screenshot diff harness (ROADMAP item 5)
A regression net built on the demo's dual-stack design: the same
commonMain screens render on native (SDL/Skia) and on upstream Compose
Desktop (jvm), and scripts/parity/parity.py pixel-diffs them per screen.

- MainJvm gains a headless --screenshot-all=<dir> mode: renders every
  registered screen via ImageComposeScene (density 1, same
  verticalScroll+padding wrapper as MainNative's --screen path) to PNGs
  in ONE jvm process.
- parity.py drives the native exe per screen (--screen/--screenshot),
  diffs against the jvm PNGs with a per-channel tolerance, and writes
  <pct>_<Name>_diff.png (amplified heatmap) + _compare.png (side by
  side) to build/parity/ (gitignored), ranked worst-first by the pct
  filename prefix + report.txt.

The ranking is the signal, not the absolute %: fonts differ between
stacks (text ghosts in the heatmap), so a screen jumping well above its
neighbours/history is the regression. Verified on Buttons/Shapes/Colors
(~16/14/4% — all font drift, structure aligned).
2026-07-14 17:06:08 +02:00
Bitsy 450247175f feat(sdl): deterministic native-resource lifecycle — release queue + bitmap cleaner
ROADMAP.md item 1. SdlImageBitmap textures were NEVER freed (manual SDL
memory the GC nudge can't touch) — the lazy-texture rework made it worse
since the render-target path also leaked. Now:

- NativeReleaseQueue (:ui): thread-safe queue of release actions drained
  on the MAIN thread each frame by the window loop. SDL calls aren't
  thread-safe and both disposal triggers are off-main (a Cleaner runs on
  a GC worker; close() can be reached from other dispatchers), so both
  just ENQUEUE.
- SdlImageBitmap: holds its texture/surface in a by-ref holder with a
  Cleaner that enqueues destruction (backstop) + an explicit close()
  (prompt path). Idempotent — first to run nulls the handles.
- ComposeWindow drains the queue between frames and once at teardown.

This makes the periodic GC.collect() nudge a safeguard rather than the
disposal mechanism (issue #2). Verified end-to-end in the example:
a direct enqueue and a real Cleaner->enqueue->drain cycle both fire.
close()-on-eviction wiring is a documented follow-up (caches hold the
ImageBitmap interface, not the concrete type).
2026-07-14 16:55:33 +02:00
Bitsy 5d6f772a1c fix(sdl): lazily-realized clips use push-time region, not the narrowed current clip
The TLS-chain regression (only the first card + nothing below it, until
a scroll shifted things): realizePendingClips re-intersected each
pending clip's region with the CURRENT fClip. A lazy clip can realize
arbitrarily deep inside descendants whose own clips have since narrowed
fClip — so an OUTER clip (the dialog's rounded Surface) realized with
the innermost descendant's tiny rect as its region. Its offscreen then
composited back only that sliver; every sibling card + the button
drawn into the same offscreen fell outside the blit and vanished.

Each pending clip now realizes with its OWN push-time region
(enclosing-clip-at-push ∩ bbox, already captured in PendingRoundClip),
and the real (already-narrowed) drawing clip is saved across the
realize loop and restored after, so draws into the innermost target
stay correctly clipped.

Reproduced with an outer rounded Surface + height-bounded verticalScroll
of rounded+bordered cards + a button (the TLS dialog's exact shape):
first-card-only before, full list + button after. Demo scroll/card
screens and bubble-wrap unaffected.
2026-07-14 16:37:45 +02:00
Bitsy 575c19f921 feat(window): CDN_PROFILE=1 per-phase frame profiler
Prints avg/max ms per main-loop phase (events / app pump / window pump /
render) every ~2s of rendered frames, via SDL performance counters.
Bubble-wrap sheet under hover: render=32/41ms dominates a 39ms frame —
confirms the renderer, not composition, is the 75Hz bottleneck
(ROADMAP.md item 2).
2026-07-14 16:19:56 +02:00
Bitsy 328663b9e7 docs: renderer roadmap — lifecycle, performance, parity, tooling 2026-07-14 16:14:54 +02:00
Bitsy c9ddd3488d fix(sdl): auto-flush the vertex batch only on triangle boundaries
SDL_RenderGeometry renders floor(count/3) triangles and silently drops
the tail. writeVertex auto-flushed the batch whenever it was full — at
ANY vertex boundary — so a triangle straddling the flush lost its tail
vertices AND left orphan vertices at the head of the next batch,
misaligning every triangle after it: ~350° pac-man circles and glitch
shapes that shifted with every redraw once a frame's geometry outgrew
one batch. Never seen before because clip layers flushed constantly;
bubble-wrap's new unclipped drawBehind sheet (84 bubbles, one giant
batch, ~4 overflows per frame) exposed it — most of the sheet simply
vanished.

Flush now only fires at multiples of 3, and kBatchCapacity drops
8192 → 8190 (a multiple of 3, so the cap lands on a triangle boundary
and the buffer can never overrun).
2026-07-14 16:08:07 +02:00
Bitsy 9958386792 fix(bridge): data.kres zips must not race executable declaration
The resources packaging pre-scanned link-task names in afterEvaluate —
but executables can be declared AFTER that runs, notably by the
bridge's own compose.desktop.native { entryPoint } DSL (also
afterEvaluate, registered later). The scan found no link tasks and
silently packaged nothing: 'data.kres not found' at startup. The old
single-module example masked this with stale build output; a fresh
multi-module layout exposed it.

Zips are now pre-registered lazily for every desktop target/variant and
wired via tasks.matching{}.configureEach — order-independent, and an
unrealised task is free when the target has no executable. (Registering
the zip reactively from inside the link task's configuration callback
is forbidden by Gradle — 'cannot be executed in the current context'.)
Content wiring moved into the zip's own configuration action, which
runs at realisation when the Compose plugin's prepare tasks and the
final compose.resources config exist.

Verified on the restructured multi-module bubble-wrap (shared KMP
library with the new com.android.kotlin.multiplatform.library DSL +
androidApp): packageDebugComposeResourcesMingwX64 runs, data.kres lands
next to shared.exe with the drawable + converted .cvr strings, app
boots with resources loading.
2026-07-14 14:39:29 +02:00
11 changed files with 635 additions and 41 deletions
+40
View File
@@ -458,6 +458,46 @@ build tell you what broke.
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't `Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
ship drop-in equivalents for our anchor / scrim patterns). ship drop-in equivalents for our anchor / scrim patterns).
## Parity harness — native-vs-JVM screenshot diff
`:demo` renders the **same commonMain screens** on two stacks: native
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
`scripts/parity/parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that visually diverges is a **port regression** (missing
content, wrong shape/colour, broken clip). Several past renderer regressions
would have been caught here.
```bash
python scripts/parity/parity.py # all screens (builds first)
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse the last renders
```
Mechanics: the JVM leg renders all screens headlessly via `ImageComposeScene`
in ONE process (`:demo:run --args=--screenshot-all=<dir>`, wired in
`MainJvm.kt`); the native leg launches the exe once per screen
(`--screen=<Name> --screenshot=<x>.bmp`). Output lands in **`build/parity/`
(gitignored)**: `<pct>_<Name>_diff.png` (amplified difference heatmap),
`<pct>_<Name>_compare.png` (native ∣ jvm ∣ diff, side by side), and
`report.txt` ranked worst-first. Windows-only for the native leg today; needs
Pillow. See `scripts/parity/README.md`.
**What the number means — read this before trusting it.** The `%differ` is the
fraction of pixels whose per-channel difference exceeds a tolerance. It is NOT
a pass/fail score and pixel-perfection is not the goal: the two stacks use
different default fonts, so **every screen carries a steady baseline
difference** — in the heatmap, text shows as a faint *doubled ghost* from
slightly different line metrics/baselines. A healthy full sweep is a smooth
gradient (~2% for a sparse screen like Counter, up to ~32% for a text-dense
one like Tabs). Known SDL parity gaps also inflate specific screens
predictably (Brushes ~23% — gradients render solid on SDL; Shadows / Canvas /
GraphicsLayer — effect differences). **The signal is the RANKING and the
delta from a screen's own history**, not the absolute value: a text-light
screen suddenly reading 60%, or a screen jumping far above its neighbours, is
the bug. In a `_diff.png`: ghosted/doubled text + dark shapes = normal font
drift; a **solid bright block, or a shape present on only one side** = a real
regression — open the `_compare.png` to see which stack is wrong.
## Conventions ## Conventions
Kotlin standard style — plain `camelCase` for parameters, local variables, Kotlin standard style — plain `camelCase` for parameters, local variables,
+91
View File
@@ -0,0 +1,91 @@
# Renderer roadmap — fixes, performance, memory
Grounded in the bubble-wrap findings (2026-07): lazy clips took the stress
sheet from 23 → 39 FPS, but 75 Hz needs structural work; issue #2 showed that
Cleaner-managed native memory starves when the Kotlin heap is quiet.
## 1. Deterministic native-resource lifecycle
Ownership closes resources the moment they're finished; GC stays as the
safeguard, not the mechanism.
- [x] **`NativeReleaseQueue`** — SDL calls are main-thread-only, so nothing may
destroy directly from a Cleaner or worker: release actions ENQUEUE, the main
loop DRAINS between frames. Cleaners become safe from any thread because
they only enqueue. Verified end-to-end (direct enqueue + a real
Cleaner→enqueue→drain cycle).
- [x] **`SdlImageBitmap` textures** — previously never destroyed (manual SDL
memory, invisible to the GC nudge). A holder + Cleaner now enqueues
`SDL_DestroyTexture` / `SDL_DestroySurface` for the texture, render target
and never-realized decoded surface; explicit `close()` frees promptly.
- [ ] **Wire `close()` into cache eviction** — the Cleaner is the current
release path for decoded/vector bitmaps (correct but GC-timed). Call
`SdlImageBitmap.close()` on image-cache eviction / `removeMemoryResource`
for prompt release (needs a cross-module hook — the caches hold the
`ImageBitmap` interface, not the concrete type).
- [ ] **Cache-eviction closes (rest)** — every image/text cache closes what it
evicts (Sdl3ImageCache already does; sweep the rest, both renderers).
- [ ] **Renderer `destroy()` chain** — window close synchronously frees every
pool (clip targets already do; text caches, image caches, shadow cache,
typefaces to follow).
- [ ] **`SkiaImageBitmap.close()`** (surface + snapshot) + close-on-eviction in
the Skia caches. *Skia source sets build only on macOS/Linux — needs a mac
or CI leg to verify.*
- [ ] **Demote the GC nudge** — once ownership does the work, stretch the 10s
interval / gate it on churn; it remains the backstop.
Verification: `demo --leaktest`-style probe (cycle screens N times, print
RSS; expect a plateau without GC sawtooth).
## 2. SDL renderer performance (the 39 → 75 Hz path)
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
(events / app pump / window pump / render / present) every ~2 s. Measure
first, optimize second.
- [ ] **Right-size clip scratch targets** — the pool allocates window-sized
textures per mask depth; allocate at the clip bbox (size-bucketed pool) to
cut fill-rate for the masks that remain after lazy clips.
- [ ] **Dirty-region rendering** — the big structural item: accumulate damage
from invalidated layers, scissor redraw to it. One bubble's spring should
not re-tessellate 84. Most likely single item to reach 75 Hz.
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
flush per run); an atlas texture lets glyphs ride the vertex batches.
- [ ] **Retained layer textures** (later) — RenderNode-style caching of static
subtrees keyed by draw content.
## 3. Skia renderer
- [ ] Bounded `SkiaImageCache` (LRU + eviction close) — unbounded HashMap today.
- [ ] Eager-close audit of the text pipeline (mirror of the SDL sweep).
- [ ] `saveLayer` huge-bounds clamp (GPU offscreen memory spikes — see
CLAUDE.md pitfall).
## 4. Correctness / parity gaps (future bug reports waiting)
Ordered by likelihood of a user hitting them:
- [ ] **Gradient brushes on SDL** — `Brush.linearGradient/radialGradient`
render solid; the per-vertex sampler infrastructure is half-present.
- [ ] **Layer rotation** — rotationZ repositions but content doesn't rotate
(hit-testing too).
- [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
paint level; needs an offscreen (the clip-target pool can serve it).
- [ ] **`clipPath` generic shapes** — bbox fallback clips square.
## 5. Permanent tooling
- [x] **Parity harness** — scripts/parity/: renders every :demo screen native +
JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
emits per-screen diff heatmaps + side-by-side compares (pct in filename).
build/parity/ (gitignored). See scripts/parity/README.md.
- [ ] Promote the press/hover automation rig (window-handle-relative input +
screenshot) into `scripts/`.
## 6. Long-term
- [ ] **SDL_GPU render backend** — real stencil clipping (no masks at all),
pipelined batching, shader gradients. Weeks of work across three
platforms; items 2–4 stay useful beneath it.
Suggested order: profiler → lifecycle queue + leak closes → clip-target
right-sizing → gradients → dirty regions → the rest by demand.
@@ -34,6 +34,8 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.reinterpret import kotlinx.cinterop.reinterpret
import sdl3.SDL_Delay import sdl3.SDL_Delay
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks import sdl3.SDL_GetTicks
import sdl3.SDL_Quit import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle import sdl3.SDL_SetWindowTitle
@@ -148,7 +150,9 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Main loop // Main loop
var vGcLastTicks = SDL_GetTicks() var vGcLastTicks = SDL_GetTicks()
var vRenderedSinceGc = false var vRenderedSinceGc = false
val vProfiler = if (platform.posix.getenv("CDN_PROFILE") != null) FrameProfiler() else null
while (!runtime.exitRequested) { while (!runtime.exitRequested) {
vProfiler?.mark()
Snapshot.sendApplyNotifications() Snapshot.sendApplyNotifications()
// ============ // ============
@@ -176,6 +180,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
} }
mainDispatcher.drainPending() mainDispatcher.drainPending()
vProfiler?.phase(0)
// ============ // ============
// App composition pump — Window()s may appear / disappear here. // App composition pump — Window()s may appear / disappear here.
@@ -195,6 +200,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Exit when the last window is gone (after at least one existed). // Exit when the last window is gone (after at least one existed).
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
if (runtime.exitRequested) break if (runtime.exitRequested) break
vProfiler?.phase(1)
// ============ // ============
// Per-window pump + render. // Per-window pump + render.
@@ -208,13 +214,24 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
Snapshot.sendApplyNotifications() Snapshot.sendApplyNotifications()
vW.frameClock.sendFrame() vW.frameClock.sendFrame()
yield() yield()
vProfiler?.phase(2)
if (vW.shouldRender()) { if (vW.shouldRender()) {
vW.renderFrame() vW.renderFrame()
vAnyRendered = true vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false if (!vW.backend.vsyncEnabled) vAllVsync = false
} }
vProfiler?.phase(3)
} }
runtime.reapDestroyed() runtime.reapDestroyed()
vProfiler?.frameDone(vAnyRendered)
// ============
// Drain deferred native-resource disposals on the MAIN thread —
// textures/surfaces whose owner closed them or whose Cleaner fired
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
// is the ownership path that makes the GC nudge below a mere
// backstop (ROADMAP.md item 1).
com.compose.sdl.graphics.NativeReleaseQueue.drain()
// ============ // ============
// Pace / idle-skip. // Pace / idle-skip.
@@ -250,6 +267,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
snapshotHandle.dispose() snapshotHandle.dispose()
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW) for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
runtime.reapDestroyed() runtime.reapDestroyed()
com.compose.sdl.graphics.NativeReleaseQueue.drain()
appComposition.dispose() appComposition.dispose()
appRecomposer.cancel() appRecomposer.cancel()
appRecomposeJob.cancelAndJoin() appRecomposeJob.cancelAndJoin()
@@ -262,6 +280,47 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit() SDL_Quit()
} }
/* CDN_PROFILE=1 — per-phase main-loop timings, printed every ~2s of rendered
frames. Phases: events (poll+dispatch), app (app-composition pump), pump
(per-window clocks/dispatch), render (layout+draw+present). Measure first,
optimize second — see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal class FrameProfiler {
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
private val fSum = DoubleArray(4)
private val fMax = DoubleArray(4)
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
fun mark() { fMark = SDL_GetPerformanceCounter() }
fun phase(inIndex: Int) {
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inIndex] += vMs
if (vMs > fMax[inIndex]) fMax[inIndex] = vMs
fMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (inRendered) fFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vNames = listOf("events", "app", "pump", "render")
val vParts = vNames.mapIndexed { vI, vName ->
val vAvg = fSum[vI] / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${(fMax[vI] * 100).toInt() / 100.0}ms"
}
val vTotal = fSum.sum() / fFrames
println("[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " total=${(vTotal * 100).toInt() / 100.0}ms")
for (vI in fSum.indices) { fSum[vI] = 0.0; fMax[vI] = 0.0 }
fFrames = 0
fLastPrintMs = vNowMs
}
}
}
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release /* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */ their native memory (see the main loop's native-memory nudge). */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class) @OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
@@ -0,0 +1,55 @@
package com.compose.sdl.graphics
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
// ==================
// MARK: NativeReleaseQueue — main-thread-deferred native resource disposal
// ==================
/**
* A queue of native-resource release actions drained on the MAIN THREAD once
* per frame by the window loop (see ComposeWindow). It exists because the two
* ways a renderer resource dies are both off the main thread, while the calls
* that free them (SDL_DestroyTexture, and any renderer API) are main-thread-only:
*
* - a Kotlin/Native `Cleaner` runs on a GC worker thread, and
* - explicit `close()` can be reached from composition/effect code on other
* dispatchers.
*
* Both simply ENQUEUE here; the main loop runs the actions between frames. This
* turns the GC (issue #2: Cleaner-managed native memory starving because the
* quiet Kotlin heap rarely triggers a collection) from the disposal MECHANISM
* into a mere safeguard — ownership closes resources promptly, the Cleaner is
* the backstop for whatever leaks a `close()`.
*
* Actions must be idempotent-safe at the source (guard against a resource being
* both explicitly closed and later GC-cleaned — see SdlImageBitmap's holder).
*/
object NativeReleaseQueue {
private val fLock = SynchronizedObject()
private var fPending = ArrayList<() -> Unit>()
/** Enqueue a release action. Safe to call from any thread. */
fun enqueue(action: () -> Unit) {
synchronized(fLock) { fPending.add(action) }
}
/**
* Run and clear every queued action. MAIN THREAD ONLY — the actions call
* renderer APIs that aren't thread-safe. Returns the number drained.
*/
fun drain(): Int {
val vBatch = synchronized(fLock) {
if (fPending.isEmpty()) return 0
val vTaken = fPending
fPending = ArrayList()
vTaken
}
for (vAction in vBatch) {
runCatching { vAction() }
}
return vBatch.size
}
}
@@ -228,15 +228,27 @@ internal class Sdl3Canvas(
} }
} }
/* Convert pending clips into real offscreen mask layers (outermost first). */ /* Convert pending clips into real offscreen mask layers (outermost first).
Each clip realizes with its OWN push-time region (pending.region =
enclosing-clip-at-push ∩ bbox), NOT the current fClip: a lazily-realized
clip may fire arbitrarily deep inside descendants whose own clips have
since narrowed fClip. Re-intersecting with that narrow clip shrank an
outer clip's offscreen to the innermost descendant's rect, so its
composite blitted back only that sliver and every sibling drawn into the
same offscreen was discarded (the TLS-chain regression: only the first
card + nothing below it, until a scroll shifted which card triggered
realization). The real (already-narrowed) drawing clip is preserved
across the loop and restored at the end, so subsequent draws into the
innermost target stay correctly clipped. */
private fun realizePendingClips() { private fun realizePendingClips() {
if (fPendingClips.isEmpty()) return if (fPendingClips.isEmpty()) return
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>() val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
val vDrawClip = fClip
while (fPendingClips.isNotEmpty()) { while (fPendingClips.isNotEmpty()) {
val vPending = fPendingClips.removeFirst() val vPending = fPendingClips.removeFirst()
fScope.flush() fScope.flush()
// A clipRect after the push may have narrowed the visible area. val vRegion = vPending.region
val vRegion = intersect(fClip, vPending.region)
val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt()) val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) { if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) {
// Degrade to the rect clip that is already active. // Degrade to the rect clip that is already active.
@@ -258,6 +270,10 @@ internal class Sdl3Canvas(
} }
} }
} }
// Restore the real drawing clip (already narrowed by inner clipRects /
// scroll bounds) for draws into the now-active innermost target.
fClip = vDrawClip
applyClip()
} }
// Flushes any pending batched geometry to SDL, then frees the scope's // Flushes any pending batched geometry to SDL, then frees the scope's
@@ -893,7 +893,13 @@ internal class Sdl3DrawScope(
// through the current affine so scale/rotate reach the GPU; the colour is // through the current affine so scale/rotate reach the GPU; the colour is
// sampled at the pre-transform point so a gradient rides its shape. // sampled at the pre-transform point so a gradient rides its shape.
private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) { private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) {
if (fBatchCount >= kBatchCapacity) flush() // Auto-flush ONLY at a triangle boundary: SDL_RenderGeometry renders
// floor(count/3) triangles and silently drops the tail, so flushing
// mid-triangle tears it AND misaligns every following triangle in the
// new batch (seen as ~350-degree pac-man circles and glitch shapes
// once a frame's geometry outgrew one batch - e.g. 84 unclipped
// drawBehind bubbles). kBatchCapacity is a multiple of 3.
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
val vBase = fBatchCount * kFloatsPerVertex val vBase = fBatchCount * kFloatsPerVertex
fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe
fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf
@@ -914,11 +920,13 @@ private const val kAaFeather: Float = 1.0f
private const val kAaHalf: Float = kAaFeather * 0.5f private const val kAaHalf: Float = kAaFeather * 0.5f
// ============ // ============
// Batch capacity — 8192 vertices = ~2730 triangles per submission. At // Batch capacity — 8190 vertices = 2730 triangles per submission (kept a
// 64 segments per full circle that's room for ~21 full-circle filled // MULTIPLE OF 3: the auto-flush in writeVertex only fires on triangle
// shapes per Canvas{} before any flush. Bigger gives fewer GPU // boundaries, so the cap must land on one). At 64 segments per full circle
// submissions; smaller saves RAM. ~128 KB at 16 bytes per SDL_Vertex. // that's room for ~21 full-circle filled shapes per Canvas{} before any
private const val kBatchCapacity: Int = 8192 // flush. Bigger gives fewer GPU submissions; smaller saves RAM. ~128 KB at
// 16 bytes per SDL_Vertex.
private const val kBatchCapacity: Int = 8190
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly // Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across. // packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
@@ -29,6 +29,34 @@ internal var currentMainCanvas: Sdl3Canvas? = null
// An ImageBitmap backed by an SDL render-target texture. The vector rasterises // An ImageBitmap backed by an SDL render-target texture. The vector rasterises
// into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect // into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect
// blits it back — with the Icon tint applied through SDL_SetTextureColorMod. // blits it back — with the Icon tint applied through SDL_SetTextureColorMod.
// Mutable native handles held BY REFERENCE so a Cleaner can free them without
// capturing the (collectable) SdlImageBitmap. Both the explicit close() and the
// GC Cleaner enqueue release of whatever is still here — the first to run nulls
// the fields, so the other is a no-op (they can't overlap: close() only runs
// while the bitmap is reachable, the Cleaner only after it isn't).
@OptIn(ExperimentalForeignApi::class)
private class SdlBitmapHandles(
var texture: COpaquePointer?,
var surface: CPointer<SDL_Surface>?,
)
/* Enqueue destruction of the handles' current contents on the main thread
(SDL calls aren't thread-safe; the Cleaner runs on a GC worker). Idempotent:
clears the fields so a second call frees nothing. Top-level so the Cleaner
block never captures the bitmap. */
@OptIn(ExperimentalForeignApi::class)
private fun enqueueRelease(inHandles: SdlBitmapHandles) {
val vTex = inHandles.texture
val vSurf = inHandles.surface
if (vTex == null && vSurf == null) return
inHandles.texture = null
inHandles.surface = null
com.compose.sdl.graphics.NativeReleaseQueue.enqueue {
if (vSurf != null) SDL_DestroySurface(vSurf)
if (vTex != null) SDL_DestroyTexture(vTex.reinterpret())
}
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
internal class SdlImageBitmap( internal class SdlImageBitmap(
private val fRenderer: COpaquePointer, private val fRenderer: COpaquePointer,
@@ -41,19 +69,28 @@ internal class SdlImageBitmap(
// Sdl3EncodedImageDecoder); converted to a texture on the first draw. // Sdl3EncodedImageDecoder); converted to a texture on the first draw.
// null → create a render TARGET for the vector-rasterisation path below // null → create a render TARGET for the vector-rasterisation path below
// (that path always constructs on the main thread). // (that path always constructs on the main thread).
private var fDecodedSurface: CPointer<SDL_Surface>? = null, fDecodedSurface: CPointer<SDL_Surface>? = null,
) : ImageBitmap { ) : ImageBitmap {
// RGBA render-target texture, premultiplied blend for compositing back (content // RGBA render-target texture, premultiplied blend for compositing back (content
// is drawn over a transparent clear with ordinary BLEND, leaving premultiplied // is drawn over a transparent clear with ordinary BLEND, leaving premultiplied
// colours — see Sdl3ClipTargets for the same reasoning). // colours — see Sdl3ClipTargets for the same reasoning).
private var fTexture: COpaquePointer? = if (fDecodedSurface != null) null else SDL_CreateTexture( private val fHandles = SdlBitmapHandles(
fRenderer.reinterpret(), texture = if (fDecodedSurface != null) null else SDL_CreateTexture(
SDL_PIXELFORMAT_RGBA32, fRenderer.reinterpret(),
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET, SDL_PIXELFORMAT_RGBA32,
maxOf(1, width), SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
maxOf(1, height), maxOf(1, width),
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) } maxOf(1, height),
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) },
surface = fDecodedSurface,
)
// GC backstop: if nothing calls close(), free the native handles when the
// bitmap is collected (deferred to the main thread via the queue). Ownership
// via close() is the primary path; this catches leaks.
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
private val fCleaner = kotlin.native.ref.createCleaner(fHandles) { enqueueRelease(it) }
// SDL renderer calls are NOT thread-safe, and the resources pipeline // SDL renderer calls are NOT thread-safe, and the resources pipeline
// decodes on Dispatchers.Default workers — so the decode path hands over a // decodes on Dispatchers.Default workers — so the decode path hands over a
@@ -63,15 +100,20 @@ internal class SdlImageBitmap(
// render-target path above. // render-target path above.
val texture: COpaquePointer? val texture: COpaquePointer?
get() { get() {
fDecodedSurface?.let { vSurface -> fHandles.surface?.let { vSurface ->
fTexture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface) fHandles.texture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) } ?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) }
SDL_DestroySurface(vSurface) SDL_DestroySurface(vSurface)
fDecodedSurface = null fHandles.surface = null
} }
return fTexture return fHandles.texture
} }
/* Free the texture/surface now (deferred to the main thread). Call on
cache eviction / removeMemoryResource so native memory releases promptly
instead of waiting for a GC. The Cleaner remains as a backstop. */
fun close() = enqueueRelease(fHandles)
override fun readPixels( override fun readPixels(
buffer: IntArray, buffer: IntArray,
startX: Int, startX: Int,
+81 -12
View File
@@ -1,23 +1,92 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import demo.registry.allCategories
import demo.shell.App import demo.shell.App
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
// The JVM comparison app's entry point. Runs the SAME shared App() shell + Core / // The JVM comparison app: the SAME shared App() + screens as :demo, on upstream
// Material 3 screens (expressive included) as :demo, but on Compose Desktop (JVM) // Compose Desktop. Interactive by default; a headless screenshot mode drives
// against upstream org.jetbrains.compose. Compare side-by-side with // the parity harness (scripts/parity — compares each screen native vs jvm).
// `:demo:runDebugExecutable<host>`. //
fun main() = application { // --screenshot-all=<dir> render every registered screen to <dir>/<Name>.png
Window( // --width / --height viewport size (default 1000 / 700)
onCloseRequest = ::exitApplication, //
title = "ComposeDesktopNative — JVM (upstream Compose)", // The single-screen wrapper MIRRORS MainNative's --screen path (dark theme,
state = rememberWindowState(width = 1000.dp, height = 700.dp), // verticalScroll + 24dp padding) so layout constraints match the native
) { // screenshots pixel-for-pixel.
MaterialTheme(colorScheme = darkColorScheme()) { fun main(args: Array<String>) {
App(isJvm = true) val screenshotDir = args.firstOrNull { it.startsWith("--screenshot-all=") }?.substringAfter('=')
if (screenshotDir != null) {
screenshotAllScreens(
outDir = File(screenshotDir),
width = args.intArg("--width", 1000),
height = args.intArg("--height", 700),
)
return
}
application {
Window(
onCloseRequest = ::exitApplication,
title = "ComposeDesktopNative — JVM (upstream Compose)",
state = rememberWindowState(width = 1000.dp, height = 700.dp),
) {
MaterialTheme(colorScheme = darkColorScheme()) {
App(isJvm = true)
}
} }
} }
} }
private fun Array<String>.intArg(name: String, default: Int): Int =
firstOrNull { it.startsWith("$name=") }?.substringAfter('=')?.toIntOrNull() ?: default
/* Render each registered screen headlessly (density 1 to match the native
physical-pixel screenshots) and write a PNG per screen. */
@OptIn(ExperimentalComposeUiApi::class)
private fun screenshotAllScreens(outDir: File, width: Int, height: Int) {
outDir.mkdirs()
val screens = allCategories().flatMap { it.screens }.distinctBy { it.name }
for (screen in screens) {
val scene = ImageComposeScene(width, height, density = Density(1f)) {
ScreenHost { screen.content() }
}
try {
val image = scene.render()
val png = image.encodeToData(EncodedImageFormat.PNG) ?: continue
File(outDir, "${screen.name}.png").writeBytes(png.bytes)
} finally {
scene.close()
}
println("jvm screenshot: ${screen.name}")
}
}
/* Same wrapper as MainNative's --screen path. */
@Composable
private fun ScreenHost(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
) { content() }
}
}
@@ -54,17 +54,24 @@ private val desktopTargets = mapOf(
) )
private fun registerDataKresTasks(project: Project) { private fun registerDataKresTasks(project: Project) {
val taskNames = project.tasks.names // The zips are PRE-REGISTERED lazily for every desktop target/variant and
val resPackage = project.resolveResourcePackage() // wired to their link task via matching{}.configureEach: executables may
// be declared after this afterEvaluate runs — notably by the bridge's OWN
// compose.desktop.native { entryPoint } DSL, which also materialises in
// afterEvaluate. (A name pre-scan raced that and silently packaged
// nothing — the "data.kres not found" crash; and Gradle forbids
// registering tasks from inside another task's configuration callback,
// so the zip cannot be created reactively either.) An unrealised
// registered task costs nothing: if the target has no executable, the
// link task never appears and the zip never runs. All content wiring
// happens in the zip's own configuration action, which only executes on
// realisation — by then the Compose plugin's prepare tasks and the final
// compose.resources config exist.
for ((target, sourceSets) in desktopTargets) { for ((target, sourceSets) in desktopTargets) {
for (variant in listOf("Debug", "Release")) { for (variant in listOf("Debug", "Release")) {
val linkName = "link${variant}Executable$target" val linkName = "link${variant}Executable$target"
if (linkName !in taskNames) continue
val zipName = "package${variant}ComposeResources$target" val zipName = "package${variant}ComposeResources$target"
if (zipName in taskNames) continue if (zipName in project.tasks.names) continue
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
.filter { it in taskNames }
val zipTask = project.tasks.register(zipName, Zip::class.java) { task -> val zipTask = project.tasks.register(zipName, Zip::class.java) { task ->
task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable." task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable."
task.archiveFileName.set("data.kres") task.archiveFileName.set("data.kres")
@@ -77,6 +84,10 @@ private fun registerDataKresTasks(project: Project) {
// decoders — an entry is one fseek+fread, never inflated. // decoders — an entry is one fseek+fread, never inflated.
task.entryCompression = ZipEntryCompression.STORED task.entryCompression = ZipEntryCompression.STORED
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
val resPackage = project.resolveResourcePackage()
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
.filter { it in project.tasks.names }
for (prepareName in prepareNames) { for (prepareName in prepareNames) {
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor") val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor")
.replaceFirstChar { it.lowercase() } .replaceFirstChar { it.lowercase() }
@@ -87,7 +98,7 @@ private fun registerDataKresTasks(project: Project) {
task.dependsOn(project.tasks.named(prepareName)) task.dependsOn(project.tasks.named(prepareName))
} }
} }
project.tasks.named(linkName) { it.dependsOn(zipTask) } project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
} }
} }
} }
+47
View File
@@ -0,0 +1,47 @@
# Parity harness
Renders every `:demo` screen on the **native** (SDL/Skia, Kotlin/Native) stack
and on the **JVM** upstream-Compose stack from the *same* commonMain
composables, then pixel-diffs them per screen. It's a regression net: a screen
whose difference jumps far above its usual level is a port bug (missing
content, wrong shape/colour, broken clip). Several of this project's renderer
regressions would have surfaced here.
```bash
python scripts/parity/parity.py # all screens (builds first)
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse the last renders
python scripts/parity/parity.py --gpu=sdl3 # native renderer (default sdl3)
```
Output → `build/parity/` (gitignored):
- `<pct>_<Name>_compare.png` — native ∣ jvm ∣ amplified-diff, side by side
- `<pct>_<Name>_diff.png` — the amplified difference heatmap alone
- `report.txt` — screens ranked by % differing
The `<pct>` prefix is zero-padded, so a plain file listing (or the report)
sorts worst-first.
## Reading the result
**Absolute % is not the metric — the ranking is.** The two stacks use
different default fonts, so text carries a steady baseline difference (in the
heatmap, every text line shows a faint *doubled* ghost from slightly different
line metrics). Buttons, cards, shapes, images should align (dark). So:
- **doubled/ghosted text, dark shapes** → normal font drift.
- **a solid bright block, or a shape present on one side only** → a real
regression. Open the `_compare.png` to see which stack is wrong.
Compare a screen's % against its neighbours and its own history: Buttons ~16%,
Shapes ~14%, Colors ~4% are the healthy baseline (mostly text). A screen that
reads 60% when text-light is the bug.
## Requirements
- Windows for the native leg today (mingwX64 exe); the JVM leg is host-neutral.
- Pillow (`pip install pillow`).
- The JVM leg renders all screens headlessly via `ImageComposeScene` in one
process (`:demo:run --args=--screenshot-all=…`); native takes one exe launch
per screen.
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Parity harness — render every demo screen on the NATIVE (SDL/Skia) stack and on
the JVM upstream-Compose stack, then pixel-diff them per screen.
The two stacks share the exact same commonMain screen composables, so a screen
that suddenly diverges from its usual difference level is a PORT REGRESSION
(missing content, wrong shape/colour, broken clip). Absolute pixel-perfection
is NOT the goal — fonts differ between stacks, so text-heavy screens carry a
steady baseline difference. The signal is the RANKING: a screen that jumps from
~8% to ~60% different is the bug. Four of the renderer regressions this project
hit would have surfaced here.
Usage (from repo root):
python scripts/parity/parity.py # all screens
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse existing screenshots
Outputs to build/parity/ (gitignored):
<pct>_<Name>_compare.png native | jvm | amplified-diff, side by side
<pct>_<Name>_diff.png the amplified pixel-difference heatmap alone
report.txt ranked table
The <pct> prefix is zero-padded so a plain file listing sorts worst-first.
Windows-only for the native leg today (mingwX64 exe). Needs Pillow.
Native uses the SDL renderer; pass --gpu to change.
"""
import subprocess, sys, os, shutil
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "parity"
NATIVE_EXE = REPO / "demo" / "build" / "bin" / "mingwX64" / "debugExecutable" / "demo.exe"
WIDTH, HEIGHT = 1000, 700
# Per-channel tolerance: below this a pixel counts as "same" (JPEG-ish noise,
# sub-pixel AA, font hinting). Tuned so unrelated screens sit well under it.
TOL = 32
GRADLEW = str(REPO / ("gradlew.bat" if os.name == "nt" else "gradlew"))
def run(cmd, **kw):
print("+ " + " ".join(str(c) for c in cmd))
return subprocess.run(cmd, cwd=REPO, **kw)
def build():
run([GRADLEW, ":demo:linkDebugExecutableMingwX64", "--console=plain"], check=True)
def jvm_shots(dst: Path):
run([GRADLEW, ":demo:run", f"--args=--screenshot-all={dst}", "--console=plain"], check=True)
def native_shot(name: str, dst: Path, gpu: str):
bmp = dst / f"{name}.bmp"
run([str(NATIVE_EXE), f"--screen={name}", f"--screenshot={bmp}",
f"--gpu={gpu}", f"--width={WIDTH}", f"--height={HEIGHT}"],
check=False, timeout=60)
if bmp.exists():
Image.open(bmp).convert("RGB").save(dst / f"{name}.png")
bmp.unlink()
def diff_pair(native: Image.Image, jvm: Image.Image):
"""Return (percent_differing, amplified_diff_image)."""
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
a = native.crop((0, 0, w, h)).convert("RGB")
b = jvm.crop((0, 0, w, h)).convert("RGB")
diff = ImageChops.difference(a, b)
# A pixel "differs" if any channel exceeds TOL.
gray = diff.convert("L")
mask = gray.point(lambda p: 255 if p > TOL else 0)
differing = sum(mask.point(lambda p: 1 if p else 0).getdata())
pct = 100.0 * differing / (w * h)
amplified = diff.point(lambda p: min(255, p * 4))
return pct, amplified
def side_by_side(native, jvm, diff, path):
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
canvas = Image.new("RGB", (w * 3 + 20, h + 20), (16, 16, 16))
canvas.paste(native.crop((0, 0, w, h)), (0, 10))
canvas.paste(jvm.crop((0, 0, w, h)), (w + 10, 10))
canvas.paste(diff, (w * 2 + 20, 10))
canvas.save(path)
def main():
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
flags = [a for a in sys.argv[1:] if a.startswith("--")]
no_build = "--no-build" in flags
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")), "sdl3")
OUT.mkdir(parents=True, exist_ok=True)
jvm_dir = OUT / "_jvm"
# Clear last run's pct-prefixed visualizations so stale percentages don't
# linger alongside the fresh ones.
for old in list(OUT.glob("*_compare.png")) + list(OUT.glob("*_diff.png")):
old.unlink()
if not no_build:
build()
if jvm_dir.exists():
shutil.rmtree(jvm_dir)
jvm_dir.mkdir(parents=True)
jvm_shots(jvm_dir)
# Screen set: the JVM run enumerates the full registry → use its PNGs as the
# source of truth for names (native takes one exe launch each).
names = sorted(p.stem for p in jvm_dir.glob("*.png"))
if argv:
wanted = {n.lower() for n in argv}
names = [n for n in names if n.lower() in wanted]
if not names:
print("No screens matched.", file=sys.stderr)
return 1
results = []
for name in names:
jvm_png = jvm_dir / f"{name}.png"
if not no_build or not (OUT / f"{name}.native.png").exists():
native_shot(name, OUT, gpu)
src = OUT / f"{name}.png"
if src.exists():
src.replace(OUT / f"{name}.native.png")
native_png = OUT / f"{name}.native.png"
if not native_png.exists():
results.append((name, None))
continue
native = Image.open(native_png).convert("RGB")
jvm = Image.open(jvm_png).convert("RGB")
pct, amp = diff_pair(native, jvm)
# Zero-padded pct prefix → worst-first in any file listing.
prefix = f"{pct:06.2f}"
amp.save(OUT / f"{prefix}_{name}_diff.png")
side_by_side(native, jvm, amp, OUT / f"{prefix}_{name}_compare.png")
results.append((name, pct))
print(f" {name:28s} {pct:6.2f}% differing")
results.sort(key=lambda r: (-1 if r[1] is None else r[1]), reverse=True)
lines = ["screen %differ", "-" * 40]
for name, pct in results:
lines.append(f"{name:28s} {'NATIVE FAILED' if pct is None else f'{pct:6.2f}%'}")
report = "\n".join(lines)
(OUT / "report.txt").write_text(report + "\n", encoding="utf-8")
print("\n" + report)
print(f"\nDiff images + report in {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())