refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half

closes #361
closes #370
closes #371

UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.

UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.

  * mcrfpy.GridData is public and standalone-constructible. A map with no view
    at all -- an offscreen level, still steppable and pathable -- was previously
    undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
  * UIGridView::init constructs the GridData directly instead of building a
    throwaway Python _GridData, stealing its base subobject, and copying ~15
    fields of rendering state out of the discarded wrapper. One init() serves
    both modes, so a second view is no longer a crippled kwarg subset
    (Grid(grid=other, z_index=5) used to raise TypeError).
  * entity.grid returns the GridData. Accepted break: an entity may be on a map
    with no view or with several, so "the grid an entity is in" is not a camera.
    Migrate player.grid.center_camera(p) -> view.center_camera(p).
  * tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.

Two latent bugs surfaced and are fixed here:

  #370  Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
        was defined on the view, so both delegated to the ghost camera. On
        master, center_camera((5,5)) leaves center_x at 50.0.
  #371  The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
        name) then cast to PyUIGridObject. UB that only worked because both
        wrappers had identical layout and both classes put UIDrawable at offset 0.

Suite 327/327.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-13 07:14:43 -04:00
commit c696b4ef41
51 changed files with 1520 additions and 2633 deletions

View file

@ -30,12 +30,13 @@ cameras over one dataset):
dead property is gone, not silently still None.
Design note carried over from the fix: GridData::views is a *list*, not a
single pointer, specifically because N views can share one GridData. Index 0
is guaranteed (by construction: a fresh GridData is only ever produced by
Grid()'s factory path, `init_with_data`; `init_explicit_view` can only ATTACH
to an already-existing GridData) to be the view that created the data, so
UIEntity.grid identity (test 2) stays deterministic even with secondary
views registered -- not an arbitrary pick from an unordered set.
single pointer, specifically because N views can share one GridData.
#361 update: entity.grid no longer returns a view at all -- it returns the
GridData. The "which view is primary?" question that views.front() answered for
identity purposes is gone, because the answer was always arbitrary dressed up as
deterministic. The registry itself remains, and is still load-bearing for
dirty-notification fan-out (test 3).
"""
import sys
import os
@ -95,18 +96,20 @@ def test_1_two_views_render_shared_mutations():
def test_2_entity_grid_identity():
"""entity.grid returns the SAME object as the Grid() that actually
created the data (GridData::views.front()), not the secondary view, not
None, and not a fresh throwaway wrapper -- even once a secondary view is
registered on the same GridData."""
"""entity.grid is the shared GridData -- one stable object, the same one both
views are looking at, and NOT either view (#361)."""
print("(2) entity.grid identity with a secondary view registered...")
grid_a = mcrfpy.Grid(grid_size=(5, 5))
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80)) # noqa: F841 (keep alive)
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80))
e = mcrfpy.Entity((1, 1), grid=grid_a)
assert e.grid is grid_a, f"entity.grid identity broken: {e.grid!r} is not grid_a ({grid_a!r})"
assert e.grid is not grid_b, "entity.grid returned the secondary view instead of the creator"
data = grid_a.grid_data
assert grid_b.grid_data is data, "the two views do not share one GridData"
assert e.grid is data, f"entity.grid identity broken: {e.grid!r} is not {data!r}"
assert e.grid is e.grid, "entity.grid is not stable across reads"
assert e.grid is not grid_a and e.grid is not grid_b, \
"entity.grid returned a view; it must return the map (#361)"
print(" OK")