[Bugfix] Frame(cache_subtree=True) silently freezes its subtree's content: render_dirty is a write-once latch, so markContentDirty never propagates #368
Labels
No labels
Alpha Release Requirement
Bugfix
Demo Target
Documentation
Major Feature
Minor Feature
priority:tier1-active
priority:tier2-foundation
priority:tier3-future
priority:tier4-deferred
Refactoring & Cleanup
system:animation
system:documentation
system:grid
system:input
system:performance
system:procgen
system:python-binding
system:rendering
system:ui-hierarchy
Tiny Feature
workflow:blocked
workflow:needs-benchmark
workflow:needs-documentation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
john/McRogueFace#368
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while fixing #364. Not grid-specific -- the repro below has no Grid in it at all.
Defect
UIDrawable::markContentDirty()(src/UIDrawable.cpp:~1090) only propagates up the parent chain conditionally:The guard is a legitimate optimization if
render_dirtyis reliably cleared once the drawable renders. It is not. A drawable that is not in render-texture mode never callsclearDirty()--UIFrame::renderonly rebuilds-and-clears underif (use_render_texture && render_dirty)(src/UIFrame.cpp:165). So an ordinary nested Frame/Caption/Sprite setsrender_dirty = trueon its first mutation and it stays true for the object's whole life.From then on, every subsequent mutation of that drawable has
was_dirty == true, and if its parent is also permanently-dirty (same reason),(!was_dirty || !p->render_dirty)isfalseand the invalidation is never propagated to any ancestor. A caching ancestor above it (Frame(cache_subtree=True)) is never told, so it re-blits its stale composite.Repro (no grid involved)
The first mutation gets through (the drawable was clean, so
!was_dirtyholds). Every mutation after it is silently dropped. That ordering is the tell: it is not "recolor doesn't invalidate," it is "nothing invalidates after the first time."Why it has stayed hidden
It only manifests under a caching ancestor --
Frame(cache_subtree=True), or aGridView(which is why #364's test caught it). Without one, every drawable is re-drawn from scratch each frame and the missed invalidation costs nothing visible.Fix directions
clearDirty()at the end of itsrender(), not just render-texture frames. Thenwas_dirtymeans what the guard assumes. This is what #364 did forUIGridView(addedclearDirty()after rasterization), which is why grid children now propagate correctly -- but it fixed only that one arm.was_dirtyhalf of the guard and keep only!p->render_dirty(still terminates: propagation stops at the first already-dirty ancestor). Cheaper to reason about, slightly more walking.Prefer (1) -- it makes the flag's meaning true rather than working around it being false. But it needs a check that nothing else depends on
render_dirtystaying latched across frames.Test
tests/regression/issue_364_grid_children_on_view_test.pycase 5b covers the grid arm. This issue needs the Frame-only repro above as its own regression test, asserting screenshot bytes change on the second and third consecutive mutation of a nested child, not just the first.Correction: the original writeup mis-stated the shape of this bug
Re-probed against
feat/355-grid-input-revival(which does not touchmarkContentDirty— confirmed pre-existing on master). Two corrections:1. It is NOT "nothing invalidates after the first time." Position changes still propagate, because
x = ...routes throughmarkCompositeDirty(), a different function with an unconditional push. OnlymarkContentDirty()latches. My repro's third step was a move, which is why it appeared to recover:2. It is worse than "the second mutation is dropped." Under a caching ancestor, content mutations NEVER propagate — not even the first.
(By the time the first recolor lands,
render_dirtyhas already latched true from construction/append, so!was_dirtyis already false.)So the accurate statement of the defect is:
Frame(cache_subtree=True)silently freezes the content of its entire subtree. Text never updates, colors never change, sprite indices never change. Only movement works. That is a considerably bigger deal than the original title suggests.Root cause, now proven rather than inferred
clearDirty()is called from exactly one place in the entire codebase:That one call site is the one #364 added.
UIFrame,UICaption,UISprite,UILine,UICircle,UIArcnever clear their dirty flags at all — sorender_dirtyis a write-once latch, and thewas_dirtyguard inmarkContentDirtyis reasoning off a flag that is permanently true.This also means the grid branch has left the codebase asymmetric:
UIGridViewis now the only drawable that honours the dirty contract. Fix direction (1) from the original issue (clear the flag honestly in everyrender()) is now clearly the right one — it makes the flag's meaning true everywhere instead of true in one class.[Bugfix] markContentDirty's was_dirty short-circuit latches: a drawable that never clears render_dirty stops propagating invalidation foreverto [Bugfix] Frame(cache_subtree=True) silently freezes its subtree's content: render_dirty is a write-once latch, so markContentDirty never propagatesFixed on
feat/355-grid-input-revival(9d67e33)markContentDirty()'s walk up the parent chain is now unconditional, matchingmarkCompositeDirty().Why not fix direction (1) or (2) from the original issue
Both suggestions in the issue body turn out to be wrong, and it's worth recording why:
Direction (2) — "drop the
was_dirtyhalf, keep!p->render_dirty" — does not fix the bug at all. The parent in the failing case is an ordinary Frame whoserender_dirtyhas itself latched true. So!p->render_dirtyis false and the walk still stops dead. I'd have shipped a no-op.Direction (1) — "clear the flag honestly in every
render()" — works, but buys a shorter walk at the price of an invariant that breaks silently the day someone adds a drawable type and forgets to callclearDirty(). That is precisely the failure that produced this bug in the first place:UIGridViewremembered (via #364) and six other classes did not.Unconditional propagation cannot be broken that way. The correctness of the render cache no longer depends on every drawable's render path remembering to do bookkeeping.
The cost is real but lands where it doesn't matter
200k content mutations, best of 3:
The old build's flatness with depth is the defect itself — it was flat because it wasn't walking. (That same build fails 10 of the new test's assertions.)
Entity movement — the actual hot path — is completely unchanged: 164ns/move either way. Position changes already went through
markCompositeDirty, which has always walked unconditionally. So the added cost applies only to content mutations (colour, text, sprite index), which are orders of magnitude rarer than movement in a real frame. Crucible confirms no regression (step_swarm12.9 → 10.7ms,entity_churn36.3 → 31.5ms, within noise).Test
tests/regression/issue_368_cache_subtree_content_dirty_test.py— 13 assertions. Every consecutive recolour and caption edit must repaint through the cache, at depth 1 and at depth 5.The load-bearing one is case 4a: idle frames under a cache must stay byte-identical. Without it, "mark everything dirty every frame" would pass every other assertion in the file while making
cache_subtreeworthless.Suite 329/329.