Commit graph

201 commits

Author SHA1 Message Date
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
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
16adc92995 Add public API-surface snapshot regression test; lock #314 freeze decisions
Phase 1 of the #314/#313 plan (docs/plan-313-314.md): commit a deterministic
full-public-API-surface snapshot as the regression net BEFORE the #313
UIEntity::grid -> GridData refactor, so that refactor reduces to a reviewable diff.

tests/unit/api_surface_snapshot_test.py enumerates the complete public mcrfpy
surface (exported types' methods/properties with docstring-derived type + ro/rw,
12 enums with int values, module functions, singletons, the automation submodule)
and diffs it against tests/snapshots/api_surface.golden.txt. Re-baseline with
MCRF_UPDATE_API_SNAPSHOT=1.

Key design points (verified against source + live introspection):
- mcrfpy.Grid IS mcrfpy.GridView and delegates its real API to an internal
  _GridData via tp_getattro -- invisible to dir(). The test walks grid.grid_data
  AND probes delegation integrity on a live instance (69/69 members resolve).
- Behavioral writability probes compensate for docstring-derived ro/rw inference.
- Every exported class is classified FROZEN vs EXPERIMENTAL; the snapshot captures
  the classification so future additions force a deliberate freeze decision.

Freeze decisions recorded in docs/api-audit-2026-04.md:
- F3: grid_pos is the canonical cell-position name (matches the constructor kwarg);
  cell_pos/cell_x/cell_y documented as aliases. Docstrings aligned in UIEntity.cpp.
- F12: deprecated set_scale KEPT in the 1.0 surface (removal would be a new break).
- automation camelCase EXEMPT from the snake_case rule.
- EXPERIMENTAL (exempt): 3D/Voxel, Tiled/LDtk import, Shader, binding helpers.
- FROZEN: core UI/Grid/Entity/value types, enums, procgen (BSP/HeightMap/
  NoiseSource/DijkstraMap/AStarPath), Drawable (root).

