Commit graph

242 commits

Author SHA1 Message Date
55968d4c09 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>
2026-07-14 07:53:42 -04:00
53234288ad fix(ui): maintain the parent link in every collection mutator
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>
2026-07-14 07:53:21 -04:00
112f3571f5 test(suite): unrot 82 tests that were passing without ever running
The suite was reporting 331/331 while at least 82 of those tests asserted
nothing at all.

They raised during setup on APIs removed long ago -- add_layer(name=...),
GridPoint.color, mcrfpy.Animation(), entity.gridstate, mcrfpy.setScene,
assets/kenney_ice.png, GridData.compute_astar -- registered no timers, hit the
engine's auto-exit-when-no-timers path, exited 0, and were scored PASS. Their
assertions had not executed in months. test_metrics.py is the sharpest example:
the existing metrics test died on line 140 with a TypeError, which is precisely
why #341 (get_metrics counters reading 0) went unnoticed.

The engine no longer permits this (#350: a headless --exec script must call
sys.exit()), and run_tests.py no longer passes a test whose output contains a
Traceback. This commit repairs the 82 they exposed, migrating each to the
current API while preserving its original intent -- not deleting assertions to
make the command exit 0. Each repair was adversarially re-verified by a second
pass asking "is this still a test, or was it gutted?"; none were.

