Summary
While re-verifying three earlier fixes on Retina hardware, I noticed that the GOGPU_DEBUG_DAMAGE=1 debug overlay — a "flash the pixels that just redrew" visualization, in the spirit of Chrome's paint flashing — was drawing its highlight boxes in the wrong place. Chasing that down turned into a logical-vs-physical-pixel units audit of the whole damage-tracking-to-present path, across both this repo and gogpu/gg (the ggcanvas integration this repo's renderer is built on). The audit turned up five distinct defects, all variations on the same underlying confusion, and two of them affect real compositing rather than just debug tooling.
The five findings at a glance
| # |
Repo |
Where |
Defect |
Affects |
Evidence |
| 1 |
gogpu/gg |
ggcanvas.Canvas.forwardDamageRects |
first-frame/post-resize damage sent to the OS compositor in logical pixels instead of physical |
Production — every resize |
headless |
| 2 |
gogpu/ui |
desktop's trackBoundaryDamage |
GPU scissor rect rounds the min corner and the size instead of the min and max corners independently — under-covers by one pixel |
Production — fractional device scales only |
headless |
| 3 |
gogpu/ui |
desktop's dirtyOverlay.draw |
GOGPU_DEBUG_DIRTY=1 overlay drawn at deviceScale² |
debug tooling |
headless |
| 4 |
gogpu/gg |
ggcanvas's damageOverlayState |
GOGPU_DEBUG_DAMAGE=1 overlay drawn at deviceScale² |
debug tooling |
hardware screenshot |
| 5 |
gogpu/gg |
ggcanvas.Canvas.Render |
damage-tracking suppression window restores tracking to a hardcoded value instead of the caller's prior state |
latent |
headless |
The common root
All five trace back to the same contract, stated in gg.Context.TrackDamageRect's own doc comment: bounds are logical (user-space) coordinates, and the context scales them to physical pixels internally for the OS compositor. Context.trackDamage honors that contract — it floors the min corner and ceils the max corner by the device scale before storing the rect. The obligation that follows is that everything downstream consuming that already-physical data has to know it's physical, and each finding gets that wrong in its own way. Findings 3 and 4 hand the physical rect to gg's ordinary Fill/Stroke drawing API, which applies the same device-scale transform a second time — deviceScale² total. Finding 1 goes the other way and skips the conversion entirely on one code path, sending logical dimensions where the OS compositor expects physical. Finding 2 has roughly the right idea but uses a different rounding formula than trackDamage itself, and the two happen to disagree at fractional scales. Finding 5 is a bug in the exit path of the same suppression window that makes findings 3 and 4's fix possible in the first place.
1. gogpu/gg — post-resize/first-frame damage reported to the OS compositor in logical pixels, not physical (production)
Root cause. The first-frame/post-resize branch of Canvas.forwardDamageRects (taken when prevFrameDamageRects == nil) sent:
setter.SetDamageRects([]image.Rectangle{
image.Rect(0, 0, c.width, c.height),
})
c.width/c.height are logical (user-space) dimensions. The tell is that MarkDirty and the software-upload path inside Render, both in this same file, reach for c.ctx.PixelWidth()/PixelHeight() precisely when they need the physical extent. Every other branch of forwardDamageRects forwards Context.FrameDamage(), which is already physical.
Reach. Canvas.Resize unconditionally nils prevFrameDamageRects at the end — correctly, since a resized surface does need full damage — so every resize re-enters this branch, not just the true first frame. Separately, SetDeviceScale never cleared prevFrameDamageRects, so a mid-session scale change (a window dragged to a different-scale display) could carry a rect recorded at the old scale into the next frame's 2-frame damage-ring union with new-scale geometry.
I traced the consumption chain rather than assume it was live. RenderTarget.SetDamageRects reaches gogpu.Context.SetDamageRects, whose own doc comment states the violated contract outright: rects are physical pixels, and callers must convert from logical DIP using the window's scale factor before calling it. From there the rects flow into the renderer's damageRects field (also documented as physical pixels), then PresentWithDamage, then wgpu's core.Surface, ending at queue.Present(..., damageRects). At deviceScale=2, this branch was breaking that contract on every resize — telling the compositor that only the top-left quadrant of the surface had changed.
One thing I want to flag directly: while writing this up I found gg#327, and its fixes in PR #311 and PR #332 — the same units mistake in a different function (Context.trackDamage, not forwardDamageRects), fixed in v0.46.9 and v0.47.3. forwardDamageRects doesn't call trackDamage at all; it constructs the rect directly, so that earlier fix never reached it. I checked specifically to rule out a duplicate before filing this: #322/#327/#328 (the "quarter-screen problem" split) are all about ggcanvas's coordinate handling, but none of their fixes touch this function.
Expected vs. actual. The damage rect for a resized or newly-presented surface should always be image.Rect(0, 0, PixelWidth(), PixelHeight()). It was the logical image.Rect(0, 0, width, height) instead.
Fix. Use c.ctx.PixelWidth()/PixelHeight() — the same accessors MarkDirty and the software-upload path already use for exactly this reason. SetDeviceScale now also nils prevFrameDamageRects, forcing one full-surface present at the new scale instead of letting the ring union mismatched geometry.
Open question. Whether this produces a visible artifact depends on whether the present backend actually honors a too-small damage rect for a resized surface. I've confirmed that the units are wrong and that the call chain is live through to wgpu core.Surface.PresentWithDamage; I haven't confirmed whether Metal specifically drops stale pixels as a result. Settling that would need a resize on a HiDPI display, watching the bottom-right roughly three-quarters of the surface for stale content, and I don't have that hardware run yet.
2. gogpu/ui — GPU scissor for damage-aware blit can under-cover by one physical pixel at fractional device scales (production, fractional scales only)
Root cause. desktop's trackBoundaryDamage computed the physical-pixel scissor rect like this:
int(float64(rx)*scale),
int(float64(ry)*scale),
int(float64(rx)*scale)+int(float64(bw)*scale+0.5),
int(float64(ry)*scale)+int(float64(bh)*scale+0.5),
— that is, truncating the min corner and rounding the size half-up. gg's own Context.trackDamage (the same function finding 1's contract traces back to) computes the equivalent physical rect differently: Floor on the min corner and Ceil on the max corner, independently — a formula that can produce a larger rect. Concretely, at scale 1.5 with logical origin x=11 and width 20, gg gives Floor(16.5)=16 through Ceil(46.5)=47, while the old formula gives 16 through 16+int(30.5)=46. The right edge lands one physical pixel short of what gg itself considers damaged.
Reach. This rect feeds the GPU scissor for a damage-aware blit that reuses the previous frame's swapchain content outside the scissor (LoadOpLoad). The missing column therefore keeps stale content from the previous frame — a real, if narrow, visual defect, not a debug-visualization artifact.
At integer device scales (1x, 2x, 3x), float64(n)*scale has no fractional part, so truncation and Floor agree and the two formulas produce identical results. The defect is confined to fractional device scales — Windows at 125%/150%/175%, or any other non-integer scale factor.
Fix. Match gg's rounding exactly: Floor on the min corner, Ceil on the max corner, computed independently rather than as corner-plus-size.
Honest gap. I can't reproduce this on my own hardware: my only test machine is a Retina Mac at an integer 2x device scale, where the two formulas agree by construction. So this one is headless-tested only. While researching finding 1 I did notice that gg#327's reporter hit a sibling class of this defect at 125% scaling on Linux/X11, which is at least evidence that the fractional-scale regime is a real user configuration and not a hypothetical.
3. gogpu/ui — GOGPU_DEBUG_DIRTY=1 overlay draws at deviceScale²
Root cause. desktop's dirtyOverlay.draw hand-multiplied its rect by the device scale before passing it to gg's Fill/Stroke:
x := float64(f.rect.Min.X) * scale
y := float64(f.rect.Min.Y) * scale
w := float64(f.rect.Max.X-f.rect.Min.X) * scale
h := float64(f.rect.Max.Y-f.rect.Min.Y) * scale
f.rect comes from Window.DirtyRegions(), which is already logical (widget screen bounds), and Fill/Stroke apply the device-scale transform themselves. So the rect got scaled by the device scale twice: once explicitly here, once inside Fill/Stroke.
Expected vs. actual. The cyan overlay box should land exactly over the widget whose dirty region it's flagging. At 2x it instead drew at 4x the correct offset and size; at 3x, 9x.
Fix. Drop the multiplication and pass the logical rect straight through. I also wrapped the draw call in a transform/paint save-restore (Push/Identity/Pop), so a leftover caller transform or paint state can't leak into the overlay or out of it, and added a guard against the border stroke's 1-pixel inset going negative for damage rects 1-2 pixels wide — reachable once the rect is stored logically, unreachable before, when the rect was always scaled larger. That guard came out of review before this landed, not from an observed failure.
Honest gap. This fix has not been run on real hardware. The headless test reproduces the predicted pixel geometry exactly, but I haven't watched the overlay on an actual Retina display yet.
4. gogpu/gg — GOGPU_DEBUG_DAMAGE=1 overlay draws at deviceScale²
Root cause. Same defect as finding 3, arrived at from the other direction. Context.trackDamage scales a logical damage rect to physical pixels once (Floor/Ceil by the device scale) for the OS compositor — that's the documented, correct behavior TrackDamageRect promises. But Canvas.Render's debug-overlay block passes Context.FrameDamage()'s already-physical rects straight into the overlay's update, which draws them through the same Fill/Stroke path finding 3 uses — applying the device matrix a second time.
I measured this on hardware before diagnosing it from source: with GOGPU_DEBUG_DAMAGE=1 set on a 2x Retina display, the green highlight boxes appeared over content well away from what actually redrew, at roughly double the size a correct overlay would have.

