Commit graph

55 commits

Author SHA1 Message Date
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
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
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
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
2d2c333cd7 Refactor UIEntity::grid to shared_ptr<GridData>; add entity.texture; closes #313
UIEntity now depends on the grid DATA layer only:
- GridData gains cell_width_px/cell_height_px (mirrored from the grid
  texture in UIGrid's 5-arg ctor; texture is write-once) so entity
  tile<->pixel math no longer reaches into rendering (getTexture()).
- GridData gains markDirty()/markCompositeDirty(): set the flag on the
  UIGrid subobject AND notify owning_view, covering both render paths.
  UIGrid disambiguates via 'using UIDrawable::markDirty' so all
  pre-existing UIGrid-receiver calls resolve exactly as before.
- The three Python wrappers that still need the full UIGrid (GridPoint
  from entity.at(), the _GridData fallback in get_grid, find_path's temp
  wrapper) reconstruct it via a single aliasing-downcast helper
  (grid_as_uigrid) that documents the never-independently-allocated
  GridData invariant; init/set_grid simplify (share grid_data directly).
  Removing the casts is deferred to #252.

entity.texture (new, frozen surface +1): thin get/set over the entity's
own UISprite. Entities render with their OWN texture (default_texture
fallback at construction); the grid's texture only determines cell size.
Setter preserves sprite_index; rejects non-Texture (TypeError),
null-data Texture wrappers (ValueError), and deletion.

Adversarial review fixes folded in:
- set_texture/get_texture guard uninitialized Entity wrappers
  (RuntimeError), isinstance errors, and null-data Textures.
- PyUIGridViewType tp_dealloc no longer unconditionally severs
  GridData::owning_view: gated on last-owner (#251 use_count pattern)
  plus owning-view identity. Previously ANY Grid wrapper GC while the
  view lived (e.g. scene.children.append(mcrfpy.Grid(...))) silently
  broke entity.grid -> Grid identity and data-layer dirty notification.

Tests: tests/regression/issue_313_entity_grid_data_test.py (texture
semantics, grid-cell-size invariance, entity.grid identity, #251 gate
survival, GridPoint outliving teardown, review-fix guards, owning_view
survival) + tests/unit/entity_texture_test.py. API snapshot golden
re-baselined: exactly +1 surface line (Entity.texture) + writability
probe flip. Docs/stubs regenerated. Native + Emscripten builds verified.

Known edges recorded in docs/api-audit-2026-04.md: texture read-back is
a fresh wrapper each get (no Texture __eq__); sprite_index not
re-validated against a new atlas. Multi-view markDirty broadcast and
pure-GridData wrappers remain deferred to #252. Addresses #314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:51:22 -04:00
17f2d6e1ef Refactor EntityBehavior SEEK/FLEE to use PathProvider strategy; refs #315
EntityBehavior no longer holds a direct DijkstraMap reference. A new
PathProvider interface has three concrete implementations:

- DijkstraProvider: steps along a (possibly inverted) DijkstraMap. SEEK
  descends a normal map toward roots; FLEE descends an inverted map away
  from threats.
- AStarProvider: follows a pre-computed AStarPath step-by-step.
- TargetProvider: takes a single (x, y) target and picks the Chebyshev
  neighbor closest to it each turn.

Entity.set_behavior() gains a pathfinder= kwarg accepting any of the above
(DijkstraMap, AStarPath, or (x, y) tuple). The old executeSeek/executeFlee
helpers collapse into a single executeProviderStep() that delegates to the
provider.

EntityBehavior.h forward-declares PathProvider so the header stays light.
EntityBehavior::reset() moves out of line to avoid pulling PathProvider
into the header.

New tests: tests/regression/issue_315_path_provider_test.py covers all three
providers driving SEEK, FLEE via inverted DijkstraMap, mid-run pathfinder
swap, and invalid-argument handling. grid_step_bench baseline refreshed
against the new provider dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:19:05 -04:00
f797120d53 Replace UIEntity gridstate with DiscreteMap perspective_map; closes #294
Per-entity FOV memory moves from std::vector<UIGridPointState> (two-bool
visible/discovered pairs) to a 3-state DiscreteMap (0=UNKNOWN, 1=DISCOVERED,
2=VISIBLE), exposed as entity.perspective_map. The invariant
visible-subset-of-discovered becomes structural (single value per cell), and
the map is a live, serializable, first-class object rather than an implicit
internal array.

Changes:
- New DiscreteMap C++ class with shared ownership; PyDiscreteMapObject now
  holds shared_ptr<DiscreteMap>. UIEntity holds the same shared_ptr.
- New mcrfpy.Perspective IntEnum (UNKNOWN/DISCOVERED/VISIBLE), modelled on
  PyInputState.
- entity.perspective_map: lazy-allocated on first access with a grid;
  setter validates size against grid and raises ValueError on mismatch;
  None clears (next access lazy-reallocates fresh).
- updateVisibility() now demotes 2->1 then promotes visible cells to 2.
- entity.at(x, y) returns grid.at(x, y) when VISIBLE, else None.
- Fog-of-war rendering in UIGridView and UIGrid reads the 3-state map.
- Removed: UIEntity::gridstate, ensureGridstate(), entity.gridstate getter,
  UIGridPointState struct + PyUIGridPointStateType.
- Obsolete tests deleted (test_gridpointstate_point,
  issue_265_gridpointstate_dangle); 4 new tests cover lazy allocation,
  identity, serialization round-trip, size validation, and the
  visible-subset-of-discovered invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:04:27 -04:00
417fc43325 Bounds-check DijkstraMap coordinates at the Python boundary
DijkstraMap.distance, .path_from, and .step_from forwarded their
coordinate arguments straight into TCOD's dijkstra routines, which
assert and abort() the entire process on out-of-bounds input. No
recoverable Python exception; the whole interpreter dies.

Validate at the binding layer. Out-of-range coordinates raise
IndexError with a message identifying the map dimensions.

Fuzz corpus crash
tests/fuzz/crashes/pathfinding_behavior-crash-b7ea442fd31774b9b16c8ae99c728f609c8c25d8
now runs cleanly.

closes #311

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:32:19 -04:00
19b43ce5fa Route Grid.compute_fov algorithm through PyFOV::from_arg
The compute_fov binding parsed its `algorithm` argument as a raw int and
cast it directly to TCOD_fov_algorithm_t. Out-of-range values (e.g. -49
reinterpreted as 4294967247) triggered UBSan's "invalid enum load" deep
in GridData::computeFOV.

PyFOV::from_arg already does the right validation for every other
algorithm entry point: accepts the FOV IntEnum, ints in
[0, NB_FOV_ALGORITHMS), or None (default BASIC); raises ValueError
otherwise. Route the binding through it.

Fuzz corpus crash
tests/fuzz/crashes/fov-crash-d5da064d802ae2b5691c520907cd692d04de8bb2
now runs cleanly.

closes #310

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:32:09 -04:00
4fd718472a Clamp Caption numeric setters to prevent UBSan float->uint UB
The `outline` and `font_size` property setters (and the matching kwargs
in __init__) passed a raw float straight into SFML's setOutlineThickness
and setCharacterSize. setCharacterSize takes unsigned int, so any
negative or out-of-range float produced undefined behavior — UBSan's
"value outside the representable range" report.

Clamp before the cast. Negative outline is also nonsensical, so it's
clamped to 0 for consistency (not UB, but wrong).

Fuzz corpus crash
tests/fuzz/crashes/property_types-crash-1f7141d732736d04b99d20261abd766194246ea6
now runs cleanly.

closes #309

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:31:58 -04:00
2b0267430d Add regression tests for vector NULL safety and uniform owner validity, closes #287
Covers #268 (sfVector2f_to_PyObject NULL propagation) and #272
(UniformCollection weak_ptr validity check). Combined with existing
tests for #258-#278, this completes regression coverage for the
full memory safety audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:57:32 -04:00
cc50424372 Add regression tests for GridPointState dangle and refcount leaks, refs #287
Covers two previously untested bug families from the memory safety audit:
- #265: GridPointState references after entity grid transfer
- #267, #275: Reference count leaks in collection/property access loops

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:06:10 -04:00
de2dbe0b48 Add regression test for entity.die() during iteration, refs #273
Verifies that die() during grid.entities iteration raises RuntimeError
(iterator invalidation protection), that the safe collect-then-die
pattern works, and that die() properly removes from spatial hash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:45:23 -04:00
8780f287bd Add missing spatial_hash.insert() in set_grid() grid transfer, closes #274
When transferring an entity to a new grid via entity.grid = new_grid,
the entity was removed from the old grid's spatial hash but never
inserted into the new one. This made it invisible to spatial queries
on the destination grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:45:18 -04:00
5173eaf7f5 Update spatial hash in animation setProperty for entity position, closes #256
UIEntity::setProperty() now calls spatial_hash.update() when draw_x/draw_y
change, matching the Python property setter behavior. Added
enable_shared_from_this<UIEntity> to support shared_from_this() in the
setProperty path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:40:29 -04:00
2f4928cfa3 Null parent_grid pointers in GridData destructor, closes #270, closes #271, closes #277
GridLayer, UIGridPoint, and GridChunk each stored a raw GridData* that
could dangle if the grid was destroyed while external shared_ptrs
(e.g. Python layer wrappers) still referenced child objects. The
destructor now nulls all parent_grid pointers before cleanup. All
usage sites already had null guards, so this completes the fix.

These were the last three unfixed bugs from the memory safety audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:34:33 -04:00
e58b44ef82 Add missing markDirty()/markCompositeDirty() to all Python property setters
Fixes a systemic bug where Python tp_getset property setters bypassed the
render cache dirty flag system (#144). The animation/C++ setProperty() path
had correct dirty propagation, but direct Python property assignments
(e.g. frame.x = 50, caption.text = "Hello") did not invalidate the parent
Frame's render cache when clip_children or cache_subtree was enabled.

Changes by file:
- UIDrawable.cpp: Add markCompositeDirty() to set_float_member (x/y),
  set_pos, set_grid_pos; add markDirty() for w/h resize
- UICaption.cpp: Add markDirty() to set_text, set_color_member,
  set_float_member (outline/font_size); markCompositeDirty() for position
- UICollection.cpp: Add markContentDirty() on owner in append, remove,
  pop, insert, extend, setitem, and slice assignment/deletion
- UISprite.cpp: Add markDirty() to scale/sprite_index/texture setters;
  markCompositeDirty() to position setters
- UICircle.cpp: Add markDirty() to radius/fill_color/outline_color/outline;
  markCompositeDirty() to center setter
- UILine.cpp: Add markDirty() to start/end/color/thickness setters
- UIArc.cpp: Add markDirty() to radius/angles/color/thickness setters;
  markCompositeDirty() to center setter
- UIGrid.cpp: Add markDirty() to center/zoom/camera_rotation/fill_color/
  size/perspective/fov setters

Closes #288, closes #289, closes #290, closes #291

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:01:41 -04:00
6d5e99a114 Remove legacy string enum comparisons from InputState/Key/MouseButton, closes #306
Removed custom __eq__/__ne__ that allowed comparing enums to legacy string
names (e.g., Key.ESCAPE == "Escape"). Removed _legacy_names dicts and
to_legacy_string() functions. Kept from_legacy_string() in PyKey.cpp as
it's used by C++ event dispatch. Updated ~50 Python test/demo/cookbook
files to use enum members instead of string comparisons. Also updates
grid.position -> grid.pos in files that had both types of changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:19:02 -04:00
354faca838 Remove redundant Grid.position alias, keep only Grid.pos, closes #308
Grid.position was a redundant alias for Grid.pos. Removed get_position/
set_position functions, getsetters entry, and setProperty/getProperty/
hasProperty branches. Updated all tests to use grid.pos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:18:30 -04:00
4a3854dac1 Fix audit type count (44->46) and add regression test for Color __eq__, refs #307
Review of session 1b14b941 found two issues:
- Exported type count was 44 in audit doc but array has 46 entries
  (EntityCollection3DIterType and one other were not counted)
- No regression test existed for the Color.__eq__/__ne__ fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:45:12 -04:00
6a0040d630 Add regression tests for Frame.children mutation and parent=None removal
frame_children_mutation_test: validates remove(), property mutation via
stored refs, fill_color persistence, iteration mutation, pop(), and
while-loop clearing — with visual screenshot verification.

parent_none_removal_test: validates that setting .parent = None removes
children from Frame.children and scene.children, Entity.grid = None
removal, and Grid overlay children removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 11:27:47 -04:00
a35352df4e Phase 4.3: Grid auto-creates GridView with rendering property sync
Grid.__init__() now auto-creates a GridView that shares the Grid's
data via aliasing shared_ptr. This enables the Grid/GridView split:

- PyUIGridObject gains a `view` member (shared_ptr<UIGridView>)
- Grid.view property exposes the auto-created GridView (read-only)
- Rendering property setters (center_x/y, zoom, camera_rotation, x, y,
  w, h) sync changes to the view automatically
- Grid still works as UIDrawable in scenes (no substitution) — backward
  compatible with all existing code and subclasses
- GridView.grid returns the original Grid with identity preservation
- Explicit GridViews (created by user) are independent of Grid's own
  rendering properties

Addresses #252. All 260 tests pass, no breaking changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:24:47 -04:00
2f1e472245 Phase 2: Entity data model extensions for behavior system
- Add Behavior enum (IDLE..FLEE, 11 values) and Trigger enum (DONE,
  BLOCKED, TARGET) as runtime IntEnum classes (closes #297, closes #298)
- Add entity label system: labels property (frozenset), add_label(),
  remove_label(), has_label(), constructor kwarg (closes #296)
- Add cell_pos integer logical position decoupled from float draw_pos;
  grid_pos now aliases cell_pos; SpatialHash::updateCell() for cell-based
  bucket management; FOV/visibility uses cell_position (closes #295)
- Add step callback and default_behavior properties to Entity for
  grid.step() turn management (closes #299)
- Update updateVisibility, visible_entities, ColorLayer::updatePerspective
  to use cell_position instead of float position

BREAKING: grid_pos no longer derives from float x/y position. Use
cell_pos/grid_pos for logical position, draw_pos for render position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:05:06 -04:00
94f5f5a3fd Phase 1: Safety & performance foundation for Grid/Entity overhaul
- Fix Entity3D self-reference cycle: replace raw `self` pointer with
  `pyobject` strong-ref pattern matching UIEntity (closes #266)
- TileLayer inherits Grid texture when none set, in all three attachment
  paths: constructor, add_layer(), and .grid property (closes #254)
- Add SpatialHash::queryCell() for O(1) entity-at-cell lookup; fix
  missing spatial_hash.insert() in Entity.__init__ grid= kwarg path;
  use queryCell in GridPoint.entities (closes #253)
- Add FOV dirty flag and parameter cache to skip redundant computeFOV
  calls when map unchanged and params match (closes #292)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:48:24 -04:00
836a0584df Preserve Python subclass identity for entities in grids (reopens #266)
The Phase 3 fix for #266 removed UIEntity::self which prevented
tp_dealloc from ever running. However, this also allowed Python
subclass wrappers (GameEntity, ZoneExit, etc.) to be GC'd while
the C++ entity lived on in a grid. Later access via grid.entities
returned a base Entity wrapper, losing all subclass methods.

Fix: Add UIEntity::pyobject field that holds a strong reference to
the Python wrapper. Set in init(), cleared when the entity leaves
a grid (die(), set_grid(None), collection removal). This keeps
subclass identity alive while in a grid, but allows proper GC when
the entity is removed. Added releasePyIdentity() helper called at
all grid exit points.

Regression test exercises Liber Noster patterns: subclass hierarchy,
isinstance() checks, combat mixins, tooltip/send methods, GC
survival, die(), pop(), remove(), and stress test with 20 entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:24:26 -04:00
115e16f4f2 Convert raw pointers to coordinate-based access (closes #264, closes #265)
GridPoint and GridPointState Python objects now store (grid, x, y)
coordinates instead of raw C++ pointers. Data addresses are computed
on each property access, preventing dangling pointers after vector
resizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:30:32 -05:00
a12e035a71 Remove entity self-reference cycle
UIEntity::init() stored self->data->self = (PyObject*)self with
Py_INCREF(self), creating a reference cycle that prevented entities
from ever being freed. The matching Py_DECREF never existed.

Fix: Remove the `self` field from UIEntity entirely. Replace all
read sites (iter next, getitem, get_perspective, entities_in_radius)
with PythonObjectCache lookups using serial_number, which uses weak
references and doesn't prevent garbage collection.

Also adds tp_dealloc to PyUIEntityType to properly clean up the
shared_ptr and weak references when the Python wrapper is freed.

Closes #266, closes #275

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:22:58 -05:00
348826a0f5 Fix gridstate heap overflows and spatial hash cleanup
Add ensureGridstate() helper that unconditionally checks gridstate size
against current grid dimensions and resizes if mismatched. Replace all
lazy-init guards (size == 0) with ensureGridstate() calls.

Previously, gridstate was only initialized when empty. When an entity
moved to a differently-sized grid, gridstate kept the old size, causing
heap buffer overflows when updateVisibility() or at() iterated using the
new grid's dimensions.

Also adds spatial_hash.remove() calls in set_grid() before removing
entities from old grids, and replaces PyObject_GetAttrString type lookup
with direct &mcrfpydef::PyUIGridType reference.

Closes #258, closes #259, closes #260, closes #261, closes #262,
closes #263, closes #274, closes #276, closes #278

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:56:16 -05:00
9718153709 Fix callback/timer GC: prevent premature destruction of Python callbacks
closes #251

Two related bugs where Python garbage collection destroyed callbacks
that were still needed by live C++ objects:

1. **Drawable callbacks (all 8 types)**: tp_dealloc unconditionally called
   click_unregister() etc., destroying callbacks even when the C++ object
   was still alive in a parent's children vector. Fixed by guarding with
   shared_ptr::use_count() <= 1 — only unregister when the Python wrapper
   is the last owner.

2. **Timer GC prevention**: Active timers now hold a Py_INCREF'd reference
   to their Python wrapper (Timer::py_wrapper), preventing GC while the
   timer is registered in the engine. Released on stop(), one-shot fire,
   or destruction. mcrfpy.Timer("name", cb, 100) now works without storing
   the return value.

Also includes audio synth demo UI fixes: button click handling (don't set
on_click on Caption children), single-column slider layout, improved
Animalese contrast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:53:50 -05:00
52fdfd0347 Test suite modernization 2026-02-09 08:15:18 -05:00
d2ea64bc32 fix: animations modifying animations during callback is now safe 2026-02-04 10:25:59 -05:00
2daebc84b5 Simplify on_enter/on_exit callbacks to position-only signature
BREAKING CHANGE: Hover callbacks now take only (pos) instead of (pos, button, action)

- Add PyHoverCallable class for on_enter/on_exit/on_move callbacks (position-only)
- Add PyCellHoverCallable class for on_cell_enter/on_cell_exit callbacks
- Change UIDrawable member types from PyClickCallable to PyHoverCallable
- Update PyScene::do_mouse_hover() to call hover callbacks with only position
- Add tryCallPythonMethod overload for position-only subclass method calls
- Update UIGrid::fireCellEnter/fireCellExit to use position-only signature
- Update all tests for new callback signatures

New callback signatures:
| Callback       | Old                      | New        |
|----------------|--------------------------|------------|
| on_enter       | (pos, button, action)    | (pos)      |
| on_exit        | (pos, button, action)    | (pos)      |
| on_move        | (pos, button, action)    | (pos)      |
| on_cell_enter  | (cell_pos, button, action)| (cell_pos)|
| on_cell_exit   | (cell_pos, button, action)| (cell_pos)|
| on_click       | unchanged                | unchanged  |
| on_cell_click  | unchanged                | unchanged  |

closes #230

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:36:02 -05:00
86bfebefcb Fix: Derivable drawable types participate in garbage collector cycle detection 2026-01-27 13:21:10 -05:00
16b5508233 Fix borrowed reference return in some callbacks 2026-01-27 10:43:10 -05:00
3fea6418ff Fix UIFrame RenderTexture positioning and toggling issues
- Fix #223: Use `position` instead of `box.getPosition()` for render_sprite
  positioning. The box was being set to (0,0) for texture rendering and
  never restored, causing frames to render at wrong positions.

- Fix #224: Add disableRenderTexture() method and call it when toggling
  clip_children or cache_subtree off. This properly cleans up the texture
  and prevents stale rendering.

- Fix #225: Improve dirty propagation in markContentDirty() to propagate
  to parent even when already dirty, if the parent was cleared (rendered)
  since last propagation. Prevents child changes from being invisible.

- Fix #226: Add fallback to standard rendering when RenderTexture can't
  be created (e.g., zero-size frame). Prevents inconsistent state.

Closes #223, closes #224, closes #225, closes #226

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:54:50 -05:00
165db91b8d Organize test suite: add README, move loose tests to proper directories
- Add tests/README.md documenting test structure and usage
- Move issue_*_test.py files to tests/regression/ (9 files)
- Move loose test_*.py files to tests/unit/ (18 files)
- tests/ root now contains only pytest infrastructure

Addresses #166

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:34:22 -05:00
baa7ee354b Add input validation and fix Entity repr
- #212: Add GRID_MAX (8192) validation to Grid, ColorLayer, TileLayer
- #213: Validate color components are in 0-255 range
- #214: Add null pointer checks before HeightMap operations
- #216: Change entities_in_radius(x, y, radius) to (pos, radius)
- #217: Fix Entity __repr__ to show actual draw_pos float values

Closes #212, closes #213, closes #214, closes #216, closes #217

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:18:27 -05:00
2f4ebf3420 tests for the last few issues (these test scripts should work with recent APIs, while the rest of the test suite needs an overhaul) 2026-01-08 10:31:21 -05:00
cec76b63dc Timer overhaul: update tests 2026-01-03 22:44:53 -05:00
838da4571d update tests: new scene API 2026-01-03 10:59:52 -05:00
9f481a2e4a fix: Update test files to use current API patterns
Migrates test suite to current API:
- Frame(x, y, w, h) → Frame(pos=(x, y), size=(w, h))
- Caption("text", x, y) → Caption(pos=(x, y), text="text")
- caption.size → caption.font_size
- Entity(x, y, ...) → Entity((x, y), ...)
- Grid(w, h, ...) → Grid(grid_size=(w, h), ...)
- cell.color → ColorLayer system

Tests now serve as valid API usage examples.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:47:48 -05:00
a258613faa feat: Migrate Grid to user-driven layer rendering (closes #150)
- Add `layers` dict parameter to Grid constructor for explicit layer definitions
  - `layers={"ground": "color", "terrain": "tile"}` creates named layers
  - `layers={}` creates empty grid (entities + pathfinding only)
  - Default creates single TileLayer named "tilesprite" for backward compat

- Implement dynamic GridPoint property access via layer names
  - `grid.at(x,y).layer_name = value` routes to corresponding layer
  - Protected names (walkable, transparent, etc.) still use GridPoint

- Remove base layer rendering from UIGrid::render()
  - Layers are now the sole source of grid rendering
  - Old chunk_manager remains for GridPoint data access
  - FOV overlay unchanged

- Update test to use explicit `layers={}` parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 23:04:09 -05:00