UICollection::repr tallied contents by derived_type(), feeding its "Grid"
bucket from PyObjectsEnum::UIGRID and routing UIGRIDVIEW into other_count.
Since #252 every mcrfpy.Grid in a scene graph is a UIGRIDVIEW, so grids were
never named: repr(scene.children) reported "1 Frame, 2 UIDrawables".
Dispatch on the asGridData() virtual (added in #355) rather than the enum,
which covers both the view and the internal _GridData type.
This was the last dead derived_type()==UIGRID gate in a user-visible path --
the same bug class #355 was opened to eliminate. One live UIGRID arm remains
in UIDrawable::removeFromParent, but it is not dead (grid children genuinely
do get parent = the internal UIGrid today) and it is redundant rather than
broken; it disappears with #361.
closes#366
Grid children are positioned in the grid's pixel-world coordinates — the same
origin as Entity positions — while Frame children are frame-local. Neither was
documented, so the difference read as an inconsistency (it surfaced during #355,
which had to decide whether to preserve it).
It is not an inconsistency. A Frame has no camera and cannot pan its content, so
frame-local is effectively screen space; a Grid does have a camera, and its
children are deliberately anchored to world content, not to the viewport. Testing
grid children at their visible coordinates would break all positioning the moment
the camera moved. The use case is diegetic UI — speech bubbles, damage numbers,
range indicators — that belongs to a place or an entity in the world.
Documents the convention on both Grid.children and Frame.children (each pointing
at the other), and adds docs/grid-coordinate-spaces.md with the overlay pattern
for screen-space UI over a grid: a sibling Frame matching the Grid's pos/size,
rather than Grid.children.
Note: docs/ is gitignored wholesale with tracked files force-added, so the guide
needed `git add -f` — it would otherwise have been silently dropped.
closes#360
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ImGuiSceneExplorer::renderDrawableNode switched on derived_type() with a UIGRID
arm that nothing in a scene graph has matched since #252; UIGRIDVIEW fell through
to `default: break`, so a Grid node in the explorer tree could not be expanded to
reveal its children or entities.
Uses UIDrawable::asGridData() (#355) rather than adding a UIGRIDVIEW enum arm.
Debug tooling only — but the explorer's blindness here is part of why the whole
#355 family stayed invisible for so long.
closes#358
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
McRFPy_API::find_in_collection gated its descent into a grid's entities and
overlay children on derived_type() == PyObjectsEnum::UIGRID — dead since #252,
because every mcrfpy.Grid in a scene graph is a UIGRIDVIEW. Named entities and
named child drawables inside a Grid were simply unfindable.
Uses the UIDrawable::asGridData() virtual added by #355 rather than adding a
UIGRIDVIEW arm next to the dead UIGRID one, which would only relocate the bug.
Two defects found in review of the now-reachable code:
- find_in_grid_entities built Entity wrappers with a raw tp_alloc, bypassing
the PythonObjectCache lookup every other entity accessor performs. When the
duplicate wrapper was deallocated, PyUIEntityType's tp_dealloc unconditionally
ran `self->data->pyobject = nullptr`, dropping the entity's #266 strong
identity reference without a DECREF. It now consults the cache first, exactly
as UIEntityCollection::getitem does, so find('hero') returns the user's Hero
instance rather than a base Entity.
- With N views sharing one GridData (the split-screen/minimap case), the
asGridData() recursion visited the shared data once per view, so findAll()
returned every grid entity and child N times. find_in_collection now threads
a visited-GridData set.
closes#357
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes#355closes#359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/GRID_ENTITY_OVERHAUL_ROADMAP.md, docs/3D_SYSTEM_GUIDE.md,
docs/PROCEDURAL_GENERATION_SPEC.md, and docs/MCROGUFACE_LITE_PICOCALC_RESEARCH.md
are a mix of historical, pending, and offshoot planning material rather than
McRogueFace user-facing docs -- same rationale as the personal-notes block
added in 54624b3. Kept locally (gitignored), not part of the tracked repo.
Addresses #356 (docstring-generator gap found during this audit).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rendered/generated documentation no longer lives in git -- it's regenerated
by a pre-commit hook (tools/hooks/pre-commit, installed via `make
install-hooks`) and shipped inside release artifacts (tools/package.sh) instead.
A compact api/manifest.json (signatures + docstring hashes + carried-forward
lifecycle metadata: since/modified per object and member) is the one exception
and stays committed, refreshed by the same hook, so API changes remain
trackable by commit/tag via `git show <ref>:api/manifest.json` and
`tools/api_delta.py REF1 REF2` without needing a full rebuild at old refs.
- tools/generate_api_manifest.py: engine-side introspection, run headless;
emits api/manifest.json (committed) and docs/generated/api_full.json
(gitignored, full docstrings -- the docs-site generator's data source).
- tools/api_delta.py: stdlib-only diff CLI (md/json/gitea-checklist formats),
with --site-dir to cross-reference affected docs-site pages via their mcrf
frontmatter.
- tools/hooks/pre-commit: incremental build + frozen-docstring gate + doc
regen + manifest refresh, gated on staged src/ changes.
- .gitignore covers the rendered docs/stubs plus personal working-notes docs
(plans, audits, research write-ups) that were never McRogueFace-user-facing.
- docs/api-stability.md and docs/threading-model.md removed: their content is
now the wiki's "API Stability Contract" and "Threading Model" system pages
(ratified 2026-07-02, migration verified 2026-07-11); the repo continues to
enforce them via tests/unit/api_surface_snapshot_test.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
Profiling sprint shipped July 2026: make profile/make callgrind rig (#345),
hot-getter fast path (#331), and four profiler-driven fixes -- get_grid
wrapper cache (#348, -97% Ir), scalar-float animation fast path (#342, -18%),
memoized enum members (#344, -99%), non-subclassed scene update skip (#343,
~1500x/frame). Harness total -8.8%; suite 307/307. Spun out #349/#350 (and
sibling #346/#347). Corrected #331 to resolved and open count to 33.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the Callgrind/wall-clock A/B for all four fixes and the two guessed
hot spots the profiler disproved (#342 strcmp cascade and weak_ptr lock),
plus how #343 was measured on the real doFrame loop rather than step().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PyScene::update did PyObject_GetAttrString(self, "update") every frame even
for a bare mcrfpy.Scene, which defines no update() and has no instance
__dict__ (so update can only exist on a Python subclass). A base-type guard
skips the failed lookup + PyErr_Clear entirely for non-subclassed scenes,
mirroring the is_python_subclass hover fast-path.
Note: mcrfpy.step() bypasses updatePythonScenes(), so this is invisible to
the step()-only headless harness. Measured instead on the real doFrame()
loop (headless run(), same doFrame path as the windowed loop), normalized
by call_update Ir/call:
call_update per frame: 4,559 Ir -> 3 Ir (~1500x; 2.1x more frames/budget)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
- 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>
The default Release build (-O3 -DNDEBUG) omits frame pointers and ships no
DWARF, so it can't be line-annotated or unwound; build-debug (-O0) profiles
unoptimized code. Add a dedicated profiling variant for C++ hot-path work
(validating #331/#342/#343/#344).
- CMakeLists.txt: MCRF_PROFILE option adds -fno-omit-frame-pointer on top of
RelWithDebInfo (-O2 -g). Binary not stripped. Post-build lib/assets/scripts
copy already keys on the mcrogueface target, so build-profile/ self-populates.
- Makefile: `make profile` (-> build-profile/), `make callgrind SCRIPT=...`
(one-shot Callgrind on a headless benchmark), `make clean-profile` (wired
into clean-all).
- docs/profiling.md: full Callgrind + perf workflow, incl. the one-time
`sudo sysctl kernel.perf_event_paranoid=1` perf needs on this box.
- Pointers from the Makefile header and CLAUDE.md build section.
Verified: DWARF sections present, frame-pointer prologues emitted, headless
smoke test passes, Callgrind yields source line + call-count attribution, and
perf --call-graph fp resolves engine symbols.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
A 30-45 minute turn-based RPG built overnight as an engine showcase:
six recruitable characters (party of three), four recurring NPCs,
choice-driven dialogue with visible-but-locked skill checks, shops,
loot, levels, two bosses with in-battle Talk options, the reverted
grey-town beat, and an epilogue assembled from the player's actual
choices. 33 dialogue scenes / 171 nodes. Terrain is ColorLayers only;
kenney_tinydungeon supplies characters, items, and props.
Creative direction, story, dialogue, maps, and systems design by
Fable; implementation by four Opus subagents (framework, battle,
overworld, integration). Verified by tests/playthrough.py: a scripted
headless run of all three acts, 20/20 beats passing, plus per-system
unit tests (script integrity, UI kit, 225-battle balance sim,
overworld).
Run: cd build && ./mcrogueface --exec ../games/unwritten/main.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4
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>
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>
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>
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>
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>
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>
Add a one-line threading-contract note to the API reference intro (HTML +
Markdown generators) and regenerate all documentation artifacts (HTML,
Markdown, man page, type stubs) to pick up the updated mcrfpy.lock() docstring.
Refs #327
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add docs/api-stability.md recording the three frozen 1.0 decisions: Color/Vector
value semantics (#326), the edit() context-manager bulk-edit convention (#328),
and subinterpreter exclusion (#330), plus a pointer to the api-surface snapshot
test as the enforcement mechanism.
Strengthen the PyColor and PyVector class docstrings to state value semantics as
a frozen contract with the supported idioms (read-modify-writeback and
whole-value assignment). Add the subinterpreter-unsupported note and policy
links to README.
closes#326, closes#328, closes#330
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add docs/threading-model.md stating the normative rule (any access to mcrfpy
objects from a non-main thread must happen inside `with mcrfpy.lock():`;
behavior outside the lock is undefined), the FrameLock/safe-window design, and
a worked worker-thread example. Strengthen the mcrfpy.lock() docstring to state
the contract with an MCRF_LINK to the new page.
closes#327
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grid.entities wrapped std::list and did std::advance() from begin() on every
indexed access, so grid.entities[i] was O(n) and a full indexed scan O(n^2).
Switch the backing container (GridData::entities and the EntityCollection /
iterator wrappers) to std::vector.
Changes:
- GridData::entities is now std::vector<shared_ptr<UIEntity>>.
- UIEntityCollection getitem uses (*vec)[index] -- O(1) random access.
- The Python iterator is now index-based (Py_ssize_t cursor) instead of
storing a container iterator. This removes any possibility of a dangling
iterator when the collection is mutated mid-iteration; the existing
start_size guard still raises RuntimeError on a size change, and the cursor
is bounds-checked against the live size so access stays memory-safe.
Iterator/mutation semantics (unchanged, now pinned by tests): iterating
grid.entities and changing its size mid-iteration raises RuntimeError.
grid.entities[i] indexing, negative indexing, IndexError on out-of-range,
and slicing behavior are all preserved.
Audit of call sites: all other consumers (UIEntity add/remove/set_grid,
UIGrid render, ImGui explorer, slice/pop/insert/setitem) use
begin/end/erase/push_back/find_if/rbegin, which are container-agnostic and
remain O(1) on vector iterators. grid.step() already snapshots entities into
a local vector before executing behaviors, so entity death (self-removal)
mid-step cannot invalidate the loop; a regression test now pins this.
UIEntity::releasePyIdentity() is still invoked at every grid-exit path.
The separate 3D EntityCollection3D (Entity3D) is unrelated and untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
One-off issue-fetch script with a hardcoded (now-invalidated) token;
not part of the project. Issue queries go through the forgejo MCP
server now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refresh open-issue count 21 -> 34 after the pre-1.0 memory-model
review filed #326-#338 (2026-07-02). Surface the five tier1 freeze
decisions (#326-#330) under Active Follow-Ups so they're visible as
blockers to locking the 1.0 API contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The issue tracker moved from gamedev.ffwf.net/gitea to
dev.ffwf.net/forgejo. Update all URLs in CLAUDE.md, README.md,
web/index.html, and tools/gitea_issues.py. CLAUDE.md also documents
the new resource-oriented forgejo MCP server (list/get/create/edit/
delete/link/unlink_gitea) and retires the stale v0.07 label-bug
workaround -- label operations re-validated working 2026-07-02.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version header and Recently Shipped section hadn't been updated since
cf844f4 (version bump + Linux package libtcod SONAME fix), which was
already pushed to origin/master. Also notes the 0.2.8 git tag is still
local-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
Release the commits since 0.2.7-prerelease-7drl2026 as 0.2.8, dropping
the in-development -7DRL-2026 suffix.
Shipped libtcod (Linux .so / Windows .dll / Emscripten .a) rebuilt from
submodule 79abc66 so release binaries carry the #321 FOV overflow fix.
The Linux .so is built SDL3=OFF with vendored utf8proc so it stays
self-contained (only libz + system libs), matching prior releases.
Fix tools/package.sh: ship libtcod under its DT_NEEDED SONAME
(libtcod.so.2) plus a libtcod.so compat symlink, mirroring the SFML
packaging. It previously shipped only the bare libtcod.so, so the Linux
package could not resolve libtcod on a clean machine -- an absolute dev
RUNPATH masked this, and it also affected 0.2.7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
The fuzz-surfaced bug batch is now on master, not pending. Drop the
stale "pending merge" bullet and decrement the tracker count (26 -> 21,
verified against Gitea). Keep the libtcod.so rebuild release note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
The #312 fuzz-surfaced bug batch (#321-#325) is now fully fixed on-branch.
Moves #322-#325 to Recently Shipped with the A/B verification evidence,
clears the Active Follow-Ups, and notes the batch is pending merge to master.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
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
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
#321 was an ASan heap-buffer-overflow in view_array_insert
(fov_permissive2.c:97) reached via ColorLayer.draw_fov ->
GridData::computeFOV -> TCOD_map_compute_fov_permissive2, surfaced by the
#312 fuzz_fov campaign. The "bad-free in ~GridData" in the issue title is
the downstream symptom of this active_views buffer overflow.
The bump pulls in four upstream FOV commits:
- off-by-one in view_array_insert overflowing active_views <- root cause
- undersized bumps buffer overflow on tiny permissive maps
- MRPAS obstacle buffer overflow / malloc(0) on small maps
- regression tests for FOV
Verified by A/B replay of the fuzz_fov crash corpus under clang-18 ASan,
same build config (clang-18, -DLIBTCOD_SDL3=OFF, identical sanitizer
flags) with only the submodule commit differing:
pre-fix (8835239): fov-crash-5f34f68e and fov-crash-a451db54 abort with
heap-buffer-overflow in view_array_insert inside
GridData::computeFOV
post-fix (79abc66): all crash inputs clean; 45s / 21,952-run fov fuzz
smoke clean, no new crashes
Note: __lib/libtcod.so (gitignored, the shipped desktop lib) must be
rebuilt from this submodule commit for release binaries to carry the fix.
Also updates ROADMAP: #321 moved to Recently Shipped; open-issue count
refreshed to the post-#312 tracker state.
closes#321
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvvE6TB2cScBVbeap2btHw
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