Calibrated against the known log geometry, the boxes measure out to logical coordinates equal to the physical rect trackDamage stored for that frame — the double-scale signature, established without leaning on a pixel ruler.
Fix. Same shape as finding 3: convert at the ingest boundary (a new logicalDamageRect helper inverts trackDamage's rounding), store logical, and let Fill/Stroke apply the scale once. I considered two alternatives and still think this is the right one:
- Scaling right before drawing instead of at ingest applies whichever scale is current at draw time to geometry recorded at record time — wrong the instant a flash survives across a
SetDeviceScale call (a window dragged to a different-scale display mid-fade).
- Cancelling the device matrix at draw time (
Push(); Scale(1/s, 1/s); ...; Pop()) has the same scale-flip problem, and on top of that changes what the existing 1-pixel border inset means: a physical pixel instead of a logical one, i.e. a sub-hairline border at 2x.
Converting at ingest and storing logical means the scale that's current at draw time is always the right one to apply, which stays correct across a scale change.
I also wrapped drawAll in Push/Identity/Pop. gg#328 is the precedent for this: Canvas.Draw's closure got exactly this state-reset fix, for the same reason — an accumulating transform/paint leak between frames — but this call site, Canvas.Render's debug-overlay block, never did. It carries the same border-inset guard as finding 3.
5. gogpu/gg — the overlay's suppression window restores the caller's damage-tracking state to a hardcoded value instead of remembering it (latent)
Root cause. Canvas.Render's overlay-draw block wraps the draw in a damage-tracking suppression window:
if len(c.damageFlashs.flashes) > 0 {
c.ctx.SetDamageTracking(false)
c.damageFlashs.drawAll(c.ctx)
c.ctx.SetDamageTracking(true)
}
The disable is load-bearing: without it, the overlay's own Fill/Stroke calls would register as damage, feed into the next frame's FrameDamage(), and the overlay would flash over its own previous flash indefinitely. The restore is the actual bug — it hardcodes true instead of capturing what tracking was set to before the disable.
I checked every SetDamageTracking disable/enable pair reachable from this repo — five call sites — and all of them are balanced, non-re-entrant pairs; nothing currently calls back into Canvas.Render between its own disable and its own enable. So I can't currently demonstrate this breaking a real caller. It isn't defended against either, though: damageTrackingEnabled was unexported with no getter, so no caller outside the package could have written a correct save/restore even if it needed to.
Fix. Add a Context.DamageTracking() bool getter next to the existing SetDamageTracking, and have Canvas.Render capture and restore around the window instead of hardcoding true. That's zero behavior change today — every reachable path enters with tracking already enabled, so the captured value is always true — but it converts an unenforced assumption into a structural invariant.
Verification
All five are verified headlessly, against a CPU-rasterized gg.Context (NewContextWithScale plus SetRasterizerMode(RasterizerAnalytic) for deterministic pixel readback) — no GPU, no window. The test files are in the patches below.
Each one also got a discrimination check — temporarily reinstating only the pre-fix arithmetic, never the changed function signatures, confirming the new test fails at exactly the predicted values, then reverting:
| Case |
Parameter |
Wanted (post-fix) |
Measured (pre-fix) |
| finding 3, 2x |
deviceScale=2 |
(20,20)-(60,60) |
(40,40)-(120,120) |
| finding 3, 3x |
deviceScale=3 |
(30,30)-(90,90) |
(90,90)-(270,270) |
| finding 3, 2x off-origin |
deviceScale=2 |
(100,60)-(180,100) |
(200,120)-(360,200) |
| finding 3, degenerate (1x1 logical) |
deviceScale=3 |
(30,30)-(33,33) |
(90,90)-(99,99) (inverted/offset border) |
| finding 4, 2x |
deviceScale=2 |
(20,20)-(60,60) |
(40,40)-(120,120) |
| finding 4, 3x |
deviceScale=3 |
(30,30)-(90,90) |
(90,90)-(270,270) |
| finding 4, scale change mid-fade (2x to 1x) |
deviceScale 2 to 1 |
(10,10)-(30,30) |
(20,20)-(60,60) |
| finding 4, degenerate (1x1 logical) |
deviceScale=3 |
(30,30)-(33,33) |
(90,90)-(99,99) (inverted/offset border) |
| finding 1, first-frame 2x |
deviceScale=2 |
(0,0)-(200,200) |
(0,0)-(100,100) (logical, not physical) |
| finding 1, first-frame 3x |
deviceScale=3 |
(0,0)-(300,300) |
(0,0)-(100,100) (logical, not physical) |
| finding 1, post-resize |
resize to 200x150 @2x |
(0,0)-(400,300) |
(0,0)-(200,150) (logical, not physical) |
| finding 1, scale-change stale rect |
1x to 2x mid-session |
(0,0)-(200,200) |
(0,0)-(100,100) (stale 1x carried forward) |
| finding 5, was-enabled (non-discriminating) |
tracking true before draw |
true after |
true after (passes both ways by construction) |
| finding 5, was-disabled |
tracking false before draw |
false after |
true after (clobbered) |
| finding 2, fractional scissor |
scale=1.5, origin (11,5), size 20x10 |
(16,7)-(47,23) |
(16,7)-(46,22) (one pixel short on the max corner) |
The 1x rows for findings 3 and 4 pass both before and after by construction — at scale 1 the defect is arithmetically a no-op — so they're a don't-over-correct guard, not a discriminator. Finding 5's was-enabled row is the same shape of guard.
Honest gaps. Only finding 4 has been observed on hardware (the screenshot above). Finding 1's practical severity — a visible artifact versus just wrong units reaching the compositor — is open, pending a resize test on real hardware. Finding 2 can't be reproduced on my hardware at all (integer device scale only). Finding 5 has no reproducing caller I can point to.
Applying these
Base versions: gg v0.50.8 for the two gg folds below, and this repo's main (v0.1.48) for the ui fold. Each diff is relative to its own repo root and applies cleanly to a pristine checkout at those versions — verified with git apply --check plus a build and test run against a separately-fetched copy, not just against my own tree.
One coupling to flag: the finding-1 fold's test (forward_damage_units_test.go) calls a scaleName helper defined in the finding-4/5 fold's test file (damage_debug_hidpi_test.go). To apply the finding-1 fold on its own, either bring that 8-line helper along or replace t.Run(scaleName(scale), ...) with t.Run(fmt.Sprintf("%gx", scale), ...). I split the patches by scope, production versus debug-tooling, rather than by file, and this is the one place that split isn't quite clean at the test level.
Patches
gg — finding 1: forwardDamageRects reports physical pixels
diff --git a/integration/ggcanvas/canvas.go b/integration/ggcanvas/canvas.go
index c2646b0..3dec7de 100644
--- a/integration/ggcanvas/canvas.go
+++ b/integration/ggcanvas/canvas.go
@@ -284,6 +284,13 @@ func (c *Canvas) SetDeviceScale(scale float64) {
c.ctx.SetDeviceScale(scale)
c.sizeChanged = true
c.dirty = true
+ // A rect recorded at the old scale is no longer valid physical-pixel
+ // geometry at the new one — union-ing it with next frame's damage (the
+ // forwardDamageRects 2-frame ring, :382-387) would present the wrong
+ // region. Forcing prevFrameDamageRects back to nil re-arms the
+ // full-window branch above so the first post-scale-change frame presents
+ // everything instead (finding 1 in this report).
+ c.prevFrameDamageRects = nil
}
// MarkDirty flags the canvas for GPU upload on next Flush().
@@ -359,9 +366,17 @@ func (c *Canvas) forwardDamageRects(dc RenderTarget, frameDamage []image.Rectang
// First frame or after Resize: full-window damage. The pixmap is complete
// but the window surface is uninitialized — must blit everything.
+ //
+ // c.width/c.height are LOGICAL (user-space) dimensions — SetDamageRects
+ // requires PHYSICAL pixels, same units every other branch below sends via
+ // frameDamage (Context.FrameDamage(), already device-scaled). Use
+ // PixelWidth/PixelHeight, the same accessors MarkDirty (:297) and the
+ // software-upload path (:735) use for exactly this reason. At deviceScale=2
+ // the un-scaled dims previously told the OS
+ // compositor only the top-left quadrant of the surface changed.
if c.prevFrameDamageRects == nil {
setter.SetDamageRects([]image.Rectangle{
- image.Rect(0, 0, c.width, c.height),
+ image.Rect(0, 0, c.ctx.PixelWidth(), c.ctx.PixelHeight()),
})
c.presentDamageRects = nil
// Empty (not nil) marks "initialized" — nil means "first frame".
diff --git a/integration/ggcanvas/forward_damage_units_test.go b/integration/ggcanvas/forward_damage_units_test.go
new file mode 100644
index 0000000..3459e63
--- /dev/null
+++ b/integration/ggcanvas/forward_damage_units_test.go
@@ -0,0 +1,129 @@
+package ggcanvas
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg"
+ "github.com/gogpu/gpucontext"
+)
+
+// forwardDamageRects's first-frame/post-resize branch (prevFrameDamageRects
+// == nil) sent LOGICAL c.width/c.height straight to SetDamageRects, while
+// every other branch forwards Context.FrameDamage(), which is PHYSICAL. At
+// deviceScale != 1 the OS compositor was told only the top-left fraction of
+// the surface changed -- reachable on every resize (Resize nils
+// prevFrameDamageRects to re-arm this branch) and on first present.
+//
+// Invariant pinned here: the first-frame/post-resize damage rect always
+// equals the canvas's PHYSICAL pixel extent, regardless of device scale
+// (finding 1 in this report).
+func TestForwardDamageRects_FirstFrameIsPhysical(t *testing.T) {
+ for _, scale := range []float64{1.0, 2.0, 3.0} {
+ t.Run(scaleName(scale), func(t *testing.T) {
+ c := &Canvas{
+ width: 100,
+ height: 100,
+ ctx: gg.NewContextWithScale(100, 100, scale),
+ }
+ tgt := &fakeDamageTarget{}
+
+ c.forwardDamageRects(tgt, nil) // prevFrameDamageRects == nil -> first-frame branch
+
+ want := image.Rect(0, 0, c.ctx.PixelWidth(), c.ctx.PixelHeight())
+ if len(tgt.calls) != 1 {
+ t.Fatalf("SetDamageRects called %d times, want 1", len(tgt.calls))
+ }
+ got := tgt.calls[0]
+ if len(got) != 1 || got[0] != want {
+ t.Errorf("first-frame damage rects = %v, want [%v] (physical pixel extent, "+
+ "not logical %dx%d)", got, want, c.width, c.height)
+ }
+ })
+ }
+}
+
+// Resize nils prevFrameDamageRects specifically to re-arm the first-frame
+// branch above (a resized surface needs full damage) -- confirm that path
+// also lands on physical units, not just the true first frame.
+func TestForwardDamageRects_PostResizeIsPhysical(t *testing.T) {
+ c := &Canvas{
+ width: 100,
+ height: 100,
+ ctx: gg.NewContextWithScale(100, 100, 2.0),
+ }
+ tgt := &fakeDamageTarget{}
+
+ // Simulate a steady-state frame first, so prevFrameDamageRects is non-nil
+ // going in -- otherwise this wouldn't discriminate from the true-first-
+ // frame test above.
+ c.forwardDamageRects(tgt, []image.Rectangle{{}})
+ if c.prevFrameDamageRects == nil {
+ t.Fatal("prevFrameDamageRects still nil after a steady-state frame")
+ }
+
+ if err := c.Resize(200, 150); err != nil {
+ t.Fatalf("Resize: %v", err)
+ }
+ if c.prevFrameDamageRects != nil {
+ t.Fatalf("Resize did not nil prevFrameDamageRects (test assumption invalid)")
+ }
+
+ tgt.calls = nil
+ c.forwardDamageRects(tgt, nil)
+
+ want := image.Rect(0, 0, c.ctx.PixelWidth(), c.ctx.PixelHeight()) // 400x300 @2x
+ if len(tgt.calls) != 1 || len(tgt.calls[0]) != 1 || tgt.calls[0][0] != want {
+ t.Errorf("post-resize damage rects = %v, want [%v]", tgt.calls, want)
+ }
+}
+
+// A device-scale change mid-stream must not let the next frame union a rect
+// recorded at the OLD scale with one recorded at the NEW scale -- that would
+// present the wrong region relative to the new physical surface size.
+func TestForwardDamageRects_DeviceScaleChangeDropsStaleRect(t *testing.T) {
+ c := &Canvas{
+ width: 100,
+ height: 100,
+ ctx: gg.NewContextWithScale(100, 100, 1.0),
+ }
+ tgt := &fakeDamageTarget{}
+
+ // Steady-state frame at 1x leaves a stale rect in prevFrameDamageRects.
+ staleRect := image.Rect(5, 5, 15, 15)
+ c.forwardDamageRects(tgt, []image.Rectangle{staleRect})
+ if c.prevFrameDamageRects == nil {
+ t.Fatal("prevFrameDamageRects still nil after a steady-state frame")
+ }
+
+ c.SetDeviceScale(2.0)
+ if c.prevFrameDamageRects != nil {
+ t.Fatalf("SetDeviceScale did not clear prevFrameDamageRects; a next-frame union " +
+ "would mix 1x and 2x geometry")
+ }
+
+ tgt.calls = nil
+ c.forwardDamageRects(tgt, nil)
+
+ want := image.Rect(0, 0, c.ctx.PixelWidth(), c.ctx.PixelHeight()) // 200x200 @2x
+ if len(tgt.calls) != 1 || len(tgt.calls[0]) != 1 || tgt.calls[0][0] != want {
+ t.Errorf("post-scale-change damage rects = %v, want [%v] (full physical extent, "+
+ "no stale 1x rect)", tgt.calls, want)
+ }
+}
+
+// fakeDamageTarget implements RenderTarget + DamageRectSetter, recording
+// every SetDamageRects call for assertion. The RenderTarget methods are
+// unused by forwardDamageRects and exist only to satisfy the type switch in
+// its caller signature.
+type fakeDamageTarget struct {
+ calls [][]image.Rectangle
+}
+
+func (f *fakeDamageTarget) SurfaceView() gpucontext.TextureView { return gpucontext.TextureView{} }
+func (f *fakeDamageTarget) SurfaceSize() (uint32, uint32) { return 0, 0 }
+func (f *fakeDamageTarget) PresentTexture(_ any) error { return nil }
+
+func (f *fakeDamageTarget) SetDamageRects(rects []image.Rectangle) {
+ f.calls = append(f.calls, rects)
+}
gg — findings 4+5: debug overlay double-DPI scaling + damage-tracking clobber
diff --git a/context.go b/context.go
index 83be7b3..22413ca 100644
--- a/context.go
+++ b/context.go
@@ -518,6 +518,14 @@ func (c *Context) SetDamageTracking(enabled bool) {
c.damageTrackingEnabled = enabled
}
+// DamageTracking reports whether per-operation damage recording is currently
+// enabled. Pair with SetDamageTracking to save and restore the caller's prior
+// state around a scoped suppression window, instead of assuming it was
+// previously enabled (finding 5 in this report).
+func (c *Context) DamageTracking() bool {
+ return c.damageTrackingEnabled
+}
+
// TrackDamageRect registers an external damage rectangle on the surface.
// Use this for compositor operations that modify the surface but don't use
// Fill/Stroke (e.g., DrawGPUTexture for dirty RepaintBoundary overlays).
diff --git a/integration/ggcanvas/canvas.go b/integration/ggcanvas/canvas.go
index ab4ce31..c640f78 100644
--- a/integration/ggcanvas/canvas.go
+++ b/integration/ggcanvas/canvas.go
@@ -695,11 +695,24 @@ func (c *Canvas) Render(dc RenderTarget) error {
// Draw overlay BEFORE present so it's visible on ALL backends.
// Android SurfaceFlinger pattern: flash-and-fade on dirty regions.
if isDebugDamageEnabled() {
- c.damageFlashs.update(damageRects)
+ // damageRects are PHYSICAL (Context.trackDamage scaled them for the OS
+ // compositor); the overlay draws through the logical Fill/Stroke API,
+ // so update converts them back. Passing them unconverted scales the
+ // overlay by deviceScale twice.
+ c.damageFlashs.update(damageRects, c.ctx.DeviceScale())
if len(c.damageFlashs.flashes) > 0 {
+ // Save/restore rather than assuming tracking was enabled — a
+ // caller-nested suppression window (e.g. a dirty child boundary
+ // re-recording during a parent's ReplayScene) would otherwise have
+ // its own SetDamageTracking(false) silently undone by this
+ // unconditional true (finding 5 in this report). Provably
+ // unreachable in ui today (every ui-side disable/enable pair is
+ // balanced with no re-entrant Render call between them), but
+ // nothing enforces that ordering.
+ prevDamageTracking := c.ctx.DamageTracking()
c.ctx.SetDamageTracking(false)
c.damageFlashs.drawAll(c.ctx)
- c.ctx.SetDamageTracking(true)
+ c.ctx.SetDamageTracking(prevDamageTracking)
}
}
diff --git a/integration/ggcanvas/damage_debug.go b/integration/ggcanvas/damage_debug.go
index 0bc6ac3..67a668f 100644
--- a/integration/ggcanvas/damage_debug.go
+++ b/integration/ggcanvas/damage_debug.go
@@ -2,6 +2,7 @@ package ggcanvas
import (
"image"
+ "math"
"os"
"sync"
"time"
@@ -40,11 +41,15 @@ type damageFlash struct {
}
// damageOverlayState tracks damage flashes with fade effect.
+// Flash rects are stored in LOGICAL (user-space) coordinates -- see update.
type damageOverlayState struct {
flashes []damageFlash
}
-func (s *damageOverlayState) update(rects []image.Rectangle) {
+// update refreshes the flash list from this frame's damage rects.
+// rects are PHYSICAL pixels, as returned by Context.FrameDamage; they are
+// converted to logical coordinates before being stored (see logicalDamageRect).
+func (s *damageOverlayState) update(rects []image.Rectangle, deviceScale float64) {
now := time.Now()
alive := s.flashes[:0]
for _, f := range s.flashes {
@@ -58,6 +63,16 @@ func (s *damageOverlayState) update(rects []image.Rectangle) {
if r.Empty() {
continue
}
+ // Context.trackDamage already scaled these to PHYSICAL pixels for the
+ // OS compositor (Context.TrackDamageRect's documented contract).
+ // drawAll renders them through the ordinary Fill/Stroke path, which
+ // applies deviceMatrix itself -- so a physical rect drawn there would be
+ // scaled by deviceScale a SECOND time (4x on a 2x display, 9x on 3x).
+ // Store logical coordinates instead: one scale application, at draw
+ // time, using whatever deviceScale is current then (correct even if the
+ // window moves to a different-scale display mid-fade).
+ r = logicalDamageRect(r, deviceScale)
+
// Refresh-or-create: if an active flash already covers the same rect,
// refresh its timestamp instead of creating a new one. This prevents
// feedback loops (TrackDamageRect same rect every frame) while keeping
@@ -78,9 +93,35 @@ func (s *damageOverlayState) update(rects []image.Rectangle) {
}
}
+// logicalDamageRect converts a physical-pixel damage rect (as stored by
+// Context.trackDamage and returned by Context.FrameDamage) back to logical
+// user-space coordinates. Floor/Ceil mirrors trackDamage's own conservative
+// rounding, so the result never under-covers the damaged region.
+//
+// Exact at integer device scales (2x, 3x). At fractional scales the result may
+// be outset by up to one logical pixel -- deliberate, in the safe direction.
+func logicalDamageRect(r image.Rectangle, deviceScale float64) image.Rectangle {
+ if deviceScale <= 0 || deviceScale == 1 {
+ return r
+ }
+ return image.Rect(
+ int(math.Floor(float64(r.Min.X)/deviceScale)),
+ int(math.Floor(float64(r.Min.Y)/deviceScale)),
+ int(math.Ceil(float64(r.Max.X)/deviceScale)),
+ int(math.Ceil(float64(r.Max.Y)/deviceScale)),
+ )
+}
+
// drawAll renders green flash-and-fade overlay via gg.Context.
+// Flash rects are LOGICAL (see update); Fill/Stroke apply the device scale.
// Works on all backends. Caller must SetDamageTracking(false) before calling.
func (s *damageOverlayState) drawAll(cc *gg.Context) {
+ // Overlay rects are surface-space: ignore any user transform left by the
+ // caller, and do not leak our color/line width into the caller's paint.
+ cc.Push()
+ cc.Identity()
+ defer cc.Pop()
+
now := time.Now()
for _, f := range s.flashes {
age := now.Sub(f.time)
@@ -102,11 +143,17 @@ func (s *damageOverlayState) drawAll(cc *gg.Context) {
cc.DrawRectangle(x, y, w, h)
_ = cc.Fill()
- // Green border with fade.
- cc.SetRGBA(0, 0.9, 0, 0.7*fade)
- cc.SetLineWidth(2)
- cc.DrawRectangle(x+1, y+1, w-2, h-2)
- _ = cc.Stroke()
+ // Border is inset 1px from the fill on each side. At logical scale a
+ // damage rect as small as 1-2px wide is ordinary -- guard against the
+ // inset going negative, which would draw an inverted, offset border
+ // box (a new instance of exactly the defect this fix removes).
+ if w > 2 && h > 2 {
+ // Green border with fade.
+ cc.SetRGBA(0, 0.9, 0, 0.7*fade)
+ cc.SetLineWidth(2)
+ cc.DrawRectangle(x+1, y+1, w-2, h-2)
+ _ = cc.Stroke()
+ }
}
}
diff --git a/integration/ggcanvas/damage_debug_hidpi_test.go b/integration/ggcanvas/damage_debug_hidpi_test.go
new file mode 100644
index 0000000..fbad600
--- /dev/null
+++ b/integration/ggcanvas/damage_debug_hidpi_test.go
@@ -0,0 +1,179 @@
+package ggcanvas
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg"
+)
+
+// The damage debug overlay (GOGPU_DEBUG_DAMAGE=1) is fed Context.FrameDamage
+// rects, which are PHYSICAL pixels. drawAll renders through Fill/Stroke, which
+// apply the device matrix themselves. Feeding physical rects straight through
+// therefore scaled the overlay by deviceScale TWICE (4x on a 2x display).
+//
+// Invariant pinned here: the overlay's drawn pixel bounding box equals the
+// physical damage rect it was given -- exactly one deviceScale application.
+func TestDamageOverlay_DrawnBBoxEqualsPhysicalDamage(t *testing.T) {
+ for _, scale := range []float64{1.0, 2.0, 3.0} {
+ t.Run(scaleName(scale), func(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, scale)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic) // deterministic CPU path
+
+ // Logical damage, exactly as a retained-mode caller registers it.
+ cc.TrackDamageRect(image.Rect(10, 10, 30, 30))
+ physical := cc.FrameDamage()
+ if len(physical) != 1 {
+ t.Fatalf("FrameDamage() = %v, want 1 rect", physical)
+ }
+ want := physical[0] // (10,10,30,30) * scale, by trackDamage's contract
+
+ var s damageOverlayState
+ s.update(physical, cc.DeviceScale())
+ cc.SetDamageTracking(false)
+ s.drawAll(cc)
+
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("Image() is %T, want *image.RGBA", cc.Image())
+ }
+ got := alphaBBoxTest(img, 16)
+ if !rectWithinTest(got, want, 1) {
+ t.Errorf("overlay bbox = %v, want %v (== the physical damage rect, +/-1px)",
+ got, want)
+ }
+ })
+ }
+}
+
+// A flash recorded at one device scale must follow the window to a display with
+// a different scale: flashes are stored logically, so the CURRENT scale applies.
+func TestDamageOverlay_SurvivesDeviceScaleChangeMidFade(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, 2.0)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+ cc.TrackDamageRect(image.Rect(10, 10, 30, 30))
+
+ var s damageOverlayState
+ s.update(cc.FrameDamage(), cc.DeviceScale()) // recorded at 2x
+
+ cc.SetDeviceScale(1.0) // window dragged to a non-Retina display
+ cc.SetDamageTracking(false)
+ s.drawAll(cc)
+
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("Image() is %T, want *image.RGBA", cc.Image())
+ }
+ want := image.Rect(10, 10, 30, 30) // logical == physical at 1x
+ if got := alphaBBoxTest(img, 16); !rectWithinTest(got, want, 1) {
+ t.Errorf("bbox after 2x->1x flip = %v, want %v", got, want)
+ }
+}
+
+// TestDamageOverlay_DegenerateRectNoInvertedBorder pins the w>2&&h>2 border
+// guard: at logical scale a damage rect as small as 1-2px wide is ordinary.
+// Before the guard, the border's 1px inset (w-2/h-2) went negative for such
+// rects, drawing an inverted box offset from the fill -- a new instance of
+// the defect this fix exists to remove.
+func TestDamageOverlay_DegenerateRectNoInvertedBorder(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, 3.0)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+
+ // Logical 1x1 damage rect -> physical 3x3 fill at (30,30)-(33,33).
+ cc.TrackDamageRect(image.Rect(10, 10, 11, 11))
+ physical := cc.FrameDamage()
+
+ var s damageOverlayState
+ s.update(physical, cc.DeviceScale())
+ cc.SetDamageTracking(false)
+ s.drawAll(cc)
+
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("Image() is %T, want *image.RGBA", cc.Image())
+ }
+ got := alphaBBoxTest(img, 16)
+ want := image.Rect(30, 30, 33, 33) // fill only -- border guard suppresses the stroke
+ if !rectWithinTest(got, want, 1) {
+ t.Errorf("overlay bbox = %v, want %v (+/-1px) -- a bbox extending outside "+
+ "or offset from the fill means the border's w-2/h-2 inset went negative",
+ got, want)
+ }
+}
+
+// TestCanvasRender_RestoresPriorDamageTrackingState pins that the overlay's
+// suppression window (Canvas.Render) restores whatever DamageTracking state
+// the caller had before drawing, rather than assuming it was enabled. A
+// caller that had already disabled tracking for its own reasons (e.g. a
+// nested boundary re-recording) must find it still disabled afterward
+// (finding 5 in this report). Provably unreachable via any current ui call
+// site -- this pins the invariant directly, at the Context level, so the
+// guarantee doesn't rely on caller-ordering discipline holding forever.
+func TestCanvasRender_RestoresPriorDamageTrackingState(t *testing.T) {
+ for _, prior := range []bool{true, false} {
+ t.Run(map[bool]string{true: "was-enabled", false: "was-disabled"}[prior], func(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, 2.0)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+ cc.TrackDamageRect(image.Rect(10, 10, 30, 30))
+
+ var s damageOverlayState
+ s.update(cc.FrameDamage(), cc.DeviceScale())
+
+ cc.SetDamageTracking(prior)
+ // Mirrors Canvas.Render's suppression window exactly (canvas.go).
+ prevDamageTracking := cc.DamageTracking()
+ cc.SetDamageTracking(false)
+ s.drawAll(cc)
+ cc.SetDamageTracking(prevDamageTracking)
+
+ if got := cc.DamageTracking(); got != prior {
+ t.Errorf("DamageTracking() after overlay draw = %v, want %v (the caller's "+
+ "prior state, not an unconditional true)", got, prior)
+ }
+ })
+ }
+}
+
+func scaleName(s float64) string {
+ switch s {
+ case 1.0:
+ return "1x"
+ case 2.0:
+ return "2x"
+ default:
+ return "3x"
+ }
+}
+
+// alphaBBoxTest returns the bounding box of all pixels whose alpha is >=
+// minAlpha. Returns the zero rectangle when nothing was drawn.
+func alphaBBoxTest(img *image.RGBA, minAlpha uint8) image.Rectangle {
+ b := img.Bounds()
+ var out image.Rectangle
+ first := true
+ for y := b.Min.Y; y < b.Max.Y; y++ {
+ for x := b.Min.X; x < b.Max.X; x++ {
+ if img.RGBAAt(x, y).A < minAlpha {
+ continue
+ }
+ p := image.Rect(x, y, x+1, y+1)
+ if first {
+ out, first = p, false
+ } else {
+ out = out.Union(p)
+ }
+ }
+ }
+ return out
+}
+
+func rectWithinTest(got, want image.Rectangle, tol int) bool {
+ d := func(a, b int) int {
+ if a > b {
+ return a - b
+ }
+ return b - a
+ }
+ return d(got.Min.X, want.Min.X) <= tol && d(got.Min.Y, want.Min.Y) <= tol &&
+ d(got.Max.X, want.Max.X) <= tol && d(got.Max.Y, want.Max.Y) <= tol
+}
diff --git a/integration/ggcanvas/damage_debug_test.go b/integration/ggcanvas/damage_debug_test.go
index b4af741..e827290 100644
--- a/integration/ggcanvas/damage_debug_test.go
+++ b/integration/ggcanvas/damage_debug_test.go
@@ -10,7 +10,7 @@ func TestDamageOverlay_RefreshSameRect(t *testing.T) {
var s damageOverlayState
r := image.Rect(170, 410, 218, 458)
- s.update([]image.Rectangle{r})
+ s.update([]image.Rectangle{r}, 1.0)
if len(s.flashes) != 1 {
t.Fatalf("first update: want 1 flash, got %d", len(s.flashes))
@@ -19,7 +19,7 @@ func TestDamageOverlay_RefreshSameRect(t *testing.T) {
// Same rect again — should refresh time, NOT create new flash.
time.Sleep(time.Millisecond)
- s.update([]image.Rectangle{r})
+ s.update([]image.Rectangle{r}, 1.0)
if len(s.flashes) != 1 {
t.Errorf("second update same rect: want 1 flash (refreshed), got %d", len(s.flashes))
@@ -35,8 +35,8 @@ func TestDamageOverlay_DifferentRectsNotDeduped(t *testing.T) {
r1 := image.Rect(10, 10, 50, 50)
r2 := image.Rect(100, 100, 200, 200)
- s.update([]image.Rectangle{r1})
- s.update([]image.Rectangle{r2})
+ s.update([]image.Rectangle{r1}, 1.0)
+ s.update([]image.Rectangle{r2}, 1.0)
if len(s.flashes) != 2 {
t.Errorf("different rects: want 2 flashes, got %d", len(s.flashes))
@@ -47,13 +47,13 @@ func TestDamageOverlay_ExpiredFlashAllowsNewForSameRect(t *testing.T) {
var s damageOverlayState
r := image.Rect(10, 10, 50, 50)
- s.update([]image.Rectangle{r})
+ s.update([]image.Rectangle{r}, 1.0)
// Simulate flash expiry by backdating.
s.flashes[0].time = time.Now().Add(-damageFlashDuration - time.Millisecond)
// Update again — expired flash pruned, same rect should create new flash.
- s.update([]image.Rectangle{r})
+ s.update([]image.Rectangle{r}, 1.0)
if len(s.flashes) != 1 {
t.Errorf("after expiry: want 1 new flash, got %d", len(s.flashes))
@@ -68,7 +68,7 @@ func TestDamageOverlay_ExpiredFlashAllowsNewForSameRect(t *testing.T) {
func TestDamageOverlay_NeedsAnimationFrameFalseAfterExpiry(t *testing.T) {
var s damageOverlayState
- s.update([]image.Rectangle{image.Rect(10, 10, 50, 50)})
+ s.update([]image.Rectangle{image.Rect(10, 10, 50, 50)}, 1.0)
if !s.needsAnimationFrame() {
t.Error("should need frame during active flash")
@@ -92,14 +92,14 @@ func TestDamageOverlay_FeedbackLoopBroken(t *testing.T) {
spinner := image.Rect(170, 410, 218, 458)
// Frame 1
- s.update([]image.Rectangle{spinner})
+ s.update([]image.Rectangle{spinner}, 1.0)
if len(s.flashes) != 1 {
t.Fatalf("frame 1: want 1 flash, got %d", len(s.flashes))
}
// Frames 2-10: same spinner rect every frame (TrackDamageRect from compositor)
for i := 2; i <= 10; i++ {
- s.update([]image.Rectangle{spinner})
+ s.update([]image.Rectangle{spinner}, 1.0)
}
// Still only 1 flash (refreshed, not duplicated)
@@ -127,7 +127,7 @@ func TestDamageOverlay_EmptyRectsIgnored(t *testing.T) {
s.update([]image.Rectangle{
{},
image.Rect(5, 5, 5, 5),
- })
+ }, 1.0)
if len(s.flashes) != 0 {
t.Errorf("empty rects should be ignored, got %d flashes", len(s.flashes))
ui — findings 2+3: cyan overlay double-DPI scaling + scissor rounding
diff --git a/desktop/desktop.go b/desktop/desktop.go
--- a/desktop/desktop.go
+++ b/desktop/desktop.go
@@ -406,7 +406,7 @@
if isDebugDirtyEnabled() {
rl.debugOverlay.update(win.DirtyRegions())
cc.SetDamageTracking(false)
- rl.debugOverlay.draw(cc, rl.canvas.DeviceScale())
+ rl.debugOverlay.draw(cc)
cc.SetDamageTracking(true)
if rl.debugOverlay.needsAnimationFrame() {
if isDebugDamageEnabled() {
@@ -757,13 +757,21 @@
rl.boundaryDamageLogical = append(rl.boundaryDamageLogical, image.Rect(
rx, ry, rx+bw, ry+bh,
))
- // Physical coords for GPU scissor.
+ // Physical coords for GPU scissor. Match gg's own trackDamage rounding
+ // (Floor on the min corner, Ceil on the max corner) exactly — truncating
+ // the min corner while round-half-up-ing the *size* is a different
+ // function, and at fractional device scales it can under-cover: e.g.
+ // scale=1.5, rx=11, bw=20 gives Floor/Ceil [16,47) but truncate+rounded-
+ // size gives [16,46) — the scissor's right edge lands one physical pixel
+ // short of gg's own damage rect, leaving a stale LoadOpLoad seam. Integer
+ // scales are unaffected — both methods
+ // agree when scale is a whole number.
scale := float64(rl.canvas.DeviceScale())
rl.frameDamageRects = append(rl.frameDamageRects, image.Rect(
- int(float64(rx)*scale),
- int(float64(ry)*scale),
- int(float64(rx)*scale)+int(float64(bw)*scale+0.5),
- int(float64(ry)*scale)+int(float64(bh)*scale+0.5),
+ int(math.Floor(float64(rx)*scale)),
+ int(math.Floor(float64(ry)*scale)),
+ int(math.Ceil(float64(rx+bw)*scale)),
+ int(math.Ceil(float64(ry+bh)*scale)),
))
}
diff --git a/desktop/debug_dirty.go b/desktop/debug_dirty.go
--- a/desktop/debug_dirty.go
+++ b/desktop/debug_dirty.go
@@ -56,7 +56,21 @@
}
}
-func (o *dirtyOverlay) draw(cc *gg.Context, scale float64) {
+// draw renders the cyan flash-and-fade overlay.
+//
+// f.rect is LOGICAL (user-space): it originates from Window.DirtyRegions ->
+// WidgetBase.ScreenBounds, i.e. layout coordinates. gg's Fill/Stroke
+// apply the device-scale matrix themselves (Context.deviceSpacePath), so the
+// rect must NOT be pre-multiplied by DeviceScale here -- doing so scales the
+// overlay by deviceScale twice (4x on a 2x display). Same units the sibling
+// SetPresentDamage call site uses.
+func (o *dirtyOverlay) draw(cc *gg.Context) {
+ // Overlay rects are screen-space: ignore any leftover user transform, and
+ // do not leak our color/line width into the caller's paint state.
+ cc.Push()
+ cc.Identity()
+ defer cc.Pop()
+
now := time.Now()
for _, f := range o.flashes {
age := now.Sub(f.time)
@@ -65,10 +79,10 @@
}
fade := 1.0 - float64(age)/float64(dirtyFlashDuration)
- x := float64(f.rect.Min.X) * scale
- y := float64(f.rect.Min.Y) * scale
- w := float64(f.rect.Max.X-f.rect.Min.X) * scale
- h := float64(f.rect.Max.Y-f.rect.Min.Y) * scale
+ x := float64(f.rect.Min.X)
+ y := float64(f.rect.Min.Y)
+ w := float64(f.rect.Max.X - f.rect.Min.X)
+ h := float64(f.rect.Max.Y - f.rect.Min.Y)
if w <= 0 || h <= 0 {
continue
}
@@ -77,10 +91,16 @@
cc.DrawRectangle(x, y, w, h)
_ = cc.Fill()
- cc.SetRGBA(0, 0.7, 0.9, 0.7*fade)
- cc.SetLineWidth(2)
- cc.DrawRectangle(x+1, y+1, w-2, h-2)
- _ = cc.Stroke()
+ // Border is inset 1px from the fill on each side. At logical scale a
+ // dirty region as small as 1-2px wide is ordinary (a caret, a thin
+ // divider) -- guard against the inset going negative, which would
+ // draw an inverted, offset border box (caught in review before landing).
+ if w > 2 && h > 2 {
+ cc.SetRGBA(0, 0.7, 0.9, 0.7*fade)
+ cc.SetLineWidth(2)
+ cc.DrawRectangle(x+1, y+1, w-2, h-2)
+ _ = cc.Stroke()
+ }
}
}
diff --git a/desktop/damage_scissor_units_test.go b/desktop/damage_scissor_units_test.go
new file mode 100644
--- /dev/null
+++ b/desktop/damage_scissor_units_test.go
@@ -0,0 +1,60 @@
+package desktop
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg/integration/ggcanvas"
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/geometry"
+)
+
+// TestTrackBoundaryDamage_ScissorMatchesFloorCeilAtFractionalScale pins that
+// trackBoundaryDamage's GPU-scissor rect (frameDamageRects) rounds identically
+// to gg's own Context.trackDamage — Floor on the min corner, Ceil on the max
+// corner — rather than truncating the min corner and round-half-up-ing the
+// SIZE, which is a different function and can under-cover by one physical
+// pixel at fractional device scales. Under
+// LoadOpLoad that missing pixel keeps the previous frame's content, a real
+// stale seam — not merely a debug-visualization glitch, since this feeds the
+// GPU scissor, not the debug overlay.
+//
+// Chosen numbers discriminate the two formulas: logical origin (11,5), size
+// 20x10, scale 1.5.
+//
+// Floor/Ceil (gg, correct): [Floor(16.5),Floor(7.5)]..[Ceil(46.5),Ceil(22.5)] = (16,7)-(47,23)
+// truncate+round-half-up-size (pre-fix): (16,7)-(16+int(30.5),7+int(15.5)) = (16,7)-(46,22)
+//
+// Integer scales are unaffected — both formulas agree when scale is whole,
+// which is why the Retina (2x) hardware session that found the debug-overlay
+// findings in this report never surfaced this.
+func TestTrackBoundaryDamage_ScissorMatchesFloorCeilAtFractionalScale(t *testing.T) {
+ canvas, err := ggcanvas.NewWithScale(fakeDeviceProvider{}, 800, 600, 1.5)
+ if err != nil {
+ t.Fatalf("ggcanvas.NewWithScale: %v", err)
+ }
+ t.Cleanup(func() { _ = canvas.Close() })
+
+ rl := &renderLoop{
+ canvas: canvas,
+ frameDamageRects: make([]image.Rectangle, 0),
+ boundaryDamageLogical: make([]image.Rectangle, 0),
+ }
+
+ pic := compositor.NewPictureLayer()
+ pic.SetRoot(false)
+ pic.SetBoundaryCacheKey(7)
+ pic.SetSize(20, 10)
+ pic.SetScreenOrigin(geometry.Pt(11, 5))
+
+ rl.trackBoundaryDamage(pic, 20, 10)
+
+ if len(rl.frameDamageRects) != 1 {
+ t.Fatalf("frameDamageRects count = %d, want 1", len(rl.frameDamageRects))
+ }
+ want := image.Rect(16, 7, 47, 23)
+ if got := rl.frameDamageRects[0]; got != want {
+ t.Errorf("scissor rect = %v, want %v (gg's own Floor/Ceil rounding; a Max.X/Max.Y "+
+ "one pixel short means the truncate+round-half-up-size formula is back)", got, want)
+ }
+}
diff --git a/desktop/damage_units_contract_test.go b/desktop/damage_units_contract_test.go
new file mode 100644
--- /dev/null
+++ b/desktop/damage_units_contract_test.go
@@ -0,0 +1,41 @@
+package desktop
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg"
+)
+
+// Contract guard: pins that gg's TrackDamageRect/FrameDamage really is
+//
+// The patched ggcanvas damage overlay divides FrameDamage() rects by
+// DeviceScale before drawing, because TrackDamageRect's documented contract is
+// "logical in, physical out". If a future gg release changes that contract
+// (e.g. makes FrameDamage logical), the patch would unscale a second time and
+// the overlay would be WRONG IN THE OPPOSITE DIRECTION -- silently, since the
+// vendored test that pins the overlay itself would have been re-applied
+// unchanged by apply-vendor-patches.sh. This test uses only exported gg API
+// and lives outside vendor/, so it survives `go mod vendor`.
+func TestGGFrameDamageIsPhysical(t *testing.T) {
+ tests := []struct {
+ scale float64
+ want image.Rectangle
+ }{
+ {1.0, image.Rect(10, 10, 30, 30)},
+ {2.0, image.Rect(20, 20, 60, 60)},
+ {3.0, image.Rect(30, 30, 90, 90)},
+ {1.5, image.Rect(15, 15, 45, 45)},
+ }
+ for _, tt := range tests {
+ cc := gg.NewContextWithScale(100, 100, tt.scale)
+ cc.TrackDamageRect(image.Rect(10, 10, 30, 30))
+ got := cc.FrameDamage()
+ if len(got) != 1 || got[0] != tt.want {
+ t.Errorf("scale %v: FrameDamage() = %v, want [%v] -- "+
+ "gg's logical-in/physical-out damage contract changed; "+
+ "re-check vendor patch 0008 before bumping gg",
+ tt.scale, got, tt.want)
+ }
+ }
+}
diff --git a/desktop/debug_dirty_test.go b/desktop/debug_dirty_test.go
new file mode 100644
--- /dev/null
+++ b/desktop/debug_dirty_test.go
@@ -0,0 +1,159 @@
+package desktop
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg"
+ "github.com/gogpu/ui/geometry"
+)
+
+// dirtyOverlay.draw must NOT pre-multiply its rect by
+// DeviceScale. Window.DirtyRegions returns LOGICAL (layout-space) rects, and
+// gg's Fill/Stroke apply the device matrix themselves (Context.deviceSpacePath).
+// Pre-multiplying scaled the overlay by deviceScale TWICE -- 4x on a 2x display.
+//
+// The overlay is env-gated in production (GOGPU_DEBUG_DIRTY=1, sync.Once), so
+// this drives update/draw directly and bypasses the gate entirely.
+func TestDirtyOverlay_DrawsAtOneDeviceScale(t *testing.T) {
+ tests := []struct {
+ name string
+ logical int // logical canvas size (square)
+ scale float64
+ region geometry.Rect
+ want image.Rectangle // expected drawn-pixel bbox, PHYSICAL
+ }{
+ // 1x: physical == logical. Non-discriminating (the bug is invisible at
+ // scale 1 by construction) but guards against over-correcting.
+ {"1x identity", 100, 1.0,
+ geometry.NewRect(10, 10, 20, 20), image.Rect(10, 10, 30, 30)},
+ // 2x: pre-fix this drew at (40,40)-(120,120).
+ {"2x retina", 100, 2.0,
+ geometry.NewRect(10, 10, 20, 20), image.Rect(20, 20, 60, 60)},
+ // 3x: pre-fix this drew at (90,90)-(270,270).
+ {"3x", 100, 3.0,
+ geometry.NewRect(10, 10, 20, 20), image.Rect(30, 30, 90, 90)},
+ // Off-origin, to catch a translation-only regression.
+ {"2x off-origin", 200, 2.0,
+ geometry.NewRect(50, 30, 40, 20), image.Rect(100, 60, 180, 100)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cc := gg.NewContextWithScale(tt.logical, tt.logical, tt.scale)
+ // The desktop test binary registers gg's GPU accelerator
+ // (ghost_repro_spike_test.go blank-imports gg/gpu), so pin the CPU
+ // analytic rasterizer for deterministic, readable-back pixels.
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+
+ var o dirtyOverlay
+ o.update([]geometry.Rect{tt.region})
+ if len(o.flashes) != 1 {
+ t.Fatalf("update produced %d flashes, want 1", len(o.flashes))
+ }
+ o.draw(cc)
+
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("cc.Image() is %T, want *image.RGBA", cc.Image())
+ }
+ got := alphaBBox(img, 16)
+ if !rectWithin(got, tt.want, 1) {
+ t.Errorf("drawn bbox = %v, want %v (+/-1px)\n"+
+ "a bbox at ~%dx the expected offset/size means the rect was "+
+ "scaled by deviceScale twice",
+ got, tt.want, int(tt.scale))
+ }
+ })
+ }
+}
+
+// TestDirtyOverlay_DoesNotLeakPaintState pins the Push/Identity/Pop wrapper:
+// the overlay must not leave its color or 2px line width on the caller's
+// context for the next frame.
+func TestDirtyOverlay_DoesNotLeakPaintState(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, 2.0)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+ cc.SetRGBA(1, 0, 0, 1)
+ cc.SetLineWidth(7)
+
+ var o dirtyOverlay
+ o.update([]geometry.Rect{geometry.NewRect(10, 10, 20, 20)})
+ o.draw(cc)
+
+ // Stroke a rect well clear of the overlay and check it came out red, not
+ // cyan -- i.e. the caller's paint survived.
+ cc.DrawRectangle(70, 70, 20, 20)
+ if err := cc.Stroke(); err != nil {
+ t.Fatalf("Stroke: %v", err)
+ }
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("cc.Image() is %T, want *image.RGBA", cc.Image())
+ }
+ px := img.RGBAAt(140, 150) // on the left edge of the stroked rect, physical
+ if px.R < 128 || px.B > 64 {
+ t.Errorf("post-overlay stroke color = %+v, want red-dominant "+
+ "(overlay leaked SetRGBA into the caller's paint)", px)
+ }
+}
+
+// TestDirtyOverlay_DegenerateRectNoInvertedBorder pins the w>2&&h>2 border
+// guard (caught in review before landing): at logical scale a dirty region as small
+// as 1-2px wide is ordinary. Before the guard, the border's 1px inset
+// (w-2/h-2) went negative for such rects, drawing an inverted box offset from
+// the fill -- a new instance of the defect this fix exists to remove.
+func TestDirtyOverlay_DegenerateRectNoInvertedBorder(t *testing.T) {
+ cc := gg.NewContextWithScale(100, 100, 3.0)
+ cc.SetRasterizerMode(gg.RasterizerAnalytic)
+
+ var o dirtyOverlay
+ // Logical 1x1 rect -> physical 3x3 fill at (30,30)-(33,33).
+ o.update([]geometry.Rect{geometry.NewRect(10, 10, 1, 1)})
+ o.draw(cc)
+
+ img, ok := cc.Image().(*image.RGBA)
+ if !ok {
+ t.Fatalf("cc.Image() is %T, want *image.RGBA", cc.Image())
+ }
+ got := alphaBBox(img, 16)
+ want := image.Rect(30, 30, 33, 33) // fill only -- border guard suppresses the stroke
+ if !rectWithin(got, want, 1) {
+ t.Errorf("drawn bbox = %v, want %v (+/-1px) -- a bbox extending outside "+
+ "or offset from the fill means the border's w-2/h-2 inset went "+
+ "negative", got, want)
+ }
+}
+
+// alphaBBox returns the bounding box of all pixels whose alpha is >= minAlpha.
+// Returns the zero rectangle when nothing was drawn.
+func alphaBBox(img *image.RGBA, minAlpha uint8) image.Rectangle {
+ b := img.Bounds()
+ var out image.Rectangle
+ first := true
+ for y := b.Min.Y; y < b.Max.Y; y++ {
+ for x := b.Min.X; x < b.Max.X; x++ {
+ if img.RGBAAt(x, y).A < minAlpha {
+ continue
+ }
+ p := image.Rect(x, y, x+1, y+1)
+ if first {
+ out, first = p, false
+ } else {
+ out = out.Union(p)
+ }
+ }
+ }
+ return out
+}
+
+func rectWithin(got, want image.Rectangle, tol int) bool {
+ d := func(a, b int) int {
+ if a > b {
+ return a - b
+ }
+ return b - a
+ }
+ return d(got.Min.X, want.Min.X) <= tol && d(got.Min.Y, want.Min.Y) <= tol &&
+ d(got.Max.X, want.Max.X) <= tol && d(got.Max.Y, want.Max.Y) <= tol
+}
Resolution checklist
Environment
gg v0.50.8; this repo's main (v0.1.48)
- Go 1.26.x,
CGO_ENABLED=0
- macOS, Intel, Retina (2x) — the source of the one hardware observation I have (finding 4's screenshot); everything else is headless-verified only
- The mechanism is backend-independent — I traced it through
Context's own transform math and gg/gogpu's documented contracts, not through a specific renderer. Fractional-scale environments (Windows 125-175%, Linux/X11 125%) weren't available to me to test directly.
Related: #172 — finding 1's SetDeviceScale half is the ggcanvas-side sibling of that report (a scale change can currently leave a stale, wrong-scale rect in the present path). gg#327 (fixed by PR #311 and PR #332, shipped v0.46.9/v0.47.3) is the same logical-vs-physical mistake in a different function; finding 1 is the call site that fix didn't reach. gg#328's Draw-closure state reset is the precedent for finding 4's transform/paint wrapper. The damage-ring work in #177 and #178, shipped in v0.1.48, is adjacent to findings 1 and 2 but independent — nothing here reverts or depends on it.
Part of the fix registry: #170
Summary
While re-verifying three earlier fixes on Retina hardware, I noticed that the
GOGPU_DEBUG_DAMAGE=1debug overlay — a "flash the pixels that just redrew" visualization, in the spirit of Chrome's paint flashing — was drawing its highlight boxes in the wrong place. Chasing that down turned into a logical-vs-physical-pixel units audit of the whole damage-tracking-to-present path, across both this repo andgogpu/gg(theggcanvasintegration this repo's renderer is built on). The audit turned up five distinct defects, all variations on the same underlying confusion, and two of them affect real compositing rather than just debug tooling.The five findings at a glance
ggcanvas.Canvas.forwardDamageRectsdesktop'strackBoundaryDamagedesktop'sdirtyOverlay.drawGOGPU_DEBUG_DIRTY=1overlay drawn atdeviceScale²ggcanvas'sdamageOverlayStateGOGPU_DEBUG_DAMAGE=1overlay drawn atdeviceScale²ggcanvas.Canvas.RenderThe common root
All five trace back to the same contract, stated in
gg.Context.TrackDamageRect's own doc comment: bounds are logical (user-space) coordinates, and the context scales them to physical pixels internally for the OS compositor.Context.trackDamagehonors that contract — it floors the min corner and ceils the max corner by the device scale before storing the rect. The obligation that follows is that everything downstream consuming that already-physical data has to know it's physical, and each finding gets that wrong in its own way. Findings 3 and 4 hand the physical rect to gg's ordinaryFill/Strokedrawing API, which applies the same device-scale transform a second time —deviceScale²total. Finding 1 goes the other way and skips the conversion entirely on one code path, sending logical dimensions where the OS compositor expects physical. Finding 2 has roughly the right idea but uses a different rounding formula thantrackDamageitself, and the two happen to disagree at fractional scales. Finding 5 is a bug in the exit path of the same suppression window that makes findings 3 and 4's fix possible in the first place.1. gogpu/gg — post-resize/first-frame damage reported to the OS compositor in logical pixels, not physical (production)
Root cause. The first-frame/post-resize branch of
Canvas.forwardDamageRects(taken whenprevFrameDamageRects == nil) sent:c.width/c.heightare logical (user-space) dimensions. The tell is thatMarkDirtyand the software-upload path insideRender, both in this same file, reach forc.ctx.PixelWidth()/PixelHeight()precisely when they need the physical extent. Every other branch offorwardDamageRectsforwardsContext.FrameDamage(), which is already physical.Reach.
Canvas.Resizeunconditionally nilsprevFrameDamageRectsat the end — correctly, since a resized surface does need full damage — so every resize re-enters this branch, not just the true first frame. Separately,SetDeviceScalenever clearedprevFrameDamageRects, so a mid-session scale change (a window dragged to a different-scale display) could carry a rect recorded at the old scale into the next frame's 2-frame damage-ring union with new-scale geometry.I traced the consumption chain rather than assume it was live.
RenderTarget.SetDamageRectsreachesgogpu.Context.SetDamageRects, whose own doc comment states the violated contract outright: rects are physical pixels, and callers must convert from logical DIP using the window's scale factor before calling it. From there the rects flow into the renderer'sdamageRectsfield (also documented as physical pixels), thenPresentWithDamage, thenwgpu'score.Surface, ending atqueue.Present(..., damageRects). AtdeviceScale=2, this branch was breaking that contract on every resize — telling the compositor that only the top-left quadrant of the surface had changed.One thing I want to flag directly: while writing this up I found gg#327, and its fixes in PR #311 and PR #332 — the same units mistake in a different function (
Context.trackDamage, notforwardDamageRects), fixed in v0.46.9 and v0.47.3.forwardDamageRectsdoesn't calltrackDamageat all; it constructs the rect directly, so that earlier fix never reached it. I checked specifically to rule out a duplicate before filing this: #322/#327/#328 (the "quarter-screen problem" split) are all aboutggcanvas's coordinate handling, but none of their fixes touch this function.Expected vs. actual. The damage rect for a resized or newly-presented surface should always be
image.Rect(0, 0, PixelWidth(), PixelHeight()). It was the logicalimage.Rect(0, 0, width, height)instead.Fix. Use
c.ctx.PixelWidth()/PixelHeight()— the same accessorsMarkDirtyand the software-upload path already use for exactly this reason.SetDeviceScalenow also nilsprevFrameDamageRects, forcing one full-surface present at the new scale instead of letting the ring union mismatched geometry.Open question. Whether this produces a visible artifact depends on whether the present backend actually honors a too-small damage rect for a resized surface. I've confirmed that the units are wrong and that the call chain is live through to
wgpu core.Surface.PresentWithDamage; I haven't confirmed whether Metal specifically drops stale pixels as a result. Settling that would need a resize on a HiDPI display, watching the bottom-right roughly three-quarters of the surface for stale content, and I don't have that hardware run yet.2. gogpu/ui — GPU scissor for damage-aware blit can under-cover by one physical pixel at fractional device scales (production, fractional scales only)
Root cause.
desktop'strackBoundaryDamagecomputed the physical-pixel scissor rect like this:— that is, truncating the min corner and rounding the size half-up.
gg's ownContext.trackDamage(the same function finding 1's contract traces back to) computes the equivalent physical rect differently:Flooron the min corner andCeilon the max corner, independently — a formula that can produce a larger rect. Concretely, at scale1.5with logical originx=11and width20,gggivesFloor(16.5)=16throughCeil(46.5)=47, while the old formula gives16through16+int(30.5)=46. The right edge lands one physical pixel short of whatggitself considers damaged.Reach. This rect feeds the GPU scissor for a damage-aware blit that reuses the previous frame's swapchain content outside the scissor (
LoadOpLoad). The missing column therefore keeps stale content from the previous frame — a real, if narrow, visual defect, not a debug-visualization artifact.At integer device scales (1x, 2x, 3x),
float64(n)*scalehas no fractional part, so truncation andFlooragree and the two formulas produce identical results. The defect is confined to fractional device scales — Windows at 125%/150%/175%, or any other non-integer scale factor.Fix. Match
gg's rounding exactly:Flooron the min corner,Ceilon the max corner, computed independently rather than as corner-plus-size.Honest gap. I can't reproduce this on my own hardware: my only test machine is a Retina Mac at an integer 2x device scale, where the two formulas agree by construction. So this one is headless-tested only. While researching finding 1 I did notice that gg#327's reporter hit a sibling class of this defect at 125% scaling on Linux/X11, which is at least evidence that the fractional-scale regime is a real user configuration and not a hypothetical.
3. gogpu/ui —
GOGPU_DEBUG_DIRTY=1overlay draws atdeviceScale²Root cause.
desktop'sdirtyOverlay.drawhand-multiplied its rect by the device scale before passing it togg'sFill/Stroke:f.rectcomes fromWindow.DirtyRegions(), which is already logical (widget screen bounds), andFill/Strokeapply the device-scale transform themselves. So the rect got scaled by the device scale twice: once explicitly here, once insideFill/Stroke.Expected vs. actual. The cyan overlay box should land exactly over the widget whose dirty region it's flagging. At 2x it instead drew at 4x the correct offset and size; at 3x, 9x.
Fix. Drop the multiplication and pass the logical rect straight through. I also wrapped the draw call in a transform/paint save-restore (
Push/Identity/Pop), so a leftover caller transform or paint state can't leak into the overlay or out of it, and added a guard against the border stroke's 1-pixel inset going negative for damage rects 1-2 pixels wide — reachable once the rect is stored logically, unreachable before, when the rect was always scaled larger. That guard came out of review before this landed, not from an observed failure.Honest gap. This fix has not been run on real hardware. The headless test reproduces the predicted pixel geometry exactly, but I haven't watched the overlay on an actual Retina display yet.
4. gogpu/gg —
GOGPU_DEBUG_DAMAGE=1overlay draws atdeviceScale²Root cause. Same defect as finding 3, arrived at from the other direction.
Context.trackDamagescales a logical damage rect to physical pixels once (Floor/Ceilby the device scale) for the OS compositor — that's the documented, correct behaviorTrackDamageRectpromises. ButCanvas.Render's debug-overlay block passesContext.FrameDamage()'s already-physical rects straight into the overlay'supdate, which draws them through the sameFill/Strokepath finding 3 uses — applying the device matrix a second time.I measured this on hardware before diagnosing it from source: with
GOGPU_DEBUG_DAMAGE=1set on a 2x Retina display, the green highlight boxes appeared over content well away from what actually redrew, at roughly double the size a correct overlay would have.Calibrated against the known log geometry, the boxes measure out to logical coordinates equal to the physical rect
trackDamagestored for that frame — the double-scale signature, established without leaning on a pixel ruler.Fix. Same shape as finding 3: convert at the ingest boundary (a new
logicalDamageRecthelper invertstrackDamage's rounding), store logical, and letFill/Strokeapply the scale once. I considered two alternatives and still think this is the right one:SetDeviceScalecall (a window dragged to a different-scale display mid-fade).Push(); Scale(1/s, 1/s); ...; Pop()) has the same scale-flip problem, and on top of that changes what the existing 1-pixel border inset means: a physical pixel instead of a logical one, i.e. a sub-hairline border at 2x.Converting at ingest and storing logical means the scale that's current at draw time is always the right one to apply, which stays correct across a scale change.
I also wrapped
drawAllinPush/Identity/Pop. gg#328 is the precedent for this:Canvas.Draw's closure got exactly this state-reset fix, for the same reason — an accumulating transform/paint leak between frames — but this call site,Canvas.Render's debug-overlay block, never did. It carries the same border-inset guard as finding 3.5. gogpu/gg — the overlay's suppression window restores the caller's damage-tracking state to a hardcoded value instead of remembering it (latent)
Root cause.
Canvas.Render's overlay-draw block wraps the draw in a damage-tracking suppression window:The disable is load-bearing: without it, the overlay's own
Fill/Strokecalls would register as damage, feed into the next frame'sFrameDamage(), and the overlay would flash over its own previous flash indefinitely. The restore is the actual bug — it hardcodestrueinstead of capturing what tracking was set to before the disable.I checked every
SetDamageTrackingdisable/enable pair reachable from this repo — five call sites — and all of them are balanced, non-re-entrant pairs; nothing currently calls back intoCanvas.Renderbetween its own disable and its own enable. So I can't currently demonstrate this breaking a real caller. It isn't defended against either, though:damageTrackingEnabledwas unexported with no getter, so no caller outside the package could have written a correct save/restore even if it needed to.Fix. Add a
Context.DamageTracking() boolgetter next to the existingSetDamageTracking, and haveCanvas.Rendercapture and restore around the window instead of hardcodingtrue. That's zero behavior change today — every reachable path enters with tracking already enabled, so the captured value is alwaystrue— but it converts an unenforced assumption into a structural invariant.Verification
All five are verified headlessly, against a CPU-rasterized
gg.Context(NewContextWithScaleplusSetRasterizerMode(RasterizerAnalytic)for deterministic pixel readback) — no GPU, no window. The test files are in the patches below.Each one also got a discrimination check — temporarily reinstating only the pre-fix arithmetic, never the changed function signatures, confirming the new test fails at exactly the predicted values, then reverting:
deviceScale=2(20,20)-(60,60)(40,40)-(120,120)deviceScale=3(30,30)-(90,90)(90,90)-(270,270)deviceScale=2(100,60)-(180,100)(200,120)-(360,200)deviceScale=3(30,30)-(33,33)(90,90)-(99,99)(inverted/offset border)deviceScale=2(20,20)-(60,60)(40,40)-(120,120)deviceScale=3(30,30)-(90,90)(90,90)-(270,270)deviceScale2 to 1(10,10)-(30,30)(20,20)-(60,60)deviceScale=3(30,30)-(33,33)(90,90)-(99,99)(inverted/offset border)deviceScale=2(0,0)-(200,200)(0,0)-(100,100)(logical, not physical)deviceScale=3(0,0)-(300,300)(0,0)-(100,100)(logical, not physical)200x150@2x(0,0)-(400,300)(0,0)-(200,150)(logical, not physical)(0,0)-(200,200)(0,0)-(100,100)(stale 1x carried forward)truebefore drawtrueaftertrueafter (passes both ways by construction)falsebefore drawfalseaftertrueafter (clobbered)scale=1.5, origin(11,5), size20x10(16,7)-(47,23)(16,7)-(46,22)(one pixel short on the max corner)The 1x rows for findings 3 and 4 pass both before and after by construction — at scale 1 the defect is arithmetically a no-op — so they're a don't-over-correct guard, not a discriminator. Finding 5's was-enabled row is the same shape of guard.
Honest gaps. Only finding 4 has been observed on hardware (the screenshot above). Finding 1's practical severity — a visible artifact versus just wrong units reaching the compositor — is open, pending a resize test on real hardware. Finding 2 can't be reproduced on my hardware at all (integer device scale only). Finding 5 has no reproducing caller I can point to.
Applying these
Base versions:
ggv0.50.8 for the twoggfolds below, and this repo'smain(v0.1.48) for theuifold. Each diff is relative to its own repo root and applies cleanly to a pristine checkout at those versions — verified withgit apply --checkplus a build and test run against a separately-fetched copy, not just against my own tree.One coupling to flag: the finding-1 fold's test (
forward_damage_units_test.go) calls ascaleNamehelper defined in the finding-4/5 fold's test file (damage_debug_hidpi_test.go). To apply the finding-1 fold on its own, either bring that 8-line helper along or replacet.Run(scaleName(scale), ...)witht.Run(fmt.Sprintf("%gx", scale), ...). I split the patches by scope, production versus debug-tooling, rather than by file, and this is the one place that split isn't quite clean at the test level.Patches
gg — finding 1: forwardDamageRects reports physical pixels
gg — findings 4+5: debug overlay double-DPI scaling + damage-tracking clobber
ui — findings 2+3: cyan overlay double-DPI scaling + scissor rounding
Resolution checklist
forwardDamageRectsreports post-resize/first-frame damage in logical pixels (production)GOGPU_DEBUG_DIRTY=1overlay drawn atdeviceScale²GOGPU_DEBUG_DAMAGE=1overlay drawn atdeviceScale²Environment
ggv0.50.8; this repo'smain(v0.1.48)CGO_ENABLED=0Context's own transform math andgg/gogpu's documented contracts, not through a specific renderer. Fractional-scale environments (Windows 125-175%, Linux/X11 125%) weren't available to me to test directly.Related: #172 — finding 1's
SetDeviceScalehalf is theggcanvas-side sibling of that report (a scale change can currently leave a stale, wrong-scale rect in the present path). gg#327 (fixed by PR #311 and PR #332, shipped v0.46.9/v0.47.3) is the same logical-vs-physical mistake in a different function; finding 1 is the call site that fix didn't reach. gg#328'sDraw-closure state reset is the precedent for finding 4's transform/paint wrapper. The damage-ring work in #177 and #178, shipped in v0.1.48, is adjacent to findings 1 and 2 but independent — nothing here reverts or depends on it.Part of the fix registry: #170