The parent link (#122/#183) is meant to be the inverse of collection
membership, but only append() maintained it.
insert(), extend() and setitem() called setParent(owner.lock())
unconditionally. A scene collection has no owner drawable, so that
resolved to null -- and setParent(nullptr) also clears parent_scene. The
drawable landed in the scene's UI vector, rendered fine, and reported a
.parent of None.
The slice arms never touched the link at all. A slice-delete left the
removed child pointing at a parent that no longer contained it; a
slice-assign never parented the incoming items, nor unparented the ones
they displaced. A stale parent is a live weak_ptr to a frame the child
has left, and it is what get_global_position() walks.
Route every mutator through link_child()/unlink_child(), which are now
the single place that knows a scene parents via setParentScene() and a
drawable via setParent().
Three latent traps in the same functions, found while fixing this:
* collection[-1] on an EMPTY collection hung the engine forever:
`while (index < 0) index += size()` adds zero on every pass.
* getitem's `index > size() - 1` check underflowed to SIZE_MAX when
size() was 0, so the bounds check passed and it indexed an empty
vector.
* insert() and setitem() resolved the index before detaching the
incoming drawable from its old parent. If it was already a member of
the same collection, detaching erased it and shifted everything
after -- so an index validated against the old size could write past
the end. Slice-assign now builds its result as an independent vector
for the same reason.
closes#377
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two real engine bugs, both surfaced by unrotting tests that had been silently
scored as passing for months, and both independently reproduced before fixing.
#375 -- DijkstraMap.path_from() returned the path REVERSED, and step_from()
inherited the error, returning a cell that is not adjacent to the query
position. libtcod builds its list as [pos, ..., root_adjacent] and
TCOD_dijkstra_path_walk() pops from the BACK, so the collecting loop produced
[root_adjacent, ..., pos]. On an open grid with root (5,5):
path_from((9,5)) -> [(6,5),(7,5),(8,5),(9,5)] # marched AWAY from the root
step_from((9,5)) -> (6,5) # a three-cell teleport
That contradicted path_from's own docstring, AStarPath.origin/.destination,
and find_path()'s convention. step_from was the dangerous one: an entity
chasing a Dijkstra root would jump next to the root on its first step. The
path is now emitted origin->destination, excluding the origin and including
the root (found via descentStep, so multi-root lands on the right root), and
step_from returns its first element -- provably adjacent.
The behavior system was never affected: PathProvider (SEEK/FLEE) uses
descentStep, which was already correct. getPathFrom/stepFrom had no internal
C++ callers.
#376 -- ColorLayer/TileLayer .visible and .z_index setters never called
markDirty(), so they did not bump content_generation, which is the key the
#351 render early-out uses. Hiding a layer left the rendered image
byte-identical (sha256 unchanged) until some unrelated mutation happened to
dirty it; re-showing it did not restore it. z_index carried an explicit
"TODO: Trigger re-sort in parent grid" and had the same defect. Both setters
now invalidate on change. (Only observable after #351 stopped redrawing
unconditionally -- the same masking story as the entity set_position bug.)
Neither was caught because no test asserted path ORDER (test_astar checks only
length and wall-avoidance, both invariant under reversal), and because
issue_148_layer_dirty_flags.py -- the test literally named for this defect
class -- had rotted and was exiting 0 without running.
closes#375closes#376
step() advanced animations and timers only. It never called updatePythonScenes(),
which lives solely in doFrame() -- so headless Scene.update(dt) overrides never
fired. That was the filed bug, but it was one of twelve omissions: the C++ scene
update never ran, scene transitions never progressed or COMPLETED, and
current_frame never advanced. Headless behaved measurably differently from a real
frame, and any game with per-frame logic in update() was untestable.
step() is now a full simulation frame: scene update, timers, Python scene update,
animations, transitions (including completion), frame-time metrics, currentFrame++.
Render and input stay out, deliberately -- rendering is orthogonal to the clock and
costs zero simulation time (see renderScene(); a screenshot draws arbitrary state
without time passing). Timers remain on simulation_time: explicit, deterministic
time is the entire point of driving a headless test with step().
This exposed the deeper bug. testTimers() reads simulation_time when headless, and
simulation_time is advanced ONLY by step() -- the doFrame run loop never touched it.
So the headless run loop spun forever on a frozen clock: any headless script that
registered a timer and did not call step() hung until killed. And a script that died
during setup registered no timers, hit the auto-exit-when-no-timers path, exited 0,
and was scored as a PASSING test.
So headless --exec now exits when its scripts finish, nonzero if a script fell off
the end without calling sys.exit(). step() is the clock; the engine cannot advance
time on its own, and pretending otherwise is what produced the hang. A genuinely
long-lived headless process (server, REPL host) opts in with the new --run-forever.
run_tests.py additionally refuses to pass any test whose output contains a
Traceback. Between the two, 82 tests that were being scored green while asserting
nothing are now correctly red; they are repaired in the following commits.
closes#350
get_metrics() returned 0 for draw_calls, ui_elements, visible_elements and
every grid counter. Two distinct defects behind one symptom.
Ordering. draw_calls/ui_elements/visible_elements WERE incremented, in
PyScene::render() -- but render runs at step 6 of the frame, after every
Python callback, while the counters were zeroed at step 1. A script calling
get_metrics() from a timer or scene update was structurally incapable of ever
observing a nonzero value.
Dead code. grid_cells_rendered, entities_rendered, total_entities,
grid_render_time and entity_render_time were never incremented ANYWHERE in
src/ -- the increments were lost in the GridView/chunk refactor and only
fovOverlayTime survived. They are re-instrumented in UIGridView::render().
Fix both by splitting the metrics along the seam that actually exists:
rendering is orthogonal to simulation and costs zero clock time (a screenshot
draws arbitrary state without advancing time). So render counters are cleared
and published by the render pass (beginRender/endRender, in both doFrame and
the on-demand renderScene), and simulation timings by the sim pass
(beginSimFrame/endSimFrame). get_metrics() reads the published values, so a
step()-only run cannot wipe the counters from the last render, and a render
cannot disturb the clock.
Riders found while in here:
- ScopedTimer ASSIGNS, so with two grid views on screen the second view's time
silently replaced the first's. Added ScopedAccumTimer for per-frame totals
accumulated across objects; fovOverlayTime had this bug already.
- fps was inflated for the first 60 frames: frameTimeHistory is zero-filled but
the mean always divided by the full 60. Now divides by frames actually seen.
- frame_time is milliseconds; the docstring said seconds. Fixed the docstring,
not the value -- the Gauntlet already reads it as ms.
closes#341
mcrfpy.current_scene, .scenes, .timers, .animations, .default_transition,
.default_transition_duration and .save_dir were served by a PEP-562 module
__getattr__. getattr/hasattr worked, but the names were absent from
dir(mcrfpy) -- and all three generators (docs, stubs, api manifest) discover
module symbols via dir(). So the single most common idiom in the engine,
`mcrfpy.current_scene = ...`, appeared in no generated documentation at all,
and had to be hand-authored and hand-verified against a live binary for the
website.
Replace the __getattr__/tp_setattro string-matching with real getset
descriptors on McRFPyModuleType (which was already a custom module type, so
this is a small change) carrying MCRF_PROPERTY docstrings.
Descriptors alone are NOT sufficient, contrary to what the issue proposed:
CPython's module.__dir__ reports only the module __dict__ keys, so type-level
descriptors stay invisible to dir(). Verified empirically, then fixed by
overriding __dir__ on the module type to union the two. This is the part that
actually closes the bug.
The generators then need to read the DESCRIPTOR, not getattr()'s value: at
generation time current_scene is None, which is how a first pass typed it
`current_scene: NoneType`. All three now introspect type(mcrfpy), and
api_delta.py learns a module_attributes section so these can no longer drift
silently. The api-surface snapshot gets its own dynamic-attribute section for
the same reason -- keying it on the value type would make the golden flip
depending on whether a scene happened to be active.
Behavior preserved: read-only attrs still raise AttributeError on assignment,
range/type validation on the writable ones is unchanged, and unknown
attributes still raise AttributeError.
Suite 331/331.
closes#356
`.parent` allocated a brand-new Python wrapper on every read, so object
identity never held: `kid.parent is parent` was False, and so was
`kid.parent is kid.parent`. A Frame/Grid subclass reached through .parent
came back as the base type with its Python attributes gone, and `.parent`
could not be used as a dict key or set member.
The cache-aware converter already existed -- convertDrawableToPython in
UICollection.cpp -- but was file-local, so three other paths hand-rolled
their own tp_alloc switch and skipped the cache:
- UIDrawable::get_parent (the filed bug)
- find_in_collection (mcrfpy.find / find_all)
- UIEntityCollection concat and slice __getitem__
The entity sites were the worst of them: a duplicate wrapper's tp_dealloc
unconditionally clears UIEntity::pyobject, destroying the #266 subclass
identity ref held by the *original* wrapper.
Promote the converter to UIDrawable::pyobject_for and route all sites
through it; add the matching convertEntityToPython for entities. This also
gives .parent the Line/Circle/Arc/Viewport3D arms it never had (they
previously fell through to None).
Residual gap, deliberately not closed here: the cache holds weakrefs, so a
subclass wrapper GC'd while only C++ holds the object still resurrects as
the base type. Closing that needs an owner-held strong ref (the #348
pattern) and is a lifetime design decision; filed separately.
Verified A/B: 17 of the new test's 22 checks fail pre-fix, all pass after.
Suite 330/330. Also corrects CLAUDE.md, which documented the long-deleted
RET_PY_INSTANCE macro and recommended bare tp_alloc as the "most common"
pattern -- i.e. taught this exact bug.
closes#369
Adds the July 13 entry for the #355 branch (15 issues closed at 36e74e0),
refreshes Active Follow-Ups now that the memory-model review is down to
#336/#337/#338, and rebuilds the open-issue inventory against the tracker
(33 -> 29; new bug grouping for #369/#372/#356).
api/manifest.json carries only the pre-commit hook's provenance stamp
(commit c696b4e/dirty -> 36e74e0/clean); no API surface changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
closes#368closes#367#368 -- Frame(cache_subtree=True) silently froze its subtree's CONTENT.
markContentDirty() gated its walk up the parent chain on
(!was_dirty || !p->render_dirty). That is sound only if render_dirty is reliably
cleared once a drawable has been drawn. It is not: clearDirty() had exactly one
call site in the whole engine (UIGridView, added by #364 on this branch). UIFrame's
non-render-texture path, UICaption, UISprite, UILine, UICircle and UIArc never
cleared it, so render_dirty was a write-once latch -- true from an object's first
mutation until it died.
With the flag stuck true, was_dirty was always true AND the parent's render_dirty
was always true, so the guard was always false and content invalidation propagated
nowhere. A caching ancestor went on re-blitting a stale composite: text never
updated, colours never changed, sprite indices never changed.
Only movement survived, because x/y route through markCompositeDirty, whose walk
was already unconditional. That asymmetry is why the bug read as intermittent
rather than total, and it is why my original issue writeup got the shape wrong --
its repro's third step was a move, so it looked like the latch cleared. It does
not. Measured on master: 4/4 recolours and 3/3 caption edits under a cache were
ALL dropped, including the first.
The fix makes markContentDirty's walk unconditional, matching markCompositeDirty.
The alternative -- repair the guard by having every drawable clear the flag
honestly -- buys a shorter walk at the price of an invariant that silently breaks
the day someone adds a drawable and forgets. Not worth it here: parent chains are
shallow, and this is the exact cost markCompositeDirty has always paid on every
position change without anyone noticing.
A/B on the changed path (200k content mutations, best of 3):
depth 1 4 8 16
before 82ns 86ns 82ns 87ns <- flat because it did nothing
after 76ns 114ns 175ns 295ns
The old build's flatness IS the defect. Entity movement -- the actual hot path --
is unchanged (164ns/move either way), since it was already on the unconditional
composite path. Crucible shows no regression (step_swarm 12.9 -> 10.7ms,
entity_churn 36.3 -> 31.5ms, within noise).
Regression test asserts every consecutive content mutation repaints through a
cache at depths 1 and 5, AND that idle frames stay byte-identical -- without that
last assertion, "mark everything dirty always" would pass and the cache would be
worthless.
#367 -- Losing window focus stranded hover exactly as a window-leave did (#363).
An unfocused window is delivered no MouseMoved, so the hover walk never runs and
whatever was hovered when focus left stays lit while the game sits in the
background: on_exit/on_cell_exit never fire and hovered_cell keeps naming a cell
the user is no longer pointing at. sf::Event::LostFocus was already produced by
every backend -- SDL2Renderer even translates SDL_WINDOWEVENT_FOCUS_LOST into it --
and, like MouseLeft before #363, had nobody listening.
Routed to the same PyScene::do_mouse_leave() that #363 added. Once unfocused the
engine genuinely does not know where the pointer is, so "hovering nothing" is the
only truthful state to hold; the next MouseMoved after focus returns restores it.
automation.loseFocus() injects the event, via a new injectWindowEvent() rather than
injectMouseEvent -- a focus event has no coordinates and no button, and should not
borrow a signature that implies it does. api_surface golden re-baselined: +1 line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
closes#361closes#370closes#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>
A grid child (speech bubble, marker, range indicator) is an OVERLAY painted over
the map through one camera -- it occupies no cell, nothing collides with it, and
the turn manager cannot see it. Entities are the world contents. So children are
per-view and entities are shared, not the other way around.
Ownership follows from that. A UIDrawable has one `parent`, but a GridData may
have N views, so a child owned by the data could only ever name one of them --
arbitrarily -- and would dangle if that view died while the others kept drawing
it. That is why children's dirty push dead-ended at the internal UIGrid, which is
in no scene: a Frame(cache_subtree=True) above the grid was never told to
re-composite and re-blitted a stale cache. Owned by the view, a child's parent is
a real scene-graph drawable and the dead-end cannot be expressed.
- GridData::children/children_need_sort -> UIGridView; UIGrid::get_children ->
UIGridView::get_children (collection owner = the view, so UICollection::append
already parents and invalidates correctly)
- UIDrawable::removeFromParent drops its UIGRID arm; set_parent rejects _GridData
- UIGrid::render's child-drawing block deleted (the ghost path has no children);
UIGrid::resize's aligned-child loop moved to UIGridView::resize, which never had
one -- a resized grid was not re-anchoring aligned children at all
- PyScene hover, mcrfpy.find()/findAll(), and the ImGui explorer read the view's
children. find() no longer dedups children per-GridData (two views hold
different children); entities still dedup.
Also fixes a latent staleness this exposed: UIGridView::render's #351 early-out
never consulted its own render_dirty. It survived on content_generation plus a
"grid has children -> always full render" carve-out -- but removing the LAST child
lifts that carve-out, leaving every other input unchanged, so the view re-blitted a
raster with the removed child still baked in. can_skip now requires !render_dirty,
and render() clears it after rasterizing.
mcrfpy.Grid IS the UIGridView, so grid.children.append(...) is unchanged for every
single-view script; only the internal _GridData loses `children` (API snapshot:
delegation 62 -> 61, public surface identical).
Answers #361's Q1 (one child, one view, one screen pixel) and moots its Q2 (no
non-drawable parent kind remains to represent), unblocking it.
Test: tests/regression/issue_364_grid_children_on_view_test.py -- 17 assertions;
the load-bearing ones screenshot a grid inside a Frame(cache_subtree=True) and
assert move/recolor/remove of a child change the pixels. All three were
byte-identical before. Suite 326/326.
Spun out: #368 (markContentDirty's was_dirty latch swallows every invalidation
after the first -- not grid-specific), #369 (.parent allocates a fresh wrapper, so
`child.parent is parent` is always False).
closes#364
closes#363
Hover was driven exclusively by sf::Event::MouseMoved -> PyScene::do_mouse_hover.
Nothing anywhere handled sf::Event::MouseLeft, so when the cursor left the window
while over a drawable, no further MouseMoved arrived and the hover state simply
froze: on_exit / on_cell_exit never fired, and Grid.hovered_cell kept reporting a
cell the mouse was nowhere near. Any hover-driven affordance (tile highlight,
tooltip, range preview) stayed stranded in its hovered visual until the player
re-entered the window AND crossed a boundary.
Pre-existing gap in the input layer -- it hit Frame.on_exit too. #355 only made it
observable, by giving grids hover callbacks that could get stuck.
The fix reuses the machinery rather than adding a parallel teardown path.
do_mouse_hover's walk becomes dispatch_hover(pos, in_window); a window-leave is
just that walk with hit_allowed=false at the root, which is precisely "the mouse is
over nothing": every hovered drawable takes its ordinary exit path, and none can
enter. So grid cells, frames, and grid overlay children all clear through the code
that already knows how to clear them.
sf::Event::MouseLeft carries no coordinates, so exit callbacks get the last
position seen by do_mouse_hover (the convention the DOM's mouseleave uses). There
is no more recent position, and inventing one would be a lie.
All three backends feed this: desktop SFML emits MouseLeft natively, SDL2Renderer
already translated SDL_WINDOWEVENT_LEAVE to it (and had nobody listening), and
headless reaches it via the new automation.mouseLeave(), which takes no position
for the same reason the event carries none.
Test asserts the cell exit fires, hovered_cell goes None, a redundant leave does
not double-fire, and re-entering the SAME cell fires on_cell_enter again -- that
last one is what distinguishes a real clear from a faked one.
api_surface golden re-baselined: +1 line, automation.mouseLeave.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GridData::views (the N-view registry that replaced owning_view in #359) was only
ever pruned by UIGridView::set_grid and by tp_dealloc -- and tp_dealloc's
unregister is gated on data.use_count() <= 1 (the #251 pattern). When the C++
view outlives its Python wrapper, which is the normal case because the scene
holds the last shared_ptr, that guard skips the unregister and the view's entry
survives its own destruction as an expired weak_ptr.
Entries therefore accumulated for the lifetime of a GridData, and markDirty() /
markCompositeDirty() lock() every one of them on every entity move. Not a
correctness bug (expired weak_ptrs lock to null and are skipped) but an unbounded
leak on a hot path.
~UIGridView is the only place guaranteed to run exactly once per view, so the
unregister belongs there. unregisterView already prunes expired entries, so no
other change is needed.
NO REGRESSION TEST: GridData::views has no Python observable, so the registry's
size cannot be asserted from a test script. Adding one (e.g. a read-only
GridData.view_count) is deliberately deferred to #361, which makes GridData a
public type and reworks this area; the fix is landed now because the leak is on
a hot path and the one-liner is unambiguous. Full suite green (324/324).
closes#362
#355 made last_clicked_cell the channel by which click_at hands a resolved cell
to dispatchCellClick (the point cannot be recomputed later -- click_at works in
parent-local space, PyScene only has global coords). Two gaps let a cell survive
into a dispatch it did not belong to:
1. click_at stashed the cell at step 3 even when it then returned nullptr because
the view had no handlers. dispatchCellClick only runs on the drawable PyScene
got back, so nothing consumed that stash and the cell lingered indefinitely.
2. the no-grid_data early return hands back `this` (when click_callable ||
is_python_subclass) without clearing the stash.
Together: click a cell on a handler-less grid, then enable interactivity and set
grid_data = None, then click the now cell-less view -- and on_cell_click fired
with a cell from a grid that no longer exists. A wrong-data dispatch, not a
missing one.
Now the cell is stashed only when the view is actually returned as the click
target, and the dataless path clears it.
NOTE: #365 as filed described the repro as "click a cell, set grid = None, click
again," which does NOT reproduce -- dispatchCellClick consumes last_clicked_cell
(nullopt on read), so a cell that was actually dispatched cannot survive. The
unconsumed handler-less stash is the real path; the regression test covers it,
plus a guard that a click on no cell leaves nothing behind.
closes#365
UICollection::repr tallied contents by derived_type(), feeding its "Grid"
bucket from PyObjectsEnum::UIGRID and routing UIGRIDVIEW into other_count.
Since #252 every mcrfpy.Grid in a scene graph is a UIGRIDVIEW, so grids were
never named: repr(scene.children) reported "1 Frame, 2 UIDrawables".
Dispatch on the asGridData() virtual (added in #355) rather than the enum,
which covers both the view and the internal _GridData type.
This was the last dead derived_type()==UIGRID gate in a user-visible path --
the same bug class #355 was opened to eliminate. One live UIGRID arm remains
in UIDrawable::removeFromParent, but it is not dead (grid children genuinely
do get parent = the internal UIGrid today) and it is redundant rather than
broken; it disappears with #361.
closes#366
Grid children are positioned in the grid's pixel-world coordinates — the same
origin as Entity positions — while Frame children are frame-local. Neither was
documented, so the difference read as an inconsistency (it surfaced during #355,
which had to decide whether to preserve it).
It is not an inconsistency. A Frame has no camera and cannot pan its content, so
frame-local is effectively screen space; a Grid does have a camera, and its
children are deliberately anchored to world content, not to the viewport. Testing
grid children at their visible coordinates would break all positioning the moment
the camera moved. The use case is diegetic UI — speech bubbles, damage numbers,
range indicators — that belongs to a place or an entity in the world.
Documents the convention on both Grid.children and Frame.children (each pointing
at the other), and adds docs/grid-coordinate-spaces.md with the overlay pattern
for screen-space UI over a grid: a sibling Frame matching the Grid's pos/size,
rather than Grid.children.
Note: docs/ is gitignored wholesale with tracked files force-added, so the guide
needed `git add -f` — it would otherwise have been silently dropped.
closes#360
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ImGuiSceneExplorer::renderDrawableNode switched on derived_type() with a UIGRID
arm that nothing in a scene graph has matched since #252; UIGRIDVIEW fell through
to `default: break`, so a Grid node in the explorer tree could not be expanded to
reveal its children or entities.
Uses UIDrawable::asGridData() (#355) rather than adding a UIGRIDVIEW enum arm.
Debug tooling only — but the explorer's blindness here is part of why the whole
#355 family stayed invisible for so long.
closes#358
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
McRFPy_API::find_in_collection gated its descent into a grid's entities and
overlay children on derived_type() == PyObjectsEnum::UIGRID — dead since #252,
because every mcrfpy.Grid in a scene graph is a UIGRIDVIEW. Named entities and
named child drawables inside a Grid were simply unfindable.
Uses the UIDrawable::asGridData() virtual added by #355 rather than adding a
UIGRIDVIEW arm next to the dead UIGRID one, which would only relocate the bug.
Two defects found in review of the now-reachable code:
- find_in_grid_entities built Entity wrappers with a raw tp_alloc, bypassing
the PythonObjectCache lookup every other entity accessor performs. When the
duplicate wrapper was deallocated, PyUIEntityType's tp_dealloc unconditionally
ran `self->data->pyobject = nullptr`, dropping the entity's #266 strong
identity reference without a DECREF. It now consults the cache first, exactly
as UIEntityCollection::getitem does, so find('hero') returns the user's Hero
instance rather than a base Entity.
- With N views sharing one GridData (the split-screen/minimap case), the
asGridData() recursion visited the shared data once per view, so findAll()
returned every grid entity and child N times. find_in_collection now threads
a visited-GridData set.
closes#357
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes#355closes#359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rendered/generated documentation no longer lives in git -- it's regenerated
by a pre-commit hook (tools/hooks/pre-commit, installed via `make
install-hooks`) and shipped inside release artifacts (tools/package.sh) instead.
A compact api/manifest.json (signatures + docstring hashes + carried-forward
lifecycle metadata: since/modified per object and member) is the one exception
and stays committed, refreshed by the same hook, so API changes remain
trackable by commit/tag via `git show <ref>:api/manifest.json` and
`tools/api_delta.py REF1 REF2` without needing a full rebuild at old refs.
- tools/generate_api_manifest.py: engine-side introspection, run headless;
emits api/manifest.json (committed) and docs/generated/api_full.json
(gitignored, full docstrings -- the docs-site generator's data source).
- tools/api_delta.py: stdlib-only diff CLI (md/json/gitea-checklist formats),
with --site-dir to cross-reference affected docs-site pages via their mcrf
frontmatter.
- tools/hooks/pre-commit: incremental build + frozen-docstring gate + doc
regen + manifest refresh, gated on staged src/ changes.
- .gitignore covers the rendered docs/stubs plus personal working-notes docs
(plans, audits, research write-ups) that were never McRogueFace-user-facing.
- docs/api-stability.md and docs/threading-model.md removed: their content is
now the wiki's "API Stability Contract" and "Threading Model" system pages
(ratified 2026-07-02, migration verified 2026-07-11); the repo continues to
enforce them via tests/unit/api_surface_snapshot_test.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>