diff --git a/docs/API_REFERENCE_DYNAMIC.md b/docs/API_REFERENCE_DYNAMIC.md
index 26b7299..ebd335b 100644
--- a/docs/API_REFERENCE_DYNAMIC.md
+++ b/docs/API_REFERENCE_DYNAMIC.md
@@ -1,6 +1,6 @@
# McRogueFace API Reference
-*Generated on 2026-04-18 13:35:02*
+*Generated on 2026-06-10 20:23:39*
*This documentation was dynamically generated from the compiled module.*
@@ -1775,6 +1775,7 @@ Attributes:
grid_x, grid_y (int): Integer tile coordinate components
draw_pos (Vector): Fractional tile position for smooth animation
perspective_map (DiscreteMap | None): 3-state per-entity FOV memory
+ texture (Texture): Texture atlas used by the entity's sprite
sprite_index (int): Current sprite index
visible (bool): Visibility state
opacity (float): Opacity value
@@ -1785,15 +1786,15 @@ Attributes:
**Properties:**
- `behavior_type` *(read-only)*: Current behavior type (int, read-only). Use set_behavior() to change.
-- `cell_pos`: Integer logical cell position (Vector). Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
-- `cell_x`: Integer X cell coordinate.
-- `cell_y`: Integer Y cell coordinate.
+- `cell_pos`: Integer logical cell position (Vector). Alias for grid_pos (the canonical name).
+- `cell_x`: Integer X cell coordinate. Alias for grid_x.
+- `cell_y`: Integer Y cell coordinate. Alias for grid_y.
- `default_behavior`: Default behavior type (int, maps to Behavior enum). Entity reverts to this after DONE trigger. Default: 0 (IDLE).
- `draw_pos`: Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.
- `grid`: Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid.
-- `grid_pos`: Grid position as integer cell coordinates (Vector). Alias for cell_pos.
-- `grid_x`: Grid X position as integer cell coordinate. Alias for cell_x.
-- `grid_y`: Grid Y position as integer cell coordinate. Alias for cell_y.
+- `grid_pos`: Integer logical cell position (Vector). Canonical cell-position property; matches the 'grid_pos' constructor argument. Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
+- `grid_x`: Integer X cell coordinate. Canonical; matches grid_pos.
+- `grid_y`: Integer Y cell coordinate. Canonical; matches grid_pos.
- `labels`: Set of string labels for collision/targeting (frozenset). Assign any iterable of strings to replace all labels.
- `move_speed`: Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15.
- `name`: Name for finding elements
@@ -1809,6 +1810,7 @@ Attributes:
- `sprite_offset_y`: Y component of sprite pixel offset.
- `step`: Step callback for grid.step() turn management. Called with (trigger, data) when behavior triggers fire. Set to None to clear.
- `target_label`: Label to search for with TARGET trigger (str or None). Default: None.
+- `texture`: Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture when the entity is constructed without one. Setting preserves sprite_index (the index is not re-validated against the new atlas). The grid's texture only determines cell size; entities draw with their own.
- `tile_height`: Entity height in tiles (int). Must be >= 1. Default 1.
- `tile_size`: Entity size in tiles as (width, height) Vector. Default (1, 1).
- `tile_width`: Entity width in tiles (int). Must be >= 1. Default 1.
diff --git a/docs/api-audit-2026-04.md b/docs/api-audit-2026-04.md
index 3f7ac29..7271aca 100644
--- a/docs/api-audit-2026-04.md
+++ b/docs/api-audit-2026-04.md
@@ -971,7 +971,11 @@ F8 (`Font` methods) = Future, explicitly NOT 1.0 blockers.
- **`entity.texture` (new in #313):** additive read/write property; getter returns the entity's
real texture, `None` only in the degenerate (default_texture-null) case — never re-derefs a null
default_texture. Added to the frozen contract + stubs + docs when #313 lands (golden gains exactly
- one line).
+ one line). Known edges, frozen as-is (2026-06-11 adversarial review): the getter mints a NEW
+ Texture wrapper per access and Texture has no `__eq__`, so `e.texture == e.texture` is False —
+ compare `.source`/sprite dims instead (same behavior as the pre-existing `Sprite.texture`);
+ setting does not re-validate `sprite_index` against the new atlas; setter rejects non-Texture
+ (TypeError), null-data Texture wrappers (ValueError, mirrors `Sprite.texture`), and deletion.
**1.0 freeze scope — class classification** (the snapshot segregates FROZEN vs EXPERIMENTAL):
diff --git a/docs/api_reference_dynamic.html b/docs/api_reference_dynamic.html
index f33bb90..8705ac1 100644
--- a/docs/api_reference_dynamic.html
+++ b/docs/api_reference_dynamic.html
@@ -108,7 +108,7 @@
McRogueFace API Reference
-
Generated on 2026-04-18 13:35:02
+
Generated on 2026-06-10 20:23:39
This documentation was dynamically generated from the compiled module.
@@ -1935,6 +1935,7 @@ Attributes:
grid_x, grid_y (int): Integer tile coordinate components
draw_pos (Vector): Fractional tile position for smooth animation
perspective_map (DiscreteMap | None): 3-state per-entity FOV memory
+ texture (Texture): Texture atlas used by the entity's sprite
sprite_index (int): Current sprite index
visible (bool): Visibility state
opacity (float): Opacity value
@@ -1945,15 +1946,15 @@ Attributes:
Properties:
- behavior_type (read-only): Current behavior type (int, read-only). Use set_behavior() to change.
- - cell_pos: Integer logical cell position (Vector). Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
- - cell_x: Integer X cell coordinate.
- - cell_y: Integer Y cell coordinate.
+ - cell_pos: Integer logical cell position (Vector). Alias for grid_pos (the canonical name).
+ - cell_x: Integer X cell coordinate. Alias for grid_x.
+ - cell_y: Integer Y cell coordinate. Alias for grid_y.
- default_behavior: Default behavior type (int, maps to Behavior enum). Entity reverts to this after DONE trigger. Default: 0 (IDLE).
- draw_pos: Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.
- grid: Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid.
- - grid_pos: Grid position as integer cell coordinates (Vector). Alias for cell_pos.
- - grid_x: Grid X position as integer cell coordinate. Alias for cell_x.
- - grid_y: Grid Y position as integer cell coordinate. Alias for cell_y.
+ - grid_pos: Integer logical cell position (Vector). Canonical cell-position property; matches the 'grid_pos' constructor argument. Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
+ - grid_x: Integer X cell coordinate. Canonical; matches grid_pos.
+ - grid_y: Integer Y cell coordinate. Canonical; matches grid_pos.
- labels: Set of string labels for collision/targeting (frozenset). Assign any iterable of strings to replace all labels.
- move_speed: Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15.
- name: Name for finding elements
@@ -1969,6 +1970,7 @@ Attributes:
- sprite_offset_y: Y component of sprite pixel offset.
- step: Step callback for grid.step() turn management. Called with (trigger, data) when behavior triggers fire. Set to None to clear.
- target_label: Label to search for with TARGET trigger (str or None). Default: None.
+ - texture: Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture when the entity is constructed without one. Setting preserves sprite_index (the index is not re-validated against the new atlas). The grid's texture only determines cell size; entities draw with their own.
- tile_height: Entity height in tiles (int). Must be >= 1. Default 1.
- tile_size: Entity size in tiles as (width, height) Vector. Default (1, 1).
- tile_width: Entity width in tiles (int). Must be >= 1. Default 1.
diff --git a/docs/mcrfpy.3 b/docs/mcrfpy.3
index 0008515..c965a3c 100644
--- a/docs/mcrfpy.3
+++ b/docs/mcrfpy.3
@@ -14,11 +14,11 @@
. ftr VB CB
. ftr VBI CBI
.\}
-.TH "MCRFPY" "3" "2026-04-18" "McRogueFace 0.2.7-prerelease-7drl2026-93-g0f7254e" ""
+.TH "MCRFPY" "3" "2026-06-10" "McRogueFace 0.2.7-prerelease-7drl2026-99-g16adc92" ""
.hy
.SH McRogueFace API Reference
.PP
-\f[I]Generated on 2026-04-18 13:35:02\f[R]
+\f[I]Generated on 2026-06-10 20:23:39\f[R]
.PP
\f[I]This documentation was dynamically generated from the compiled
module.\f[R]
@@ -1894,6 +1894,7 @@ attachment) grid_pos (Vector): Integer tile coordinates (logical game
position) grid_x, grid_y (int): Integer tile coordinate components
draw_pos (Vector): Fractional tile position for smooth animation
perspective_map (DiscreteMap | None): 3-state per-entity FOV memory
+texture (Texture): Texture atlas used by the entity\[cq]s sprite
sprite_index (int): Current sprite index visible (bool): Visibility
state opacity (float): Opacity value name (str): Element name
sprite_offset (Vector): Pixel offset for oversized sprites
@@ -1904,11 +1905,11 @@ sprite_offset_x (float): X component of sprite offset sprite_offset_y
Current behavior type (int, read-only).
Use set_behavior() to change.
- \f[V]cell_pos\f[R]: Integer logical cell position (Vector).
-Decoupled from draw_pos.
-Determines which cell this entity logically occupies for collision,
-pathfinding, etc.
+Alias for grid_pos (the canonical name).
- \f[V]cell_x\f[R]: Integer X cell coordinate.
+Alias for grid_x.
- \f[V]cell_y\f[R]: Integer Y cell coordinate.
+Alias for grid_y.
- \f[V]default_behavior\f[R]: Default behavior type (int, maps to
Behavior enum).
Entity reverts to this after DONE trigger.
@@ -1918,13 +1919,16 @@ Use for smooth animation between grid cells.
- \f[V]grid\f[R]: Grid this entity belongs to.
Get: Returns the Grid or None.
Set: Assign a Grid to move entity, or None to remove from grid.
-- \f[V]grid_pos\f[R]: Grid position as integer cell coordinates
-(Vector).
-Alias for cell_pos.
-- \f[V]grid_x\f[R]: Grid X position as integer cell coordinate.
-Alias for cell_x.
-- \f[V]grid_y\f[R]: Grid Y position as integer cell coordinate.
-Alias for cell_y.
+- \f[V]grid_pos\f[R]: Integer logical cell position (Vector).
+Canonical cell-position property; matches the `grid_pos' constructor
+argument.
+Decoupled from draw_pos.
+Determines which cell this entity logically occupies for collision,
+pathfinding, etc.
+- \f[V]grid_x\f[R]: Integer X cell coordinate.
+Canonical; matches grid_pos.
+- \f[V]grid_y\f[R]: Integer Y cell coordinate.
+Canonical; matches grid_pos.
- \f[V]labels\f[R]: Set of string labels for collision/targeting
(frozenset).
Assign any iterable of strings to replace all labels.
@@ -1970,6 +1974,13 @@ Set to None to clear.
- \f[V]target_label\f[R]: Label to search for with TARGET trigger (str
or None).
Default: None.
+- \f[V]texture\f[R]: Sprite texture atlas (Texture).
+Defaults to mcrfpy.default_texture when the entity is constructed
+without one.
+Setting preserves sprite_index (the index is not re-validated against
+the new atlas).
+The grid\[cq]s texture only determines cell size; entities draw with
+their own.
- \f[V]tile_height\f[R]: Entity height in tiles (int).
Must be >= 1.
Default 1.
diff --git a/src/GridData.cpp b/src/GridData.cpp
index 8829723..c385cad 100644
--- a/src/GridData.cpp
+++ b/src/GridData.cpp
@@ -2,8 +2,27 @@
#include "GridData.h"
#include "UIEntity.h"
#include "PyTexture.h"
+#include "UIGrid.h" // #313 - markDirty forwards to the UIGrid subobject
+#include "UIGridView.h" // #313 - and notifies owning_view
#include
+// #313 - Render invalidation from the data layer (see GridData.h).
+// GridData is never independently heap-allocated (always a UIGrid base
+// subobject), so the downcast is valid; remove once #252 allows pure GridData.
+void GridData::markDirty() {
+ static_cast(this)->UIDrawable::markDirty();
+ if (auto view = owning_view.lock()) {
+ view->markDirty();
+ }
+}
+
+void GridData::markCompositeDirty() {
+ static_cast(this)->UIDrawable::markCompositeDirty();
+ if (auto view = owning_view.lock()) {
+ view->markCompositeDirty();
+ }
+}
+
GridData::GridData()
{
entities = std::make_shared>>();
diff --git a/src/GridData.h b/src/GridData.h
index 2f5d15a..878e643 100644
--- a/src/GridData.h
+++ b/src/GridData.h
@@ -39,6 +39,14 @@ public:
// =========================================================================
int grid_w = 0, grid_h = 0;
+ // #313 - Cell pixel dimensions, mirrored from the owning UIGrid's texture
+ // at construction (UIGrid::ptex is write-once, ctor only). Lets the data
+ // layer do tile<->pixel math without reaching into rendering state.
+ int cell_width_px = 16;
+ int cell_height_px = 16;
+ int cell_width() const { return cell_width_px; }
+ int cell_height() const { return cell_height_px; }
+
// #123 - Chunk-based storage for large grid support
std::unique_ptr chunk_manager;
// Legacy flat storage (kept for small grids or compatibility)
@@ -136,6 +144,17 @@ public:
// =========================================================================
std::weak_ptr owning_view;
+ // #313 - Render invalidation from the data layer. Entities hold
+ // shared_ptr but still need to invalidate rendering when their
+ // visual state changes. These set the dirty flags on the UIGrid subobject
+ // (GridData is never independently heap-allocated -- always a UIGrid base)
+ // AND notify owning_view, covering both render paths (a bare _GridData
+ // rendered directly, and the normal GridView). Within UIGrid itself the
+ // UIDrawable versions win via using-declarations (see UIGrid.h).
+ // Multi-view broadcast (secondary views) is deferred to #252.
+ void markDirty();
+ void markCompositeDirty();
+
protected:
// Initialize grid storage (flat or chunked) and TCOD map
void initStorage(int gx, int gy, GridData* parent_ref);
diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp
index 8f71d0b..d0480d1 100644
--- a/src/UIEntity.cpp
+++ b/src/UIEntity.cpp
@@ -20,8 +20,21 @@
#include "PyUniformCollection.h" // #106: Uniform collection support
// UIDrawable methods now in UIBase.h
#include "UIEntityPyMethods.h"
+#include
-
+// #313: UIEntity::grid holds the GridData base, but some Python wrappers
+// (PyUIGridObject, PyUIGridPointObject) and pathfinding helpers still take the
+// full UIGrid. GridData is never independently heap-allocated -- it is always
+// a UIGrid base subobject (see GridData.h) -- so this aliasing downcast is
+// valid and shares the original control block (never mints a new one, which
+// would double-free and break the #251 use_count dealloc gate).
+// TODO(#252): remove once those wrappers accept pure GridData.
+static std::shared_ptr grid_as_uigrid(const std::shared_ptr& grid)
+{
+ if (!grid) return nullptr;
+ assert(dynamic_cast(grid.get()) != nullptr);
+ return std::shared_ptr(grid, static_cast(grid.get()));
+}
UIEntity::UIEntity()
: grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f)
@@ -133,7 +146,7 @@ PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
- obj->grid = entity->grid;
+ obj->grid = grid_as_uigrid(entity->grid); // #313: wrapper still holds UIGrid
obj->x = x;
obj->y = y;
return (PyObject*)obj;
@@ -310,14 +323,11 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
// Handle grid attachment
if (grid_obj) {
- std::shared_ptr grid_ptr;
+ std::shared_ptr grid_ptr;
if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
- // #252: GridView (unified Grid) - extract internal UIGrid
+ // #252: GridView (unified Grid) - share its grid data directly (#313)
PyUIGridViewObject* pyview = (PyUIGridViewObject*)grid_obj;
- if (pyview->data->grid_data) {
- grid_ptr = std::shared_ptr(
- pyview->data->grid_data, static_cast(pyview->data->grid_data.get()));
- }
+ grid_ptr = pyview->data->grid_data;
} else {
// Internal _GridData type
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
@@ -495,6 +505,50 @@ int UIEntity::set_spritenumber(PyUIEntityObject* self, PyObject* value, void* cl
return 0;
}
+// #313 - texture property: thin wrapper over the entity's own UISprite.
+// Entities render from their own texture (falling back to default_texture at
+// construction); the grid's texture is only used for cell-size math.
+PyObject* UIEntity::get_texture(PyUIEntityObject* self, void* closure) {
+ if (!self->data) {
+ // Entity.__new__ without __init__ leaves data null
+ PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object");
+ return NULL;
+ }
+ auto tex = self->data->sprite.getTexture();
+ if (!tex) {
+ // Only reachable if default_texture was null at construction
+ Py_RETURN_NONE;
+ }
+ return tex->pyObject();
+}
+
+int UIEntity::set_texture(PyUIEntityObject* self, PyObject* value, void* closure) {
+ if (!self->data) {
+ PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object");
+ return -1;
+ }
+ if (!value) {
+ PyErr_SetString(PyExc_TypeError, "Cannot delete texture attribute");
+ return -1;
+ }
+ int is_texture = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyTextureType);
+ if (is_texture == -1) return -1; // isinstance itself raised
+ if (!is_texture) {
+ PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance");
+ return -1;
+ }
+ auto pytexture = (PyTextureObject*)value;
+ if (!pytexture->data) {
+ // Texture.__new__ without __init__ leaves data null (same guard as UISprite)
+ PyErr_SetString(PyExc_ValueError, "Invalid texture object");
+ return -1;
+ }
+ // Preserves sprite_index (not re-validated against the new atlas)
+ self->data->sprite.setTexture(pytexture->data);
+ if (self->data->grid) self->data->grid->markDirty();
+ return 0;
+}
+
PyObject* UIEntity::get_float_member(PyUIEntityObject* self, void* closure)
{
auto member_ptr = reinterpret_cast(closure);
@@ -550,14 +604,15 @@ int UIEntity::set_float_member(PyUIEntityObject* self, PyObject* value, void* cl
// #176 - Helper to get cell dimensions from grid
static void get_cell_dimensions(UIEntity* entity, float& cell_width, float& cell_height) {
- // Default cell dimensions when no texture
+ // Default cell dimensions when no grid is attached
constexpr float DEFAULT_CELL_WIDTH = 16.0f;
constexpr float DEFAULT_CELL_HEIGHT = 16.0f;
if (entity->grid) {
- auto ptex = entity->grid->getTexture();
- cell_width = ptex ? static_cast(ptex->sprite_width) : DEFAULT_CELL_WIDTH;
- cell_height = ptex ? static_cast(ptex->sprite_height) : DEFAULT_CELL_HEIGHT;
+ // #313: cell size lives on the data layer (mirrored from the grid's
+ // texture at construction) -- entities no longer reach into rendering.
+ cell_width = static_cast(entity->grid->cell_width());
+ cell_height = static_cast(entity->grid->cell_height());
} else {
cell_width = DEFAULT_CELL_WIDTH;
cell_height = DEFAULT_CELL_HEIGHT;
@@ -737,8 +792,11 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
}
// Fallback: return internal _GridData wrapper (no owning view)
- if (grid->serial_number != 0) {
- PyObject* cached = PythonObjectCache::getInstance().lookup(grid->serial_number);
+ // #313: serial_number lives on the UIDrawable side; recover the full
+ // UIGrid via the aliasing helper (same control block, no new ownership).
+ auto uigrid = grid_as_uigrid(grid);
+ if (uigrid->serial_number != 0) {
+ PyObject* cached = PythonObjectCache::getInstance().lookup(uigrid->serial_number);
if (cached) {
return cached;
}
@@ -748,15 +806,15 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
if (pyGrid) {
- pyGrid->data = grid;
+ pyGrid->data = uigrid;
pyGrid->weakreflist = NULL;
- if (grid->serial_number == 0) {
- grid->serial_number = PythonObjectCache::getInstance().assignSerial();
+ if (uigrid->serial_number == 0) {
+ uigrid->serial_number = PythonObjectCache::getInstance().assignSerial();
}
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyGrid, NULL);
if (weakref) {
- PythonObjectCache::getInstance().registerObject(grid->serial_number, weakref);
+ PythonObjectCache::getInstance().registerObject(uigrid->serial_number, weakref);
Py_DECREF(weakref);
}
}
@@ -793,12 +851,11 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
}
// #252: Accept both internal _GridData and GridView (unified Grid)
- std::shared_ptr new_grid;
+ std::shared_ptr new_grid;
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyUIGridViewObject* pyview = (PyUIGridViewObject*)value;
if (pyview->data->grid_data) {
- new_grid = std::shared_ptr(
- pyview->data->grid_data, static_cast(pyview->data->grid_data.get()));
+ new_grid = pyview->data->grid_data; // #313: share data directly
} else {
PyErr_SetString(PyExc_ValueError, "Grid has no data");
return -1;
@@ -1140,9 +1197,11 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
auto grid = self->data->grid;
// Extract target position
+ // #313: ExtractPosition still takes UIGrid*; the downcast is valid because
+ // GridData is always a UIGrid base subobject (see grid_as_uigrid).
int target_x, target_y;
if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y,
- grid.get(), "target")) {
+ static_cast(grid.get()), "target")) {
return NULL;
}
@@ -1158,10 +1217,11 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
// Build args to delegate to Grid.find_path
// Create a temporary PyUIGridObject wrapper for the grid (internal _GridData type)
+ // #313: wrapper holds shared_ptr; alias-cast from the data ptr.
auto* grid_type = &mcrfpydef::PyUIGridType;
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
if (!pyGrid) return NULL;
- new (&pyGrid->data) std::shared_ptr(grid);
+ new (&pyGrid->data) std::shared_ptr(grid_as_uigrid(grid));
// Build keyword args for Grid.find_path
PyObject* start_tuple = Py_BuildValue("(ii)", start_x, start_y);
@@ -1817,6 +1877,12 @@ PyGetSetDef UIEntity::getsetters[] = {
"Get: Returns the Grid or None. "
"Set: Assign a Grid to move entity, or None to remove from grid.", NULL},
{"sprite_index", (getter)UIEntity::get_spritenumber, (setter)UIEntity::set_spritenumber, "Sprite index on the texture on the display", NULL},
+ // #313 - entities render from their OWN texture, not the grid's
+ {"texture", (getter)UIEntity::get_texture, (setter)UIEntity::set_texture,
+ "Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture when "
+ "the entity is constructed without one. Setting preserves sprite_index "
+ "(the index is not re-validated against the new atlas). The grid's "
+ "texture only determines cell size; entities draw with their own.", NULL},
{"visible", (getter)UIEntity_get_visible, (setter)UIEntity_set_visible, "Visibility flag", NULL},
{"opacity", (getter)UIEntity_get_opacity, (setter)UIEntity_set_opacity, "Opacity (0.0 = transparent, 1.0 = opaque)", NULL},
{"name", (getter)UIEntity_get_name, (setter)UIEntity_set_name, "Name for finding elements", NULL},
diff --git a/src/UIEntity.h b/src/UIEntity.h
index 4daccc4..003cd89 100644
--- a/src/UIEntity.h
+++ b/src/UIEntity.h
@@ -23,6 +23,7 @@
#include
class UIGrid;
+class GridData;
// UIEntity
/*
@@ -65,7 +66,12 @@ class UIEntity : public std::enable_shared_from_this
public:
uint64_t serial_number = 0; // For Python object cache
PyObject* pyobject = nullptr; // Strong ref: preserves Python subclass identity while in grid
- std::shared_ptr grid;
+ // #313: Entities depend on the grid DATA layer only (cells, entities,
+ // FOV, pathfinding, cell size). This is always an aliasing shared_ptr
+ // sharing a UIGrid's control block (GridData is never independently
+ // heap-allocated); Python wrappers that still need the full UIGrid use
+ // grid_as_uigrid() in UIEntity.cpp.
+ std::shared_ptr grid;
// Per-entity perspective memory (#294): 3-state DiscreteMap --
// 0 = unknown, 1 = discovered, 2 = visible. Lazily allocated on first
// access to entity.perspective_map (when a grid is set) and whenever
@@ -159,6 +165,9 @@ public:
static int set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_spritenumber(PyUIEntityObject* self, void* closure);
static int set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure);
+ // #313 - texture property (thin wrapper over the entity's own UISprite)
+ static PyObject* get_texture(PyUIEntityObject* self, void* closure);
+ static int set_texture(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_float_member(PyUIEntityObject* self, void* closure);
static int set_float_member(PyUIEntityObject* self, PyObject* value, void* closure);
@@ -265,6 +274,7 @@ namespace mcrfpydef {
" grid_x, grid_y (int): Integer tile coordinate components\n"
" draw_pos (Vector): Fractional tile position for smooth animation\n"
" perspective_map (DiscreteMap | None): 3-state per-entity FOV memory\n"
+ " texture (Texture): Texture atlas used by the entity's sprite\n"
" sprite_index (int): Current sprite index\n"
" visible (bool): Visibility state\n"
" opacity (float): Opacity value\n"
diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp
index 738e4a2..c7e0de2 100644
--- a/src/UIGrid.cpp
+++ b/src/UIGrid.cpp
@@ -58,6 +58,12 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = _ptex ? _ptex->sprite_height : DEFAULT_CELL_HEIGHT;
+ // #313: mirror into the GridData base so the data layer (and entities
+ // holding shared_ptr) can do tile<->pixel math. ptex is
+ // write-once (ctor only), so this never goes stale.
+ cell_width_px = cell_width;
+ cell_height_px = cell_height;
+
center_x = (gx/2) * cell_width;
center_y = (gy/2) * cell_height;
diff --git a/src/UIGrid.h b/src/UIGrid.h
index 42a9fdc..b2d2d00 100644
--- a/src/UIGrid.h
+++ b/src/UIGrid.h
@@ -46,6 +46,13 @@ public:
UIGrid();
UIGrid(int, int, std::shared_ptr, sf::Vector2f, sf::Vector2f);
~UIGrid();
+
+ // #313: GridData also declares markDirty/markCompositeDirty (data-layer
+ // invalidation for entities holding shared_ptr). Within UIGrid,
+ // keep resolving unqualified calls to the UIDrawable flag-setters --
+ // identical to pre-#313 behavior.
+ using UIDrawable::markDirty;
+ using UIDrawable::markCompositeDirty;
void update();
void render(sf::Vector2f, sf::RenderTarget&) override final;
PyObjectsEnum derived_type() override final;
diff --git a/src/UIGridView.h b/src/UIGridView.h
index ee46af7..726b7c1 100644
--- a/src/UIGridView.h
+++ b/src/UIGridView.h
@@ -138,8 +138,16 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
- // Clear owning_view back-reference before releasing grid_data
- if (obj->data && obj->data->grid_data) {
+ // Clear owning_view back-reference before releasing grid_data --
+ // but only when this wrapper is the LAST owner of the view (#251
+ // pattern, mirrors PyUIGridType) AND the dying view is actually
+ // the one owning_view points at. The previous ungated reset
+ // severed the back-reference whenever ANY Python wrapper was
+ // GC'd while the C++ view lived on (e.g. held by scene.children),
+ // breaking entity.grid -> Grid identity and the #313 data-layer
+ // dirty notifications.
+ if (obj->data && obj->data->grid_data && obj->data.use_count() <= 1 &&
+ obj->data->grid_data->owning_view.lock() == obj->data) {
obj->data->grid_data->owning_view.reset();
}
obj->data.reset();
diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi
index b635983..180645b 100644
--- a/stubs/mcrfpy.pyi
+++ b/stubs/mcrfpy.pyi
@@ -653,15 +653,15 @@ class Entity:
"""A game entity that exists on a grid with sprite rendering."""
def __init__(self, grid_pos=None, texture=None, sprite_index=0, **kwargs) -> None: ...
behavior_type: int # Current behavior type (int, read-only). Use set_behavior() to change.
- cell_pos: Vector # Integer logical cell position (Vector). Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
- cell_x: Any # Integer X cell coordinate.
- cell_y: Any # Integer Y cell coordinate.
+ cell_pos: Vector # Integer logical cell position (Vector). Alias for grid_pos (the canonical name).
+ cell_x: Any # Integer X cell coordinate. Alias for grid_x.
+ cell_y: Any # Integer Y cell coordinate. Alias for grid_y.
default_behavior: int # Default behavior type (int, maps to Behavior enum). Entity reverts to this after DONE trigger. Default: 0 (IDLE).
draw_pos: Vector # Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.
grid: Any # Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid.
- grid_pos: Vector # Grid position as integer cell coordinates (Vector). Alias for cell_pos.
- grid_x: Any # Grid X position as integer cell coordinate. Alias for cell_x.
- grid_y: Any # Grid Y position as integer cell coordinate. Alias for cell_y.
+ grid_pos: Vector # Integer logical cell position (Vector). Canonical cell-position property; matches the 'grid_pos' constructor argument. Decoupled from draw_pos. Determines wh...
+ grid_x: Any # Integer X cell coordinate. Canonical; matches grid_pos.
+ grid_y: Any # Integer Y cell coordinate. Canonical; matches grid_pos.
labels: frozenset # Set of string labels for collision/targeting (frozenset). Assign any iterable of strings to replace all labels.
move_speed: float # Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15.
name: Any # Name for finding elements
@@ -677,6 +677,7 @@ class Entity:
sprite_offset_y: Any # Y component of sprite pixel offset.
step: Any # Step callback for grid.step() turn management. Called with (trigger, data) when behavior triggers fire. Set to None to clear.
target_label: Any # Label to search for with TARGET trigger (str or None). Default: None.
+ texture: Texture # Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture when the entity is constructed without one. Setting preserves sprite_index (the index is n...
tile_height: int # Entity height in tiles (int). Must be >= 1. Default 1.
tile_size: Any # Entity size in tiles as (width, height) Vector. Default (1, 1).
tile_width: int # Entity width in tiles (int). Must be >= 1. Default 1.
diff --git a/tests/regression/issue_313_entity_grid_data_test.py b/tests/regression/issue_313_entity_grid_data_test.py
new file mode 100644
index 0000000..b270dc9
--- /dev/null
+++ b/tests/regression/issue_313_entity_grid_data_test.py
@@ -0,0 +1,164 @@
+# Regression test for #313: UIEntity::grid -> shared_ptr + entity.texture
+#
+# Guards the invariants that must survive the #313 refactor:
+# (a) entity.texture getter returns the entity's real Texture (never crashes)
+# (b) setter accepts Texture only (TypeError otherwise), reflected on read-back,
+# and preserves sprite_index
+# (c) entity pixel position (x/y/pos) is derived from the GRID's cell size,
+# NOT the entity's own texture -- setting a different-sized texture on the
+# entity must not move it
+# (d) entity.grid returns the same Grid (GridView) object across step/FOV cycles
+# (PythonObjectCache identity)
+# (e) find_path() and at() still work, and constructing their temporary internal
+# grid wrappers does not destroy the grid's cell callbacks (#251 guard)
+# (f) a GridPoint obtained from entity.at() outlives the entity and the grid
+# reference without crashing (aliasing shared_ptr keeps the grid alive)
+import mcrfpy
+import gc
+import sys
+
+failures = []
+
+def check(cond, label):
+ status = "ok" if cond else "FAIL"
+ print(" %s: %s" % (status, label))
+ if not cond:
+ failures.append(label)
+
+def main():
+ tex16 = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
+ grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex16, pos=(0, 0), size=(160, 160))
+ for y in range(10):
+ for x in range(10):
+ p = grid.at(x, y)
+ p.walkable = True
+ p.transparent = True
+
+ entity = mcrfpy.Entity(grid_pos=(2, 2), texture=tex16, sprite_index=5, grid=grid)
+
+ # --- (a) getter returns the real texture ---
+ t = entity.texture
+ check(t is not None, "(a) entity.texture is not None")
+ check(isinstance(t, mcrfpy.Texture), "(a) entity.texture is a Texture")
+ check(t.sprite_width == 16 and t.sprite_height == 16,
+ "(a) entity.texture has the constructor texture's sprite dims")
+ check(t.source == tex16.source, "(a) entity.texture.source matches constructor texture")
+
+ # --- (c) pixel position comes from the grid's cell size, not the entity texture ---
+ x_before, y_before = entity.x, entity.y
+ check(x_before == 2 * 16 and y_before == 2 * 16,
+ "(c) pixel pos = grid_pos * grid cell size before texture change")
+
+ # an 8x8 texture with 8x8 sprites (single red sprite)
+ tex8 = mcrfpy.Texture.from_bytes(bytes([255, 0, 0, 255] * 64), 8, 8, 8, 8,
+ name="")
+
+ # --- (b) setter semantics ---
+ entity.texture = tex8
+ check(entity.texture.sprite_width == 8 and entity.texture.sprite_height == 8,
+ "(b) texture set is reflected on read-back")
+ check(entity.sprite_index == 5, "(b) sprite_index preserved across texture set")
+ try:
+ entity.texture = 42
+ check(False, "(b) non-Texture assignment raises TypeError")
+ except TypeError:
+ check(True, "(b) non-Texture assignment raises TypeError")
+ try:
+ entity.texture = None
+ check(False, "(b) None assignment raises TypeError")
+ except TypeError:
+ check(True, "(b) None assignment raises TypeError")
+ try:
+ del entity.texture
+ check(False, "(b) attribute deletion raises TypeError")
+ except TypeError:
+ check(True, "(b) attribute deletion raises TypeError")
+
+ # --- (c) continued: position unchanged after the 8x8 texture swap ---
+ check(entity.x == x_before and entity.y == y_before,
+ "(c) pixel pos unchanged after setting a different-sized entity texture")
+
+ # --- (d) entity.grid identity across a step/FOV cycle ---
+ check(entity.grid is grid, "(d) entity.grid is the user-created Grid object")
+ grid.compute_fov((2, 2), radius=5)
+ grid.step(1)
+ check(entity.grid is grid, "(d) entity.grid identity survives step + compute_fov")
+
+ # --- (e) find_path / at() work; cell callbacks survive temp grid wrappers ---
+ grid.on_cell_click = lambda pos, btn, action: None
+ path = entity.find_path((5, 5))
+ check(path is not None, "(e) find_path returns a result on an open grid")
+ check(grid.on_cell_click is not None,
+ "(e) grid.on_cell_click survives find_path's temporary grid wrapper (#251)")
+
+ entity.update_visibility()
+ gp = entity.at(2, 2)
+ check(gp is not None, "(e) entity.at(own cell) returns a GridPoint when visible")
+ check(gp.walkable is True, "(e) GridPoint from entity.at() reads cell data")
+ check(grid.on_cell_click is not None,
+ "(e) grid.on_cell_click survives entity.at()'s grid wrapper (#251)")
+
+ # --- (g) hardening guards from the #313 adversarial review ---
+ # Null-data Texture (Texture.__new__ without __init__) must be rejected,
+ # not crash (same ValueError guard as UISprite.texture).
+ null_tex = mcrfpy.Texture.__new__(mcrfpy.Texture)
+ try:
+ entity.texture = null_tex
+ check(False, "(g) null-data Texture assignment raises ValueError")
+ except ValueError:
+ check(True, "(g) null-data Texture assignment raises ValueError")
+ # An object whose __class__ raises must not crash isinstance handling.
+ class EvilMeta:
+ @property
+ def __class__(self):
+ raise RuntimeError("no class for you")
+ try:
+ entity.texture = EvilMeta()
+ check(False, "(g) isinstance-error during assignment raises, no crash")
+ except (TypeError, RuntimeError):
+ check(True, "(g) isinstance-error during assignment raises, no crash")
+ # Uninitialized Entity wrapper (Entity.__new__ without __init__) must
+ # raise RuntimeError on texture access, not segfault.
+ uninit = mcrfpy.Entity.__new__(mcrfpy.Entity)
+ try:
+ _ = uninit.texture
+ check(False, "(g) uninitialized Entity texture get raises RuntimeError")
+ except RuntimeError:
+ check(True, "(g) uninitialized Entity texture get raises RuntimeError")
+ try:
+ uninit.texture = tex16
+ check(False, "(g) uninitialized Entity texture set raises RuntimeError")
+ except RuntimeError:
+ check(True, "(g) uninitialized Entity texture set raises RuntimeError")
+
+ # --- (h) owning_view survives Python wrapper GC while the C++ view lives ---
+ # The canonical idiom: append the Grid to a scene, drop the Python
+ # reference. The data-layer dirty notifications and entity.grid -> Grid
+ # identity both depend on owning_view staying intact.
+ scene = mcrfpy.Scene("issue313_ov")
+ g2 = mcrfpy.Grid(grid_size=(4, 4), texture=tex16, pos=(0, 0), size=(64, 64))
+ e2 = mcrfpy.Entity(grid_pos=(1, 1), texture=tex16, grid=g2)
+ scene.children.append(g2)
+ del g2
+ gc.collect()
+ check(type(e2.grid).__name__ == "Grid",
+ "(h) entity.grid still returns Grid after wrapper GC (owning_view intact)")
+
+ # --- (f) GridPoint outlives entity + grid references ---
+ entity.die()
+ del entity
+ del grid
+ gc.collect()
+ check(gp.walkable is True, "(f) GridPoint usable after entity.die() and grid release")
+ check(tuple(gp.grid_pos) == (2, 2), "(f) GridPoint position intact after teardown")
+ del gp
+ gc.collect()
+
+ if failures:
+ print("FAIL: %d check(s) failed" % len(failures))
+ sys.exit(1)
+ print("PASS")
+ sys.exit(0)
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/snapshots/api_surface.golden.txt b/tests/snapshots/api_surface.golden.txt
index 53c0130..33690af 100644
--- a/tests/snapshots/api_surface.golden.txt
+++ b/tests/snapshots/api_surface.golden.txt
@@ -465,6 +465,7 @@ submodule automation
prop sprite_offset_y: Any (rw)
prop step: Any (rw)
prop target_label: Any (rw)
+ prop texture: Texture (rw)
prop tile_height: int (rw)
prop tile_size: Any (rw)
prop tile_width: int (rw)
@@ -1229,4 +1230,4 @@ func typewrite :: typewrite(message, interval=0.0) - Type text with optional int
=== WRITABILITY PROBES (#313-touched properties) ===
Entity.grid: writable
Entity.sprite_index: writable
- Entity.texture: ABSENT
+ Entity.texture: writable
diff --git a/tests/unit/entity_texture_test.py b/tests/unit/entity_texture_test.py
new file mode 100644
index 0000000..dc0527f
--- /dev/null
+++ b/tests/unit/entity_texture_test.py
@@ -0,0 +1,48 @@
+# Unit test for entity.texture (#313): happy-path get/set semantics.
+import mcrfpy
+import sys
+
+failures = []
+
+def check(cond, label):
+ status = "ok" if cond else "FAIL"
+ print(" %s: %s" % (status, label))
+ if not cond:
+ failures.append(label)
+
+def main():
+ # Entity constructed WITHOUT a texture falls back to mcrfpy.default_texture
+ e_default = mcrfpy.Entity(grid_pos=(0, 0))
+ dt = mcrfpy.default_texture
+ check(e_default.texture is not None, "no-texture entity exposes a texture")
+ check(isinstance(e_default.texture, mcrfpy.Texture),
+ "no-texture entity texture is a Texture")
+ check(e_default.texture.source == dt.source and
+ e_default.texture.sprite_width == dt.sprite_width,
+ "no-texture entity uses mcrfpy.default_texture")
+
+ # Entity constructed WITH a texture exposes that texture
+ tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
+ e = mcrfpy.Entity(grid_pos=(1, 1), texture=tex, sprite_index=3)
+ check(e.texture.source == tex.source, "constructor texture is exposed")
+ check(e.texture.sprite_count == tex.sprite_count,
+ "exposed texture matches constructor texture's sprite_count")
+
+ # Setting a texture is reflected on read-back; sprite_index preserved
+ tex2 = mcrfpy.Texture.from_bytes(bytes([0, 255, 0, 255] * 256), 16, 16, 16, 16,
+ name="")
+ e.texture = tex2
+ check(e.texture.source == tex2.source, "assigned texture is exposed")
+ check(e.sprite_index == 3, "sprite_index preserved across texture assignment")
+
+ # Works without a grid attachment (no crash, no grid needed)
+ check(e_default.texture is not None, "texture access works without grid attachment")
+
+ if failures:
+ print("FAIL: %d check(s) failed" % len(failures))
+ sys.exit(1)
+ print("PASS")
+ sys.exit(0)
+
+if __name__ == "__main__":
+ main()