[Bugfix] Grid children's dirty propagation dead-ends at the internal UIGrid, never reaching the GridView or its caching ancestors #364
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.
Blocks
Reference
john/McRogueFace#364
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 by adversarial review of the #355/#359 branch (
feat/355-grid-input-revival,85e4bbe). Reported as minor; not fixed there.Defect
UIGrid::get_children(src/UIGridPyProperties.cpp~:350) parents a grid's child drawables to the internal_GridData/UIGrid object:UIDrawable::markCompositeDirty()walks up theownerchain to invalidate caching ancestors. For a grid child that walk reaches the internal UIGrid — which no scene ever contains — and stops. It never reaches theUIGridViewthat actually renders the child, nor anyFrame(use_render_texture=True)above it.#359's
GridData::viewsregistry fixed exactly this push path for GridData mutations (entity moves, layer edits, viaGridData::markDirty), but grid children areUIDrawables that dirty themselves throughUIDrawable::markCompositeDirty(), which knows nothing about the registry.Consequence
Mutating a grid child (moving a speech bubble, changing a marker's color) may not invalidate the render caches above it, so the change can fail to appear until something else forces a redraw. This is latent rather than always-visible because #351's early-out polls
content_generation— which child mutations do not bump.Fix
Either (a) parent grid children to the
UIGridViewrather than the internal UIGrid — which is the architecturally correct answer and may fall out of #361, or (b) have the UIGrid'smarkCompositeDirtyforward intoGridData::viewsthe wayGridData::markCompositeDirtyalready does.Prefer (a). Check whether #361 (
_GridDatashedsUIDrawable) subsumes this entirely — if children can no longer be parented to a non-drawable, the dead-end cannot exist.Test
Append a Frame to
grid.childreninside aFrame(use_render_texture=True), render, mutate the child, render again, and assert the composite is updated.Correction: fix (a) is not available, and this is a #361 prerequisite (2026-07-12)
Analysis session on
feat/355-grid-input-revival. Three corrections to the body above.1. The mechanism is
parent, notowner. The body saysmarkCompositeDirty()"walks up theownerchain." It doesn't — it walksparent(UIDrawable.cpp:1082), and it is not virtual (UIDrawable.h:336), soGridData::markCompositeDirty(which does fan out toviews) can never be reached from a parent-chain walk. The dead-end is real; the description of it was imprecise. The chain is:UIGrid::get_childrensets the collection'sownerto the internal UIGrid (UIGridPyProperties.cpp:284) →UICollection::appendcallssetParent(owner)(UICollection.cpp:657) → the child'sparentis the internal UIGrid, whose ownparentis null.2. Fix (a) — "parent grid children to the GridView" — is impossible, so this issue is NOT subsumed by #361.
childrenlives onGridData(GridData.h:130), besideentities: shared world content in grid-world coordinates (#360). AUIDrawablehas exactly oneparentweak_ptr, but aGridDatamay have N views. Whichever view the child was appended through would become its parent arbitrarily, and if that view died the parent would dangle while the child still rendered in the remaining views. Fix (b) is the answer — children stay owned by GridData, and the upward push fans out through theviewsregistry, exactly asGridData::markCompositeDirtyalready does for entity moves.This inverts the dependency: #361 does not subsume this issue, it is blocked on it. Once
UIGridshedsUIDrawable,setParent(internal UIGrid)will not typecheck, so #361 cannot land without answering this first. Full design note (unifiedParentRefvariant replacing the ad-hocparent+parent_scenefields) is in the #361 comment thread.3. Scope is narrower than filed, but there's a hidden cost. The #351 early-out already disables itself whenever a grid has any children (
UIGridView.cpp:189:&& (!grid_data->children || grid_data->children->empty())), so the child's own pixels are never stale — a grid with children fully re-renders every frame regardless. The dead-end only bites a caching ancestor above the view (aFrame(use_render_texture=True)wrapping the grid), which re-blits its stale composite. That matches the test recipe in the body exactly.The flip side: any grid with even one child currently pays a permanent full re-render. That's the #352 carve-out. Precise child dirty-tracking (this issue's fix (b), plus bumping
content_generationon child mutation) would let the carve-out be removed and restore the early-out for grids with children — which is a real performance win, not just a correctness fix.Blocking #361.
Resolved: children move to the GridView. Fix (a) is back on, by changing what "children" means (2026-07-13)
Decision (repo owner, 2026-07-13): children live on the
GridView; entities stay onGridData.My earlier comment ruled out fix (a) ("parent grid children to the GridView") on the grounds that
childrenlives onGridData, which may have N views, so no single view can be the parent. That reasoning was sound but it tookchildren-on-GridDataas given. It isn't. Move the collection to the view and the objection evaporates: each child belongs to exactly one view, so its parent is a real scene-graph drawable and the dead-end cannot be expressed.Why this is the right semantics, not just a convenient one
A drawable child is an overlay painted over the map through one camera -- a speech bubble, a marker, a range indicator. It occupies no cell, nothing collides with it, the turn manager cannot see it, and it is already drawn on top of layers and entities and already unaffected by FOV. Entities are the world contents: they live in cells, they hit
walkable/transparent, pathfinding andgrid.step()act on them, and every camera must show the same ones. So entities are shared and children are per-view. The engine has been treating children as overlays all along; only the ownership disagreed.This also kills the duplicate-annotation worry in reverse: a minimap at
zoom=0.25should not inherit the main view's speech bubbles (they'd render as illegible smears). Per-view children is the behavior you want anyway.What it resolves
parentis theUIGridView, somarkCompositeDirty()/markContentDirty()walk up through it to anyFrame(cache_subtree=True)exactly as a Frame's child does. Noviewsfan-out, nogrid_ownerfield.global_positionambiguous under N views). One child, one view, one camera, one screen pixel. The ambiguity was purely a consequence of children living on shared data. (The transform itself is still not applied -- see the follow-up issue below -- but the question of what the answer should be is now settled.)ParentRefvariant becomes optional cleanup rather than a prerequisite for #361.GridDataholds noUIDrawableat all now, so once it shedsUIDrawablethere is no non-drawable-parent case to represent. #361 is unblocked.Migration cost: near zero
mcrfpy.Gridis theUIGridView(samePyTypeObject), sogrid.children.append(bubble)is unchanged for every single-view script in the repo. The API-surface snapshot confirms it:childrengains onGrid/GridView, loses on the internal_GridData, delegation drops 62 -> 61. The public surface ofmcrfpy.Gridis identical.What changes:
entity.grid.childrenwill not exist once #361 lands (entity.grid->GridData) -- same accepted break, same reason, ascenter_camera. Andchild.parent = <_GridData>now raisesTypeErrorinstead of silently working.The one thing given up: a decorative drawable conceptually baked into the map (a zone-name Caption that should survive save/load) is now view-scoped. Such a thing wants to be a layer or an entity. Flagged as a decision, not discovered later.
Fix (committed)
GridData::children/children_need_sort->UIGridView(GridData.h:128,UIGridView.h)UIGrid::get_children->UIGridView::get_children; the collection'sowneris the view, soUICollection::appendalready parents correctly and already callsowner->markContentDirty()UIDrawable::removeFromParentdrops itsUIGRIDarm entirely (down to Frame / GridView / scene)UIDrawable::set_parentno longer accepts a_GridDataUIGrid::render's child-drawing block deleted (the ghost path has no children);UIGrid::resize's aligned-child loop moved toUIGridView::resize-- it was never ported when the view was split out, so a resized grid never re-anchored aligned childrenPyScenehover walk,mcrfpy.find()/findAll(), and the ImGui scene explorer read the view's children.findno longer dedups children by GridData (two views hold different children); entities still dedup viavisited_grids.A second, real bug this exposed
UIGridView::render's #351 early-out never consulted its ownrender_dirty. It got away with it because every content mutation bumpscontent_generation-- and the children carve-out (!children->empty()-> always full render) papered over the rest. But removing the last child lifts the carve-out, at which point every other input is unchanged and the view re-blits a cached raster with the removed child still baked into it. Fixed by adding!render_dirtytocan_skipand callingclearDirty()after a full rasterization.That also means the view now has a precise per-child dirty signal, so the
!children->empty()carve-out can be dropped -- restoring the early-out for grids that have children, which is the performance half of my earlier comment. Left in place here (conservative = always correct) and handed to #352.Test
tests/regression/issue_364_grid_children_on_view_test.py-- 17 assertions, all failing beforehand in the ways predicted:_GridData_GridDatarejects being a parent and exposes nochildrenFrame(cache_subtree=True); move / recolor / remove a grid child and assert the screenshot bytes change. All three were byte-identical (stale composite) before the fix. Plus an idle-stable pair, so the skip path is not merely disabled.Suite 326/326.
.parentallocates a fresh wrapper on every read, sochild.parent is parentis always False #369