fix(bindings): pin a subclassed drawable's wrapper while a collection owns it

PythonObjectCache holds weakrefs, so #369 preserved object identity only
while Python still held a reference to the wrapper. Drop that reference
while C++ still owns the object, and the wrapper is collected; the next
.parent / find() / indexing lookup misses the cache and allocates a fresh
BASE-type wrapper. The subclass, and every attribute the user set on it,
are silently gone:

    del parent; gc.collect()
    type(kid.parent)   # mcrfpy.Frame, not MyFrame
    kid.parent.hp      # AttributeError

Entities already solve this with a strong ref from the C++ object back to
its wrapper, pinned for as long as the entity is in a grid (#266). #373
noted drawables had no equivalent boundary. They do: membership in a
children collection. It is already represented by the parent link, and
setParent()/setParentScene() are the choke points every collection mutator
goes through -- so that is where the pin is taken and dropped.

Only subclassed drawables are pinned. A base-type wrapper carries no state
of its own (the types have no __dict__), so re-creating one on a cache miss
is unobservable, and pinning every drawable ever created would cost memory
for nothing.

The pin is a reference cycle (wrapper -> shared_ptr -> drawable -> wrapper)
that Python's GC cannot see through the C++ side, so it must be released on
the way out or the object leaks forever. Every exit is covered:

  * unlinking calls setParent(nullptr), which drops the pin;
  * removeFromParent() re-evaluates it as its LAST statement, so releasing
    the last reference to the wrapper -- whose dealloc drops the wrapper's
    shared_ptr to the drawable -- cannot touch a freed `this`;
  * an owner destroyed while it still holds children releases their pins
    first (releaseChildPins), since nothing unlinks them on that path: the
    vector simply dies. Without it the whole subtree would be stranded.

Scene gains the virtual destructor it needed for that release -- it was a
polymorphic base that GameEngine deletes through a Scene*, so it needed one
regardless.

Depends on #377: the pin keys off the parent link, which four of six
collection mutators did not maintain.

closes #373

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-14 07:53:42 -04:00
commit 55968d4c09
8 changed files with 296 additions and 1 deletions

File diff suppressed because one or more lines are too long