Two tests could not be made to pass because they were right and the engine was
wrong. Rather than paper over them they were left failing and the bugs fixed
separately in 48eef0b: DijkstraMap path order (#375) and layer-setter cache
invalidation (#376). Three integration tests had encoded the reversed Dijkstra
order as expected behavior; their assertions now state the real contract
(excludes the origin, ends at the root).

Suite: 334/334, every one of them actually asserting.

Refs #341, #350, #372
2026-07-14 07:29:23 -04:00
bd0b913d7d fix(demos): unrot tests/demo and gate it in CI
tests/demo/demo_main.py died immediately on `mcrfpy.sceneUI`, removed with the
pre-Scene-object API. Four bugs, not the one filed:

  1. screens/base.py called mcrfpy.sceneUI(name). The Scene object it had already
     constructed owns the UI collection: scene.children.
  2. demo_main.py called the removed mcrfpy.setScene().
  3. run_interactive() referenced `menu`, a local of create_menu() -- NameError on
     every interactive launch, independent of the sceneUI bug.
  4. run_headless() assigned a DemoScreen (not a Scene) to mcrfpy.current_scene ->
     TypeError, so the headless screenshot path was broken too.

The screens themselves had rotted the same way as the unit suite: add_layer() no
longer takes keyword arguments or a name (it takes a layer object), layer.set()
takes a position tuple, and mcrfpy.Animation is no longer exported (targets animate
themselves). Fixed in grid_demo and animation_demo, including the code samples the
demos display -- the demos are documentation, so a stale sample is a stale doc.

The headless path also no longer needs a Timer at all. Rendering costs zero
simulation time (#341/#350), so it just activates each scene and screenshots it,
rather than waiting for frames that headless never runs.

Six showcase scripts wrote to a hardcoded /opt/goblincorps/... absolute path outside
the repo, so they raised PermissionError for anyone else. Replaced with
tests/demo/docs_output.py: $MCRF_DOCS_REPO, else a side-by-side mcrogueface.github.io
checkout, else tests/demo/screenshots/.

The point of the issue was the gate, so: run_tests.py now runs demo_main.py as a
smoke test. Verified it catches the original bug -- reintroducing the sceneUI call
turns the demo suite red. CLAUDE.md tells newcomers to read tests/demo/screens/ for
correct API usage; nothing was checking that it ran.

The 19 standalone showcase scripts under screens/ are not on the demo_main path and
are not adopted here; follow-up issue to come.

closes #372
2026-07-14 06:46:03 -04:00
30abb0b7ec fix(engine): headless step() is a full simulation frame; --exec can no longer hang
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
2026-07-14 06:37:52 -04:00
c72ab22999 fix(metrics): report counters Python can actually observe
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
2026-07-14 06:37:33 -04:00
464af6a549 fix(bindings): make dynamic module attributes dir()-discoverable
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
2026-07-13 23:47:04 -04:00
ce15469ffc fix(bindings): route every drawable wrapper through PythonObjectCache
`.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
2026-07-13 23:37:01 -04:00
9d67e33370 fix(render): content invalidation propagates again; clear hover on focus loss
closes #368
closes #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>
2026-07-13 07:40:20 -04:00
c696b4ef41 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>
2026-07-13 07:14:43 -04:00
3e18c1fbf6 fix(grid): overlay children belong to the GridView, not the shared GridData
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
2026-07-12 23:06:59 -04:00
4bb1966b97 fix(input): dispatch hover-exit when the cursor leaves the window
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>
2026-07-12 21:12:58 -04:00
11f8394f9b fix(grid): never dispatch a stale cell from UIGridView::click_at
#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
2026-07-12 20:26:59 -04:00
7952d25cad fix(grid): UICollection.repr names Grids again instead of "UIDrawable"
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
2026-07-12 20:26:32 -04:00
086c886d70 fix(grid): find()/findAll() descend into grids again
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>
2026-07-12 18:02:04 -04:00
85e4bbec07 fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
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 #355
closes #359

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
54624b353d Untrack generated docs; add API manifest infra and pre-commit hook
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>
2026-07-11 20:57:31 -04:00
f68a770346 feat(bench): Gauntlet OOM safety guards + The Crucible wall-clock benchmark; closes #353 closes #354
The Gauntlet's auto-ramp had a step ceiling but no memory ceiling. GRID TITAN
ramps grid side S with cost ~ S*S, but renders into a fixed viewport so frame
time can stay under budget while allocation runs away -- observed hard-locking
the desktop mid grid-allocation (all RAM consumed, forced logout).

#353 -- memory safety (defense in depth, weakest failure mode last):
  * tests/benchmarks/gauntlet/safety.py: rss_mb/vmsize_mb probes +
    install_address_space_cap() (RLIMIT_AS backstop; a runaway Grid() then
    aborts THIS process via std::bad_alloc instead of taking the machine down).
  * Trial.predict_bytes(load) + Trial.max_load: a trial refuses a load whose
    predicted footprint exceeds a 512 MB budget BEFORE allocating it. GRID
    TITAN implements both (S*S*~28 B; max_load=4300 ~= 494 MB).
  * RampController: pre-allocation predict/cap check + post-set_load RSS
    watchdog; records stop_reason (budget/hard_cap/max_load/mem_predict/
    mem_rss/max_steps). run_gauntlet.py installs the address-space cap at start.
  * tests/unit/gauntlet_safety_test.py proves the ramp bails on the memory
    guards before over-allocating (synthetic runaway trial records the largest
    load it was asked to build).

#354 -- The Crucible (tests/benchmarks/crucible.py): a headless, deterministic
wall-clock microbenchmark of fixed "comically extreme but tractable" configs
(grid alloc, cell fill, layer writes, turn-manager swarm, FOV, A*, entity
churn). Safe (<512 MB, ~seconds), display-free, cross-version (missing APIs
report "unsupported"). Emits JSON; MCRF_CRUCIBLE_BASELINE diffs two builds.

This is the safe replacement for the windowed-Gauntlet A/B that crashed the
desktop. First result, current master vs the 0.2.8 release artifact (both
headless): grid_alloc -58.9%, grid_fill -25.4%, layer_fill -56.1%,
entity_churn -43.0%, fov_storm -10.3%, step_swarm -6.2%, path_queries -3.2%;
geomean 0.673 (32.7% faster overall), peak RSS ~103 vs ~119 MB. Tracks the
#332 SoA rewrite + #329 entity indexing.

Suite 314/314.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:28:13 -04:00
6b99a1b346 fix(grid): turn manager invalidates view render cache on entity move; addresses #351
The #351 UIGridView render early-out re-blits a cached texture while
GridData.content_generation is unchanged. Entity moves through the Python
setters bump content_generation, but the C++ turn manager (Grid.step())
mutated entity->cell_position / entity->position directly and never
invalidated the view -- so entities pathfinding via step() did not visibly
move on screen (a dirty-flag regression in the #351 optimization).

step() now tracks whether any entity moved across all rounds and, once at the
end, calls grid->GridData::markCompositeDirty(). The explicit GridData::
qualification is required: UIGrid's `using UIDrawable::markCompositeDirty`
otherwise shadows the data-layer override and skips both the
content_generation bump and the owning_view notification.

Also removed dead code in the MOVED branch (identical if/else on move_speed).

Regression test tests/regression/issue_351_step_movement_render_test.py drives
a WAYPOINT entity via step() and asserts the rendered frame changes; verified
A/B (fails pre-fix: moved (1,1)->(2,2) but frame identical; passes post-fix).
Suite 313/313.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:15:36 -04:00
ad223b1ac0 feat(grid): numpy buffer views for ColorLayer/TileLayer via layer.edit(); closes #335
Implements the #328 bulk-edit convention for grid layers. Writable zero-copy
views of layer data are exposed ONLY through a context manager:

    with layer.edit() as view:        # ColorLayer -> (h, w, 4) uint8
        np.asarray(view)[...] = ...    # TileLayer  -> (h, w) int32 (-1 = no tile)
    # on __exit__ the whole layer is conservatively invalidated (markDirty),
    # so the edit re-renders.

Both layers already stored dense, contiguous, row-major data (std::vector<
sf::Color> / std::vector<int>; render caches chunk separately), so the views are
true zero-copy aliases. A new internal _LayerEdit type is the buffer exporter
(bf_getbuffer) and the context manager (__enter__ returns memoryview(self),
__exit__ calls GridLayer::markDirty()); it is not directly instantiable. The
conservative whole-layer invalidation on exit means no change-tracking is
needed, and it composes with the #351 render early-out (markDirty bumps
content_generation).

Independent of GridData SoA (#332): these are the render layers, not the logic
grid. A contiguous walkable/transparent view now has its storage prerequisite
from #332 but remains a separate follow-up.

Regression test issue_335_layer_edit_test.py: buffer shape/format/readonly,
zero-copy writeback both directions, the -1 tile sentinel, optional numpy
aliasing, and a screenshot check that __exit__ actually re-renders. API-surface
golden re-baselined (two new edit methods); docs/stubs regenerated; frozen
docstring gate 100%. Suite 312/312.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:04:42 -04:00
2ceb18e673 refactor(grid): GridData SoA -- dense uint8 planes for walkable/transparent; closes #332
Cell logic storage was an array-of-UIGridPoint: 24 bytes/cell for 2 bytes of
real payload (walkable/transparent bools), the other 22 being redundant grid_x/
grid_y and a parent_grid back-pointer. Grids larger than 64 in either dimension
switched to non-contiguous 64x64 chunk storage, so no whole-grid contiguous
view was possible at any size.

Replaced with two dense row-major std::vector<uint8_t> planes on GridData
(walkable_plane, transparent_plane), indexed y*grid_w + x, with inline
is/setWalkable / is/setTransparent accessors. Chunking is dropped for LOGIC data
entirely (render caches still chunk independently in GridLayers) -- so cells are
contiguous at any size. Result: 12x smaller logic layer (2 vs 24 B/cell) and
cache-friendly iteration; a 1024x1024 grid's logic data is 2 MB instead of 24 MB.

The Python GridPoint wrapper already held (grid, x, y) and re-resolved per
access, so it just calls the new accessors -- low-invasiveness. GridData::at()
-> UIGridPoint& is removed; its callers convert to accessors:
- UIGridPoint get/set_bool_member + repr (the wrapper)
- UIGridPyMethods apply-threshold / apply-ranges heightmap writers
- syncTCODMap / syncTCODMapCell (read planes)
- EntityBehavior::isCellWalkable and PathProvider::cellWalkable (pathfinding --
  both were missed by the first scout; found via exhaustive grep)
- UITestScene

UIGridPoint's per-cell data members + ctor removed (class now only namespaces
the Python type's accessors). GridChunk.h/.cpp and ChunkManager deleted (dead
after logic-chunking removal; nothing else referenced them). No walkable/
transparent serialization existed, so no format impact.

Unblocks #333 (TCOD map dedup) and a future contiguous numpy view of
walkable/transparent.

Regression test issue_332_soa_storage_test.py: round-trips walkable/transparent
on a 200x160 grid at and across the old 64-cell chunk boundary, default values,
toggle-off, subscript vs at() agreement, grid_pos/entities, and a per-cell
footprint guard. Suite 311/311 (FOV + pathfinding + heightmap unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:43:30 -04:00
5b2a412c21 perf(grid): lazily allocate rotationTexture; footprint measurement; addresses #338
Safe subset of the #338 drawable memory diet. UIGrid and UIGridView each
embedded an sf::RenderTexture rotationTexture value member paid by every grid,
though only camera-rotated grids ever use it. Made it a unique_ptr allocated on
first rotation, so non-rotating grids (the common case) don't carry it.

Measured per-drawable resident cost (issue_338_drawable_footprint_test.py, 5000
instances): Frame ~551 B, Caption ~1135 B (the embedded sf::Text). At hundreds
of drawables that is tens of KB total, confirming the issue's own note. The
larger render_sprite base-class relocation is therefore DEFERRED -- it would
touch every render path, which the issue explicitly says to avoid, for a payoff
that does not justify the risk. Left open for that remaining part.

Suite 310/310 (rotation tests green through the lazy path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:31:18 -04:00
8af3bf45b6 feat(discretemap): buffer protocol for zero-copy numpy views; closes #334
DiscreteMap now implements tp_as_buffer/bf_getbuffer, exposing its dense uint8
storage as a 2-D (height, width) C-contiguous writable view. memoryview(dmap)
and np.asarray(dmap) share memory with the map -- no copy -- so per-entity FOV
memory (#294 perspective_map) and other DiscreteMaps can be inspected/edited
with numpy at native speed.

The exporter owns the buffer via its shared_ptr<DiscreteMap>; getbuffer INCREFs
it for the view's lifetime (balanced by the default PyBuffer_Release DECREF, so
no bf_releasebuffer). shape/strides are stored on the object -- dimensions are
immutable after construction, so concurrent views safely share them.

Independent of the GridData SoA work (#332): DiscreteMap already owned a dense
contiguous uint8_t* with no libtcod coupling.

Regression test issue_334_discretemap_buffer_test.py: shape/format/strides,
row-major [x,y]->[y,x] mapping, zero-copy writeback both directions, optional
numpy aliasing (skipped when not bundled), and a buffer over a live entity
perspective_map. Suite 309/309.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:00:53 -04:00
0c61c70ccd perf(grid): clean-state render early-out for UIGridView; closes #351
UIGridView::render() re-rasterized its RenderTexture (clear + all layer draws +
entity draws + display) every frame unconditionally -- the #255 early-out and
markCompositeDirty() machinery existed only on the superseded UIGrid::render()
path, dead since the #252 GridView shim. Idle grids paid a full re-raster per
frame for nothing.

Fix: a clean-state early-out that re-blits the cached texture when nothing
affecting the raster changed. Two signals, chosen so a missed invalidation
can't silently produce a stale frame:
- Camera params (center_x/y, zoom, camera_rotation, box size, fill_color,
  render-tex size) are compared directly on the view -- impossible to forget.
- Grid content changes bump a new GridData::content_generation counter at the
  existing chokes: GridData::markDirty/markCompositeDirty (entities, animation),
  GridLayer::markDirty (all tile/color/texture edits), layer add/remove.

Also fixes a latent bug: the Python entity setters set_position and
set_spritenumber updated the spatial hash but never invalidated rendering
(masked only because the view redrew every frame). They, and the entity
add/remove/die sites, now mark the grid dirty.

Perspective overlays (checked authoritatively on the UIGrid, since the view's
copy isn't resynced at runtime) and grid children are conservative
always-render carve-outs -- tracked precisely later in #352.

Regression test issue_351_gridview_early_out_test.py screenshots before/after
each mutation kind (entity move via set_position and via animation, tile edit,
color edit, entity add/die, camera pan, zoom) and asserts the pixels changed --
guarding against false early-outs -- plus an idle pair that must stay identical.
Suite 308/308.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:55:32 -04:00
73bed0f7a5 perf(input): cache Key/InputState enum members per event; closes #344
Every keyboard event rebuilt its Key and InputState IntEnum members via
EnumMeta.__call__ (PyObject_CallFunction), running the full Python enum
lookup machinery on each dispatch. PyKey::get_enum_member and
PyInputState::get_enum_member now memoize members (unordered_map<int,
PyObject*>, strong ref held for the interpreter lifetime) and return a new
reference, replacing the construction at the call sites in call_on_key
(PySceneObject) and the legacy key callable (PyCallable).

Callgrind A/B (tests/benchmarks/sprint_perf_baseline.py, 6k key events):
  enum-ctor PyObject_CallFunction self Ir: 19.18M -> 0.228M  (-99%)
Wall-clock (median/3): 665 ms -> 454 ms  (-31%)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:59:47 -04:00
5bbffe283a perf(animation): scalar-float fast path skipping variant visits; closes #342
The animation hot path re-dispatched the AnimationValue std::variant twice
per frame -- once in interpolate() and once in applyValue() -- via
std::visit. Profiling showed this __do_visit machinery was ~473M Ir (13.4%
of the benchmark), by far the largest reducible animation cost.

(The originally-hypothesized culprits were disproven by the profiler: the
setProperty strcmp cascade is cold -- std::string == const char* short-
circuits on length, ~0.15%/branch -- and an attempt to cut update()'s
weak_ptr triple-lock was a wash, since locking an empty weak_ptr touches no
control block or atomics.)

Scalar-float animations (x, y, w, h, opacity, rotation, color channels...)
are the overwhelmingly common case. A single flag set at construction
(isSimpleFloatAnim) lets update() interpolate the float directly and call
setProperty(float), bypassing both std::visit dispatches. startValue is
always float-holding for a float targetValue, so the fast path is safe.

Callgrind A/B (tests/benchmarks/sprint_perf_baseline.py):
  Animation::update inclusive Ir: 1,479,186,800 -> 1,207,377,600  (-18%)
  variant __do_visit self Ir (anim): 473.2M -> 98.8M  (-79%)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:59:28 -04:00
0e4b36f42a perf(grid): cache internal Grid wrapper in GridView; closes #348
UIGridView::get_grid() allocated a throwaway UIGrid wrapper + weakref on
every access. Because it cached only a weakref to a temporary that the
caller immediately dropped, the cache hit rate was 0% -- every grid.at(),
grid.entities, layer write, or per-cell procgen access re-allocated. The
GridView now holds one persistent strong ref to its internal Grid wrapper
for its lifetime (invalidated in set_grid and tp_clear, released in
~UIGridView under a Py_IsInitialized guard, and visited in tp_traverse).

No ownership cycle: the wrapper aliases the UIGrid control block, which is
a different object/control block than the view's own PyObject.

Callgrind A/B (tests/benchmarks/sprint_perf_baseline.py, 72k grid ops):
  get_grid inclusive Ir: 72,575,645 -> 2,089,926  (-97%)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:59:17 -04:00
f0a44fe3e3 docs: durable sprint plan + baseline harness for perf batch #342-#348
- tests/benchmarks/sprint_perf_baseline.py: one harness, four isolated stress
  scenarios (anim-dispatch #342, scene-update #343, key-dispatch #344,
  grid-churn #348), wall-clock timed, step()-only/pure-loop (no screenshots).
  Doubles as a Callgrind target for per-function instruction counts.
- docs/sprint-perf-342-348.md: durable plan with pre-fix baselines captured
  (wall-clock median/3 + Callgrind self-Ir per target function), the A/B
  re-measurement protocol, per-issue root cause / fix direction / files /
  acceptance / risk, suggested order (#348, #342, then #343/#344), and DoD.

Baselines (Callgrind self-Ir, harness total 3.52B Ir): setProperty 158.6M
(4.50%, #342); get_grid 72.5M (2.06%, 0% cache hit, #348); enum-ctor 19.18M
over 6000 calls (#344); per-step update GetAttrString ~3.4k Ir/call (#343).

Refs #342, #343, #344, #345, #348.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:00:51 -04:00
ef0408e06e docs(profiling): document screenshot/PNG trap; add full-loop workload
- docs/profiling.md: new "Profiling the render path" section — headless
  step() doesn't render; automation.screenshot() forces a render but PNG
  encoding then dominates (~96% of instructions in libsfml stbi_zlib_compress),
  burying engine work. Guidance: profile update/animation with step()-only,
  subtract stbi_* for the render path. Alludes to the Hybrid Scene
  Serialization wiki proposal (QOI + UI-tree serialization) as a faster
  capture path that would also de-noise screenshot-driven profiling.
- tests/benchmarks/profile_workload.py: reusable full-loop driver (textured
  grid + moving entities + animated nested UI, forced renders). This is the
  workload that surfaced #348.

Refs #345, #348.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:54:39 -04:00
182da6230d Fast path for hot property getters: stop importing mcrfpy per call; closes #331
Replace the PyImport_ImportModule("mcrfpy") + PyObject_GetAttrString +
full type-call ceremony with direct &mcrfpydef::PyXType references
(safe since all PyTypeObjects are inline C++17 definitions) in every
non-init call site:

- UIDrawable: get_pos/get_origin/get_global_pos now allocate via
  PyVector(...).pyObject(); set_pos/set_origin check the Vector type
  directly.
- GridLayers: ColorLayer at/subscript wrap via PyColor(...).pyObject()
  (also fixes a module refcount leak per read); set/fill/fill_rect,
  draw_fov/apply_perspective color parsing, Entity/Texture/HeightMap
  instance checks, and TileLayer texture get/set use direct type refs.
- UIGrid init layers=, add_layer/remove_layer/layer(), Grid.layers
  getter: direct ColorLayer/TileLayer type refs.
- PyTileSetFile.to_texture, PyLdtkProject.tileset: direct type refs.

Benchmark (tests/benchmarks/issue_331_property_read_bench.py, 200k
reads, release build):
  frame.pos              559 ns -> 65 ns/read  (8.6x)
  frame.origin           571 ns -> 64 ns/read  (8.9x)
  frame.global_position  589 ns -> 66 ns/read  (8.9x)
  ColorLayer.at          608 ns -> 262 ns/read (2.3x)

Test suite: 304/304 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK5DHgRDNGmpN54vnEHAot
2026-07-08 21:58:50 -04:00
506ad57cc6 Gauntlet #340: menu layout polish; commit first captured baseline; closes #340
Menu rows no longer clip at the right edge (baseline text shortened, info column
moved left). Commit the first real windowed baseline (run of 2026-07-02 on the
dev machine, all six trials scored, full get_metrics snapshots at peak) with a
provenance/variance caveat recorded as DESIGN.md deviation 10 -- scores for
callback-heavy trials vary several-fold on a busy desktop, so recapture on a
quiet system before trusting fine-grained deltas.

The Gauntlet delivers: interactive menu/trial/results scenes with the
instrument-panel HUD (sparkline, budget-colored FPS, load/ramp readout), six
subsystem stress trials with geometric auto-ramp and p95-vs-16.67ms scoring,
JSON baseline diffing with letter grades, and a headless suite test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
1aa6624758 Gauntlet #340: strike-based hard-cap bail (3 over-cap samples per window)
Three full windowed runs showed 10x-100x per-trial score variance: a single
stray desktop frame >100 ms (compositor/GC hiccup) instantly zeroed trials
whose held p95 was 6-9 ms. Require 3 over-cap samples in a hold window before
bailing -- a truly overloaded engine trips that within ~50 ms of wall time,
while lone hiccups now just count toward the window's p95. Documented as
DESIGN.md deviations 8 and 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
d651a86776 Gauntlet #340: fix first-run baseline self-compare; pathfinder ramp start
First full run exposed two issues: (1) write_run promotes baseline.json before
the results scene loads it, so a first run compared against itself and showed
'= same' instead of FIRST RUN -- detect via the _promoted_baseline flag;
(2) pathfinder_rush started at 5 queries/tick, but queries burst on one 100 ms
tick frame, so the very first hold window failed (p95 38.9 ms) and scored 0 --
start the ramp at 1 query/tick so the sustainable level is actually found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
23b02b87ea Gauntlet #340: menu + results scenes, baseline IO, run driver
gauntlet_main.py: interactive app (menu with color-cycling title + baselines,
per-trial scenes, manual/-+/auto-ramp controls, results table with vs-baseline
deltas and letter grades). baseline_io.py: latest/baseline JSON read/write/compare
with geomean scoring. run_gauntlet.py: unattended full auto-ramp run that writes
the baseline and screenshots the results scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
b20903b9ed Gauntlet #340: shared HUD overlay + entity-swarm motion
Instrument-panel HUD: trial banner, 60-bar frame-time sparkline with 16.7 ms
hairline, and FPS / p95 / draw-calls / load readout (budget-colored). Give swarm
entities a nonzero move_speed so they spread visually instead of piling on target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
9f371bccd3 Gauntlet #340: headless trials smoke test
setup / set_load (two levels) / tick / teardown per trial with count assertions;
suite-friendly, no rendering. Passes headless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
859730f02d Gauntlet #340: spec, package skeleton, scoring, six trials
Add the DESIGN.md (spec copy + implementation deviations), the trials package
(Trial base + ordered registry + six stress trials: entity swarm, animation
storm, grid titan, pathfinder rush, ui avalanche, sightline siege), and the
RampController/scoring logic. All pure Python under tests/benchmarks/gauntlet/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:22 -04:00
dabca794ed Add tests pinning EntityCollection semantics and O(1) indexing (#329)
Pre-change TDD tests for the std::list -> std::vector container swap:

- tests/unit/entity_collection_mutation_test.py pins observable behavior:
  positive/negative indexing, out-of-range IndexError, slicing (returns a
  fresh list; slice wrappers are not cache-identity), in-order iteration,
  RuntimeError on size change during iteration, and grid.step() immunity to
  mid-step entity self-removal.
- tests/regression/issue_329_entity_index_perf_test.py guards against a
  regression to O(n) indexed access / O(n^2) full-scan.

Both pass against the pre-swap std::list implementation, locking in the
public contract before the internal container change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:13:18 -04:00
9298aea072 Reject non-finite floats before int casts; closes #323, closes #324, closes #325
Three Python bindings cast user-supplied doubles to narrower integer types
without checking for NaN/inf, which is undefined behavior (UBSan aborts):

  #323 SoundBuffer.pitch_shift: factor only rejected <= 0.0, but every NaN
       comparison is false, so a NaN factor reached
       static_cast<size_t>(frames / factor) (AudioEffects.cpp:27,
       nan->unsigned long). Binding now rejects non-finite factors with
       ValueError; pitchShift() guard hardened as defense-in-depth.

  #324 Texture.hsl_shift: NaN/inf hue/sat/lit shifts propagated to NaN r/g/b
       and the (sf::Uint8)(c * 255.0f) casts (PyTexture.cpp:458,
       nan->unsigned char). Binding now rejects non-finite shifts with
       ValueError before any pixel math.

  #325 Vector.int: (long)std::floor(component) on inf/NaN, or a finite value
       outside [LONG_MIN, LONG_MAX] (PyVector.cpp:610, inf->long). Property
       now range-checks both components and raises OverflowError.

Verified by A/B replay of the #312 audio_dsp / texture_factory /
property_types crash corpus under clang-18 UBSan/ASan: each input aborts
pre-fix at the exact cited line and runs clean post-fix. Three new
regression tests cover NaN/inf/out-of-range inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
2026-06-26 23:53:02 -04:00
a2c76c3643 Check Python C-API returns in terrain_enum builders; closes #322
WangSet.terrain_enum (and the parallel AutoRuleSet.terrain_enum) built a
Python IntEnum from parsed terrain/color names but ignored the return codes
of PyLong_FromLong, PyUnicode_FromString, and PyDict_SetItemString. A name
carrying invalid UTF-8 -- trivially reachable from an untrusted .tsx/.ldtk
import -- makes those calls fail and leave an exception pending. The code
then called PyObject_Call with that error already set, tripping CPython's
_PyErr_Occurred assertion and aborting (libFuzzer: deadly signal).

Both functions now check every C-API return, clean up their references, and
return NULL so the real exception (e.g. UnicodeDecodeError) propagates.

Verified by A/B replay of the #312 fuzz_import_parsers crash corpus under
clang-18 ASan/UBSan: the saved crash input aborts pre-fix inside
PyWangSet::terrain_enum -> PyObject_Call and runs clean post-fix. New
regression test crafts a .tsx with invalid-UTF-8 wangset/wangcolor names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
2026-06-26 23:52:38 -04:00
515a53ac17 Polish #315 pathfinding demo: panel backdrops, base layer, fixed layout
Rework pathfinding_demo.py into a clearer visual explainer. Each of the
three panels now has a backdrop frame, a floor/wall base ColorLayer so it
reads as a full grid, a title above, and a short live readout below. The
multi-root FLEE panel gains a faint safety-gradient backdrop and tighter
guard-stepping. Layout is pinned to the fixed 1024x768 headless render
size. Loads clean under --headless --exec.

Addresses #315

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
2026-06-26 20:57:23 -04:00
246ed886db Fold Tier C surface into existing fuzz targets; closes #312
Extends the five existing targets to cover the remaining gaps from #312
without new files:

- property_types     Line/Circle/Arc setters, Scene.children collection ops
                     (index/count/find/insert/slice/pop), module functions
                     find/find_all/bresenham/lock. Benchmark triplet excluded
                     (end_benchmark writes a file per call).
- grid_entity        grid.at / [x,y] / entities_in_radius / center_camera /
                     hovered_cell, and GridPoint named-layer __getattr__/
                     __setattr__.
- pathfinding_behavior  Grid.find_path + full AStarPath (peek/__len__/__bool__/
                     iteration) that path_from didn't reach.
- fov                ColorLayer perspective (apply/update/clear_perspective)
                     and draw_fov.
- maps_procgen       ColorLayer/TileLayer apply_threshold/apply_ranges/
                     apply_gradient from HeightMap sources.

The full instrumented campaign surfaced five new bugs, filed as #321 (HIGH
ColorLayer.draw_fov bad-free), #322 (WangSet.terrain_enum error-pending
abort), #323/#324/#325 (float->int UB in pitch_shift/hsl_shift/Vector). Per
decision, this issue delivers fuzz coverage only; the bugs are tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 16:45:03 -04:00
925699ef0b Add 4 libFuzzer targets for Tier A/B API surface; addresses #312
New targets under tests/fuzz/, wired into Makefile FUZZ_TARGETS, each with a
seed corpus (parser seeds are real fixtures prefixed with a loader selector
byte):

- fuzz_audio_dsp        SoundBuffer factories + 14 DSP effects + concat/mix.
                        Self-contained (CPU sample math, no device).
- fuzz_import_parsers   TileSetFile/TileMapFile/LdtkProject. Loaders take a
                        path, so each iteration writes mutated bytes to a temp
                        file; OSError (IOError) is swallowed as an expected
                        parse-failure outcome.
- fuzz_texture_factory  Texture.from_bytes/composite/hsl_shift byte ingestion.
                        Multiplication-overflow path documented as out of scope
                        (would OOM, not crash cleanly).
- fuzz_shader_bindings  uniforms[] + PropertyBinding/CallableBinding lifetime,
                        target Drawable destroyed mid-flight (#270/#271/#277
                        pattern). Degrades to pure binding-lifetime fuzzing if
                        shaders are unavailable.

All four signature-validated against the live mcrfpy API before running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 16:44:47 -04:00
ab6aecd0b0 Add read-only Caption.font getter; addresses #320
The Caption class docstring listed `font` under its Attributes, but no getter
existed -- the PyUICaptionObject.font slot was GC-managed yet never populated by
init(). Wire it up:
  - init() now stores the supplied Font (incref) or, when none/None was given, a
    wrapper around the engine default font, so the getter reflects what is
    actually rendered rather than returning None.
  - New read-only `font` getset (consistent with Sprite.texture being read-only).

Regenerated API docs/stubs/man page and rebaselined the api-surface snapshot
(one added line: Caption `prop font: Font (ro)`). Frozen docstring gate 100%,
suite 297/297.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 12:18:53 -04:00
5eecb2b2b0 Rewrite stale Animation-ctor unit tests to drawable.animate()
test_animation_raii and test_animation_property_locking called the
mcrfpy.Animation(...) constructor, which was removed from the module export
during the API freeze. The Animation type still exists (returned by
drawable.animate()) and every behavior these tests check is intact:
  - hasValidTarget()/complete()/stop() on the returned handle (weak-target RAII)
  - conflict_mode 'replace'/'queue'/'error' + invalid-mode ValueError (#120)

Ported both to drawable.animate(prop, target, seconds, easing, conflict_mode=).
This file is the suite's only conflict_mode coverage, so it was rewritten rather
than deleted. Durations converted ms -> s. Suite now 297/297.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 12:11:37 -04:00
415ee02438 Fix Caption constructor positional signature to match docstring; closes #320
The frozen docstring advertised Caption(pos, font, text, ...) -- font as the
2nd positional, text the 3rd -- matching the Sprite/Entity convention. But
UICaption::init laid its two positional slots out as (pos, text) with font
keyword-only (format "|Oz$...", kwlist {"pos","text",...}), so:
  - Caption((x,y), None, "text") raised "at most 2 positional arguments"
  - Caption((x,y), "string")     bound the string to text, not font

Reorder the positional-or-keyword slots to (pos, font, text) so the impl
matches its public, #314-locked docstring and its sibling constructors.

Behavior change (audited safe): Caption((x,y), "string") now binds the string
to font (-> TypeError, font must be a Font). Zero live callers use the old
(pos, text) 2-positional form; the legacy (text, x, y) callers in
src/scripts/text_input_widget*.py already fail under the current impl.

Adds tests/regression/issue_320_caption_positional_font_test.py (11 checks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 12:11:27 -04:00
98489a96fd Fix verify-pass code bugs #317/#318/#319
Three small bugs surfaced by the #314 docstring-accuracy verify pass:

#317 automation.scroll() dropped the x of its position argument: scroll()
resolved (x, y) but called injectMouseEvent(MouseWheelScrolled, clicks, y),
passing the scroll amount as x. injectMouseEvent now takes the scroll delta as
its own parameter and scroll() forwards the real x/y.

#318 GridView.texture always returned None (a TODO stub). It now returns a
Texture wrapper sharing the underlying shared_ptr<PyTexture>, mirroring
Grid.texture. (mcrfpy.Grid and mcrfpy.GridView are the same type post-#252, so
this fixes both names.)

#319 Entity.visible_entities(radius=None) raised TypeError: radius was parsed
with the 'i' format code, which rejects None. It now parses radius as an object
and treats None / omitted / -1 as "use the grid's default fov_radius"; a
non-int, non-None radius raises a clear TypeError.

- regression tests for each under tests/regression/
- api_surface snapshot re-baselined (visible_entities signature; texture
  property now Texture | None) and docs/stubs regenerated; frozen docstring
  gate still 100%

closes #317
closes #318
closes #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 10:12:41 -04:00
265425321c Windowed perspective writeback in UIEntity::updateVisibility; closes #316
Clip the demote+promote passes in updateVisibility() to an AABB sized to
fov_radius instead of two full-W*H walks per entity. A prev_fov window cache
demotes last tick's promoted rect (not the current one) so a moving entity
leaves no trailing "ghost vision". On a 1000x1000 grid the Phase 5.2 benchmark's
flat ~25-36 ms/entity writeback overhead collapses to single-digit microseconds
(384x-6577x speedup on the cheap algorithms; below timing noise on the rest).

Adversarial verify caught a regression the happy-path test missed: the
documented from_bytes -> assign -> update_visibility() load/resume path left
permanent ghost-VISIBLE cells, because prev_fov only bounds engine-promoted
cells. Fixed with a one-shot perspective_full_demote_pending flag (full demote
only on the tick after an external assignment; per-turn hot path stays
windowed). Documented the engine-owned demote contract on the perspective_map
property.

- src/UIEntity.cpp/.h: windowed demote/promote, prev_fov cache, demote flag
- src/DiscreteMap.cpp/.h: demoteVisibleRect(x0, y0, x1, y1)
- tests/regression/issue_316_sparse_perspective_test.py: 7-section regression
  (equivalence matrix, radius-0, moving disjoint, trailing-edge, grid resize,
  load/resume assignment, AABB margin lock)
- docs regenerated for the perspective_map docstring change

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 09:40:05 -04:00
eafe65683f F15: correct docstring accuracy from adversarial verify pass (#314)
Follow-up to the macro conversion: an adversarial verify pass (one agent per
file vs. the C++ implementation + stubs) found 62 content issues; the real
ones are fixed here.

Callbacks (centralized):
- on_click: receives (pos, MouseButton, InputState), not str (8 files).
- on_enter/on_exit/on_move (UIBase.h): hover passes only (pos) -- removed the
  fictional button/action args.
- bounds/global_bounds (UIBase.h): mark (tuple, read-only).

Signatures / types:
- Grid.find_path: document heuristic + weight; get_dijkstra_map: document roots;
  compute_fov: FOV | int = FOV.BASIC (not the C constant FOV_BASIC) + Returns;
  at/is_in_fov: document (pos) and (x, y) call forms.
- get_metrics: document all 16 returned dict keys (was 8); bresenham: drop the
  bogus '*' keyword-only separator.
- Nullable defaults typed correctly: BSP seed/size, ColorLayer draw_fov/
  apply_perspective Color|None, Entity.visible_entities radius int=-1 (None is
  rejected by the 'i' parser -> see #319).
- Type-token fixes: GridView.center -> Vector; GridView.texture -> (None,
  read-only) (unimplemented, #318); GridPoint.grid_pos -> (tuple, read-only);
  EntityCollection.find -> Entity | list[Entity] | None; extend RuntimeError;
  UniformCollection.values -> list[float | tuple | None].
- automation: onScreen (x, y) form documented; scroll notes x is ignored (#317).

Also: correct stale AStarPath/DijkstraMap signatures in docs/api-audit-2026-04.md
(the bindings were right, the audit table was outdated). Rebaseline the API
snapshot golden and regenerate docs/stubs.

Code-level bugs surfaced by the pass are filed as #317, #318, #319.

Refs #314, #317, #318, #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:43:47 -04:00
5725a4f035 F15: convert frozen binding docstrings to MCRF_* macros (#314)
Convert 289 raw PyMethodDef/PyGetSetDef docstring slots to MCRF_METHOD /
MCRF_PROPERTY across the 20 frozen (non-3D) binding files, bringing the
frozen surface to 100% macro compliance (check_frozen_docstrings.sh PASS).
Done via a one-agent-per-file workflow gated by validate_file_docstrings.sh
and per-wave build/doc-rebuild checks.

- Adds #include "McRFPy_Doc.h" where missing; fills the lone genuine doc
  gap (UIGrid.at, which was MISSING a doc field in two arrays).
- McRFPy_Doc.h: comment documenting the MCRF_METHOD_DOC comma rule (the
  trap that broke the GridLayers conversion mid-run).
- Rebaseline api_surface golden: property types now resolve to real types
  instead of "Any" (e.g. grid_pos: Vector, on_cell_click: Callable | None),
  and 11 properties correctly flip rw->ro now that their docstrings carry
  "read-only" (collections, grid_size, hovered_cell, texture, view — all
  verified against NULL setter slots).
- Regenerate docs/stubs/man page from the new docstrings.

Module-level functions use MCRF_METHOD(<name>, ...) (expands identically to
the intended MCRF_FUNCTION; the audit's compliance set is METHOD/PROPERTY).
Experimental 3D/Voxel bindings (src/3d/) remain exempt from the freeze.

Pre-existing failures unrelated to this change: test_animation_*,
test_constructor_comprehensive (reference the removed mcrfpy.Animation and
old constructor arity).

Refs #314

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:20:55 -04:00