BSP: add safety features and API improvements (closes #202, #203, #204, #205, #206)

Safety improvements:
- Generation counter detects stale BSPNode references after clear()/split_recursive()
- GRID_MAX validation prevents oversized BSP trees
- Depth parameter capped at 16 to prevent resource exhaustion
- Iterator checks generation to detect invalidation during mutation

API improvements:
- Changed constructor from bounds=((x,y),(w,h)) to pos=(x,y), size=(w,h)
- Added pos and size properties alongside bounds
- BSPNode __eq__ compares underlying pointers for identity
- BSP __iter__ as shorthand for leaves()
- BSP __len__ returns leaf count

Tests:
- Added tests for stale node detection, GRID_MAX validation, depth cap
- Added tests for __len__, __iter__, and BSPNode equality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-12 07:59:31 -05:00
commit 6caf3dcd05
4 changed files with 442 additions and 118 deletions

View file

@ -3,17 +3,23 @@
#include "Python.h"
#include <libtcod.h>
#include <vector>
#include <cstdint>
// Forward declarations
class PyBSP;
class PyBSPNode;
// Maximum recursion depth to prevent memory exhaustion
// 2^16 = 65536 potential leaf nodes, which is already excessive
constexpr int BSP_MAX_DEPTH = 16;
// Python object structure for BSP tree (root owner)
typedef struct {
PyObject_HEAD
TCOD_bsp_t* root; // libtcod BSP root (owned, will be deleted)
int orig_x, orig_y; // Original bounds for clear()
int orig_w, orig_h;
uint64_t generation; // Incremented on structural changes (clear, split)
} PyBSPObject;
// Python object structure for BSPNode (lightweight reference)
@ -21,6 +27,7 @@ typedef struct {
PyObject_HEAD
TCOD_bsp_t* node; // libtcod BSP node (NOT owned)
PyObject* bsp_owner; // Reference to PyBSPObject to prevent dangling
uint64_t generation; // Generation at time of creation (for validity check)
} PyBSPNodeObject;
// BSP iterator for traverse()
@ -29,6 +36,7 @@ typedef struct {
std::vector<TCOD_bsp_t*>* nodes; // Pre-collected nodes
size_t index;
PyObject* bsp_owner; // Reference to PyBSPObject
uint64_t generation; // Generation at iterator creation
} PyBSPIterObject;
class PyBSP
@ -42,6 +50,8 @@ public:
// Properties
static PyObject* get_bounds(PyBSPObject* self, void* closure);
static PyObject* get_pos(PyBSPObject* self, void* closure);
static PyObject* get_size(PyBSPObject* self, void* closure);
static PyObject* get_root(PyBSPObject* self, void* closure);
// Splitting methods (#202)
@ -59,9 +69,14 @@ public:
// HeightMap conversion (#206)
static PyObject* to_heightmap(PyBSPObject* self, PyObject* args, PyObject* kwds);
// Sequence protocol
static Py_ssize_t len(PyBSPObject* self);
static PyObject* iter(PyBSPObject* self);
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
static PySequenceMethods sequence_methods;
};
class PyBSPNode
@ -73,8 +88,13 @@ public:
static void dealloc(PyBSPNodeObject* self);
static PyObject* repr(PyObject* obj);
// Comparison
static PyObject* richcompare(PyObject* self, PyObject* other, int op);
// Properties (#203)
static PyObject* get_bounds(PyBSPNodeObject* self, void* closure);
static PyObject* get_pos(PyBSPNodeObject* self, void* closure);
static PyObject* get_size(PyBSPNodeObject* self, void* closure);
static PyObject* get_level(PyBSPNodeObject* self, void* closure);
static PyObject* get_is_leaf(PyBSPNodeObject* self, void* closure);
static PyObject* get_split_horizontal(PyBSPNodeObject* self, void* closure);
@ -93,6 +113,9 @@ public:
// Helper to create a BSPNode from a TCOD_bsp_t*
static PyObject* create(TCOD_bsp_t* node, PyObject* bsp_owner);
// Validity check - returns false and sets error if node is stale
static bool checkValid(PyBSPNodeObject* self);
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
@ -116,6 +139,8 @@ public:
static PyObject* traversal_enum_class;
static PyObject* create_enum_class(PyObject* module);
static int from_arg(PyObject* arg, int* out_order);
// Cleanup for module finalization (optional)
static void cleanup();
};
namespace mcrfpydef {
@ -127,23 +152,31 @@ namespace mcrfpydef {
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyBSP::dealloc,
.tp_repr = PyBSP::repr,
.tp_as_sequence = &PyBSP::sequence_methods,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"BSP(bounds: tuple[tuple[int, int], tuple[int, int]])\n\n"
"BSP(pos: tuple[int, int], size: tuple[int, int])\n\n"
"Binary Space Partitioning tree for procedural dungeon generation.\n\n"
"BSP recursively divides a rectangular region into smaller sub-regions, "
"creating a tree structure perfect for generating dungeon rooms and corridors.\n\n"
"Args:\n"
" bounds: ((x, y), (w, h)) - Position and size of the root region.\n\n"
" pos: (x, y) - Top-left position of the root region.\n"
" size: (w, h) - Width and height of the root region.\n\n"
"Properties:\n"
" bounds ((x, y), (w, h)): Read-only. Root node bounds.\n"
" pos (tuple[int, int]): Read-only. Top-left position (x, y).\n"
" size (tuple[int, int]): Read-only. Dimensions (width, height).\n"
" bounds ((pos), (size)): Read-only. Combined position and size.\n"
" root (BSPNode): Read-only. Reference to the root node.\n\n"
"Iteration:\n"
" for leaf in bsp: # Iterates over leaf nodes (rooms)\n"
" len(bsp) # Returns number of leaf nodes\n\n"
"Example:\n"
" bsp = mcrfpy.BSP(bounds=((0, 0), (80, 50)))\n"
" bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 50))\n"
" bsp.split_recursive(depth=4, min_size=(8, 8))\n"
" for leaf in bsp.leaves():\n"
" print(f'Room at {leaf.bounds}')\n"
" for leaf in bsp:\n"
" print(f'Room at {leaf.pos}, size {leaf.size}')\n"
),
.tp_iter = (getiterfunc)PyBSP::iter,
.tp_methods = nullptr, // Set in McRFPy_API.cpp
.tp_getset = nullptr, // Set in McRFPy_API.cpp
.tp_init = (initproc)PyBSP::init,
@ -163,8 +196,13 @@ namespace mcrfpydef {
"BSPNode - Lightweight reference to a node in a BSP tree.\n\n"
"BSPNode provides read-only access to node properties and navigation.\n"
"Nodes are created by BSP methods, not directly instantiated.\n\n"
"WARNING: BSPNode references become invalid after BSP.clear() or\n"
"BSP.split_recursive(). Accessing properties of an invalid node\n"
"raises RuntimeError.\n\n"
"Properties:\n"
" bounds ((x, y), (w, h)): Position and size of this node.\n"
" pos (tuple[int, int]): Top-left position (x, y).\n"
" size (tuple[int, int]): Dimensions (width, height).\n"
" bounds ((pos), (size)): Combined position and size.\n"
" level (int): Depth in tree (0 for root).\n"
" is_leaf (bool): True if this node has no children.\n"
" split_horizontal (bool | None): Split orientation, None if leaf.\n"
@ -174,6 +212,7 @@ namespace mcrfpydef {
" parent (BSPNode | None): Parent node, or None if root.\n"
" sibling (BSPNode | None): Other child of parent, or None.\n"
),
.tp_richcompare = PyBSPNode::richcompare,
.tp_methods = nullptr, // Set in McRFPy_API.cpp
.tp_getset = nullptr, // Set in McRFPy_API.cpp
.tp_init = (initproc)PyBSPNode::init,