Doc/stub regeneration and the entity.texture addition are deferred to Phase 2
(the #313 refactor). Addresses #314.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:51:02 -04:00
3030ac488b Add interactive pathfinding demo for #315; closes #315
tests/demo/screens/pathfinding_demo.py runs three panels side-by-side:

  Panel 1 - A* with selectable heuristic. Keys 1-5 cycle EUCLIDEAN, MANHATTAN,
            CHEBYSHEV, DIAGONAL, ZERO. Q/W bump the weight by 0.25 to show
            weighted A* behaviour.
  Panel 2 - Dijkstra flood from a cursor-controlled root. Arrow keys move the
            cursor; the distance field re-renders as a blue gradient.
  Panel 3 - Multi-root FLEE: three guard entities flee from a shared set of
            threats using an inverted multi-root DijkstraMap, animated one
            step per timer tick. T adds a new threat; R resets.

Exercises the new surface: mcrfpy.Heuristic, Grid.find_path(heuristic=,
weight=), Grid.get_dijkstra_map(roots=...), DijkstraMap.invert(), and
DijkstraMap.descent_step().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:19:17 -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
767d0d4b0f Extend pathfinding API with heuristics, multi-root Dijkstra, and FLEE primitives; refs #315
Phase A (Python surface):
- New mcrfpy.Heuristic IntEnum: EUCLIDEAN, MANHATTAN, CHEBYSHEV, DIAGONAL, ZERO
- Grid.find_path() accepts heuristic= and weight= kwargs (weighted A*)
- Grid.get_dijkstra_map() accepts roots= (list of positions or DiscreteMap mask)

Phase B (FLEE primitives):
- DijkstraMap.invert() returns a new map with inverted distance field
- DijkstraMap.descent_step(pos) returns steepest-descent neighbor or None

DijkstraMap internally switched from the C++ TCODDijkstra wrapper to the C API
(TCOD_dijkstra_*) because multi-root compute and invert/get_descent are not
exposed on the wrapper. Single-root Dijkstra cache is preserved for backward
compatibility; multi-root and mask paths bypass the cache since cache keys
would be ill-defined.

New tests: heuristic_enum_test, find_path_heuristic_test, multi_root_dijkstra_test,
dijkstra_flee_test. Baseline JSONs for dijkstra_bench and gridview_render_bench
refreshed against the new implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:18:49 -04:00
59e722166a Phase 5.2: performance benchmark suite for grid/entity/FOV/pathfinding
Adds 6 benchmark scripts in tests/benchmarks/ covering all 5 scenarios from
Kanboard #37, plus a shared baseline helper:

  grid_step_bench.py        100 ent / 100x100 grid / 1000 grid.step() rounds
                            mix of IDLE/NOISE8/SEEK/FLEE behaviors
  fov_opt_bench.py          100 ent / 1000x1000 grid; entity.update_visibility()
                            (with DiscreteMap perspective writeback) vs bare
                            grid.compute_fov() (no writeback) across FOV
                            algorithms BASIC/SHADOW/SYMMETRIC_SHADOWCAST and
                            radii 8/16/32
  spatial_hash_bench.py     entities_in_radius() at radii (1,5,10,50) x
                            entity counts (100,1k,10k); compares against
                            naive O(n) baseline with hit-count validation
  pathfinding_bench.py      A* across grid sizes/densities/heuristics/weights,
                            plus with-vs-without `collide=` collision-label
                            comparison (0/10/100 blockers on 100x100)
  gridview_render_bench.py  1/2/4 GridViews on shared grid; uses
                            automation.screenshot() to force real renders in
                            headless mode (mcrfpy.step alone is render-stubbed)
  dijkstra_bench.py         single-root, multi-root, mask, invert, descent
  _baseline.py              writes baseline JSON to baseline/phase5_2/

All scripts emit JSON to stdout and write a baseline copy under
tests/benchmarks/baseline/phase5_2/ for regression comparison. All run
headless; pure time.perf_counter() timing for compute benches, screenshot
wall-time for the render bench (start/end_benchmark would only capture the
no-op headless game loop, so direct timing is used).

Notable findings captured in baselines:
- spatial hash: 5x to >300x speedup over naive O(n), hits validated identical
- update_visibility: ~25-37 ms/entity perspective writeback overhead on
  1000x1000 grid (full-grid demote+promote loop in UIEntity::updateVisibility)
  dominates over the actual TCOD FOV cost (~3-24 ms). Worth a follow-up issue
  for sparse perspective updating.
- gridview render: per-view cost scales near-linearly down (~78ms total for
  1, 2, or 4 views) -- the multi-view system shares state efficiently.

Refs Kanboard #37.
2026-04-18 06:45:40 -04:00
98a9497a6c Add Phase 5.1 end-to-end scenario test for Grid entity behaviors
tests/integration/grid_entity_e2e_test.py exercises the full
Grid + GridView + EntityBehavior stack over 100 turns with four
simultaneous entities on a 20x20 walled grid:

  Player  turn_order=0  manual placement, excluded from step()
  Guard   PATROL waypoints + target_label="player" + sight_radius=5
          step() callback switches to SEEK on TARGET trigger
  NPC     NOISE8 random 8-directional wander
  Trap    SLEEP turns=10, DONE callback fires then reverts to IDLE

Player is placed near the (15,15) waypoint so the Guard both
patrols (visits >=1 waypoint) and engages SEEK once in sight.
Verifies trigger count, DONE step index, behavior_type reversion,
and no crashes / Python exceptions over the full 100-turn run.

Kanboard card 36.
2026-04-18 05:44:06 -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
328b37d02e Merge W9: fuzz_pathfinding_behavior target 2026-04-10 11:20:29 -04:00
e41ed258d2 Merge W8: fuzz_fov target
# Conflicts:
#	tests/fuzz/fuzz_fov.py
2026-04-10 11:20:29 -04:00
9216773b1e Merge W7: fuzz_maps_procgen target for HeightMap/DiscreteMap interfaces 2026-04-10 11:20:00 -04:00
2b78d0bd2c Merge W5: fuzz_property_types target for #267, #268, #272 2026-04-10 11:19:52 -04:00
53712f535b Merge W4: fuzz_grid_entity target for #258-#263, #273, #274 2026-04-10 11:19:34 -04:00
598f22060a Add fuzz_pathfinding_behavior target, addresses #283
Fuzzes grid.get_dijkstra_map with random roots/diagonal_cost/collide,
DijkstraMap.distance/path_from/step_from/to_heightmap queries, and
grid.step() with entity behavior callbacks that mutate the entity
list mid-iteration (adjacent to #273).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:18:01 -04:00
2d207bb876 Add fuzz_maps_procgen target for HeightMap/DiscreteMap interfaces, addresses #283
Fuzzes procgen through its standardized data-container interface: direct
HeightMap and DiscreteMap operations (scalar, binary, bitwise, subscript)
plus one-directional conversions NoiseSource.sample -> HeightMap,
BSP.to_heightmap, DiscreteMap.from_heightmap, and dm.to_heightmap.
Treating HM/DM as the unified surface covers every procgen system
without fuzzing each individually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:17:52 -04:00
56eeab2ff5 Add fuzz_anim_timer_scene target for lifecycle bug family, addresses #283
Targets #269 (PythonObjectCache race), #270 (GridLayer dangling parent),
#275 (UIEntity missing tp_dealloc), #277 (GridChunk dangling parent).
Exercises timer/animation callbacks that mutate scene and drawable
lifetimes across firing boundaries, including scene swap mid-callback
and closure captures that can survive past their target's lifetime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:16:40 -04:00
df4757c448 Add fuzz_property_types target for refcount/type-confusion bugs, addresses #283
Targets #267 (PyObject_GetAttrString reference leaks), #268 (sfVector2f
NULL deref), #272 (UniformCollection weak_ptr). Exercises every exposed
property on Frame/Caption/Sprite/Grid/Entity/TileLayer/ColorLayer/Color/
Vector with both correct-type and deliberately-wrong-type values, plus
hot-loop repeated GetAttrString to stress refcount sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:15:39 -04:00
bba72cb33b Add fuzz_fov target, addresses #283
Random compute_fov/is_in_fov exercises with varying origin, radius,
light_walls, algorithm params including out-of-bounds origins and
extreme radii. Toggles grid.at(x,y).transparent between computes to
stress stale fov-state bugs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:15:01 -04:00
0d433c1410 Add fuzz_grid_entity target for EntityCollection bug family, addresses #283
Targets #258-#263 (gridstate overflow on entity transfer between
differently-sized grids), #273 (entity.die during iteration), #274
(spatial hash on set_grid). Dispatches 13 operations driven by
ByteStream from fuzz_common.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:13:16 -04:00
90a2945a9f Add native libFuzzer fuzz harness for Python API, addresses #283
Pivots away from atheris (which lacks Python 3.14 support) to a single
libFuzzer-linked executable that embeds CPython, registers mcrfpy, and
dispatches each iteration to a Python fuzz_one_input(data: bytes) function
loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var.

libFuzzer instruments the C++ engine code where all #258-#278 bugs live;
Python drives the fuzzing logic via an in-house ByteStream replacement
for atheris.FuzzedDataProvider. Python-level exceptions are caught; only
ASan/UBSan signal real bugs.

CMake
- MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp
  plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address,
  undefined. Asset+lib post-build copy added so the embedded interpreter
  finds its stdlib and default_font/default_texture load.

Makefile
- fuzz-build builds only mcrfpy_fuzz (fast iterate)
- fuzz loops over six targets setting MCRF_FUZZ_TARGET for each
- fuzz-long TARGET=x SECONDS=n for deep manual runs
- fuzz-repro TARGET=x CRASH=path for crash reproduction
- Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define

tests/fuzz
- fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target,
  resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes,
  calls target, swallows Python errors.
- fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS
- Six target stubs (grid_entity, property_types, anim_timer_scene,
  maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up
- README with build/run/triage instructions

Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz,
make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each,
667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus
input cleanly. No crashes from the stubs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
6bf5c451a3 Add composite sprite_grid for multi-tile entities, closes #237
Entities can now specify per-tile sprite indices via the sprite_grid
property. When set, each tile in a multi-tile entity renders its own
sprite from the texture atlas instead of the single entity sprite.

API:
  entity.tile_size = (3, 2)
  entity.sprite_grid = [[10, 11, 12], [20, 21, 22]]
  entity.sprite_grid = None  # revert to single sprite

Accepts nested lists, flat lists, or tuples. Use -1 for empty tiles.
Dimensions must match tile_width x tile_height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 04:15:06 -04:00
a4e0b97ecb Add multi-tile entity support with tile_width/tile_height, closes #236
Entities can now span multiple grid cells via tile_width and tile_height
properties (default 1x1). Frustum culling accounts for entity footprint,
and spatial hash queries return multi-tile entities for all covered cells.

API: entity.tile_size = (2, 2) or entity.tile_width = 2; entity.tile_height = 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:57:47 -04:00
9a06ae5d8e Add texture display bounds for non-uniform sprite content, closes #235
Textures can now specify display_size and display_origin to crop sprite
rendering to a sub-region within each atlas cell. This supports texture
atlases where content doesn't fill the entire cell (e.g., 16x24 sprites
centered in 32x32 cells).

API: Texture("sprites.png", 32, 32, display_size=(16, 24), display_origin=(8, 4))
Properties: display_width, display_height, display_offset_x, display_offset_y

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:57:41 -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
e0bcee12a3 Add DiscreteMap to_bytes/from_bytes serialization, closes #293
to_bytes() returns raw uint8 cell data as Python bytes object.
from_bytes(data, size, enum=None) is a classmethod that constructs
a new DiscreteMap from serialized data with dimension validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:06:02 -04:00
061b29a07a Add compound Color and Vector animation targets (pos, fill_color), closes #218
UIFrame, UICaption, and UISprite now accept "pos" as an alias for "position"
in the animation property system. UICaption and UISprite gain Vector2f
setProperty/getProperty overrides enabling animate("pos", (x, y), duration).
Color compound animation (fill_color, outline_color) was already supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:05:55 -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
e7462e37a3 Remove camelCase module functions (setScale, findAll, getMetrics, setDevConsole), closes #304
Breaking API change: removes 4 camelCase function aliases from the mcrfpy
module. The snake_case equivalents (set_scale, find_all, get_metrics,
set_dev_console) remain and are the canonical API going forward.

- Removed setScale, findAll, getMetrics, setDevConsole from mcrfpyMethods[]
- Updated game scripts to use snake_case names
- Updated test scripts to use snake_case names
- Removed camelCase entries from type stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:07:22 -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
c15d836e79 Remove deprecated sprite_number property from Sprite and Entity, closes #305
sprite_number was a legacy alias for sprite_index. All code should use
sprite_index directly. Removed from getsetters, setProperty/getProperty/
hasProperty in UISprite and UIEntity, animation property handling, and
type stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:18:20 -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
a61f05229f Per-entity FOV cache for TARGET trigger optimization, closes #303
Add tiered optimization to grid.step() TARGET trigger evaluation:
- Tier 1: O(1) target_label check (already existed)
- Tier 2: O(bucket) spatial hash pre-filter (already existed)
- Tier 3: O(radius^2) bounded FOV via TCOD radius (verified TCOD bounds iteration)
- Tier 4: Per-entity FOV result cache - stores visibility bitmap per entity,
  skips FOV recomputation when entity hasn't moved and map transparency unchanged

Key changes:
- GridData: Add transparency_generation counter, bumped on syncTCODMap/Cell
- UIEntity: Add TargetFOVCache struct with visibility bitmap and validation
- UIGrid::py_step: Restructure TARGET check to collect matching targets first,
  then check/populate per-entity cache before testing visibility
- Entity.find_path(): New convenience method delegating to Grid.find_path
- Grid.find_path/get_dijkstra_map: Add collide parameter for entity-aware
  pathfinding (marks labeled entity cells as non-walkable during computation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 01:34:45 -04:00