From 8af3bf45b6c2a50ca211fae9de00d83c440cdcd3 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 11 Jul 2026 01:00:53 -0400 Subject: [PATCH] feat(discretemap): buffer protocol for zero-copy numpy views; closes #334 DiscreteMap now implements tp_as_buffer/bf_getbuffer, exposing its dense uint8 storage as a 2-D (height, width) C-contiguous writable view. memoryview(dmap) and np.asarray(dmap) share memory with the map -- no copy -- so per-entity FOV memory (#294 perspective_map) and other DiscreteMaps can be inspected/edited with numpy at native speed. The exporter owns the buffer via its shared_ptr; 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) --- src/PyDiscreteMap.cpp | 43 ++++++++++++ src/PyDiscreteMap.h | 7 ++ .../issue_334_discretemap_buffer_test.py | 65 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 tests/regression/issue_334_discretemap_buffer_test.py diff --git a/src/PyDiscreteMap.cpp b/src/PyDiscreteMap.cpp index 3f4ba66..1ce450a 100644 --- a/src/PyDiscreteMap.cpp +++ b/src/PyDiscreteMap.cpp @@ -1317,6 +1317,49 @@ PyObject* PyDiscreteMap::mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(arg return PyMemoryView_FromMemory(reinterpret_cast(self->values), len, PyBUF_WRITE); } +// ============================================================================ +// Buffer protocol (#334) - zero-copy 2D (h, w) uint8 view. +// np.asarray(dmap) -> shape (h, w), dtype uint8, C-contiguous, writable. +// The exporter (this object) owns the buffer via its shared_ptr; +// INCREF keeps it alive for the view's lifetime (balanced by the default +// PyBuffer_Release DECREF -- no bf_releasebuffer needed). shape/strides live on +// the object (dimensions are immutable after construction, so concurrent views +// safely share them). +// ============================================================================ +int PyDiscreteMap::getbuffer(PyObject* exporter, Py_buffer* view, int flags) +{ + PyDiscreteMapObject* self = reinterpret_cast(exporter); + if (!self->values) { + PyErr_SetString(PyExc_RuntimeError, "DiscreteMap not initialized"); + view->obj = nullptr; + return -1; + } + + self->buf_shape[0] = self->h; // rows + self->buf_shape[1] = self->w; // cols + self->buf_strides[0] = self->w; // itemsize == 1, so byte strides == element strides + self->buf_strides[1] = 1; + + view->buf = self->values; + view->obj = exporter; + Py_INCREF(exporter); + view->len = static_cast(self->w) * static_cast(self->h); + view->readonly = 0; + view->itemsize = 1; + view->format = (flags & PyBUF_FORMAT) ? const_cast("B") : nullptr; + view->ndim = 2; + view->shape = self->buf_shape; + view->strides = (flags & PyBUF_STRIDES) ? self->buf_strides : nullptr; + view->suboffsets = nullptr; + view->internal = nullptr; + return 0; +} + +PyBufferProcs PyDiscreteMap::as_buffer = { + .bf_getbuffer = PyDiscreteMap::getbuffer, + .bf_releasebuffer = nullptr, +}; + // ============================================================================ // Serialization // ============================================================================ diff --git a/src/PyDiscreteMap.h b/src/PyDiscreteMap.h index 65bd638..30b132c 100644 --- a/src/PyDiscreteMap.h +++ b/src/PyDiscreteMap.h @@ -22,6 +22,8 @@ typedef struct { uint8_t* values; // cached data->data() int w, h; // cached data->width()/height() PyObject* enum_type; // Optional Python IntEnum for value interpretation + Py_ssize_t buf_shape[2]; // #334 - buffer-protocol (h, w) view shape + Py_ssize_t buf_strides[2]; // #334 - row-major strides {w, 1} } PyDiscreteMapObject; class PyDiscreteMap @@ -74,6 +76,10 @@ public: static PyObject* to_bool(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds); static PyObject* mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args)); + // Buffer protocol (#334) - zero-copy numpy view: np.asarray(dmap) -> (h, w) uint8 + static int getbuffer(PyObject* exporter, Py_buffer* view, int flags); + static PyBufferProcs as_buffer; + // Serialization static PyObject* to_bytes(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args)); static PyObject* from_bytes(PyTypeObject* type, PyObject* args, PyObject* kwds); @@ -99,6 +105,7 @@ namespace mcrfpydef { .tp_dealloc = (destructor)PyDiscreteMap::dealloc, .tp_repr = PyDiscreteMap::repr, .tp_as_mapping = &PyDiscreteMap::mapping_methods, // dmap[x, y] subscript + .tp_as_buffer = &PyDiscreteMap::as_buffer, // #334 - np.asarray zero-copy .tp_flags = Py_TPFLAGS_DEFAULT, .tp_doc = PyDoc_STR( "DiscreteMap(size: tuple[int, int], fill: int = 0, enum: type[IntEnum] = None)\n\n" diff --git a/tests/regression/issue_334_discretemap_buffer_test.py b/tests/regression/issue_334_discretemap_buffer_test.py new file mode 100644 index 0000000..d8e8336 --- /dev/null +++ b/tests/regression/issue_334_discretemap_buffer_test.py @@ -0,0 +1,65 @@ +"""Regression test for #334 - DiscreteMap buffer protocol (zero-copy numpy view). + +DiscreteMap now implements the C buffer protocol (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 edits through either side are visible on the other. + +Direct-execution style (no game loop needed). +""" +import mcrfpy +import sys + + +def main(): + # width=5, height=3 -> buffer shape is (h, w) = (3, 5) + dm = mcrfpy.DiscreteMap((5, 3), fill=0) + dm[1, 2] = 7 # dmap indexing is [x, y] + + mv = memoryview(dm) + assert mv.shape == (3, 5), f"shape {mv.shape}, expected (3, 5)" + assert mv.format == 'B', f"format {mv.format}, expected 'B' (uint8)" + assert mv.ndim == 2, f"ndim {mv.ndim}" + assert mv.readonly is False, "buffer should be writable" + assert mv.strides == (5, 1), f"strides {mv.strides}, expected row-major (5, 1)" + + # Row-major mapping: dmap[x, y] == buffer[y, x] + assert mv[2, 1] == 7, f"buffer[y=2,x=1] {mv[2, 1]}, expected 7" + + # Zero-copy writeback: buffer -> dmap + mv[0, 4] = 9 # y=0, x=4 + assert dm[4, 0] == 9, f"dmap[4,0] {dm[4, 0]}, expected 9 (write via buffer)" + + # ... and dmap -> buffer + dm[3, 1] = 4 # x=3, y=1 + assert mv[1, 3] == 4, f"buffer[y=1,x=3] {mv[1, 3]}, expected 4 (write via dmap)" + + # Optional numpy path (only if bundled); asserts true zero-copy sharing. + try: + import numpy as np + a = np.asarray(dm) + assert a.shape == (3, 5) and a.dtype == np.uint8, (a.shape, a.dtype) + assert a[2, 1] == 7 + a[2, 2] = 11 # y=2, x=2 -- must alias + assert dm[2, 2] == 11, "numpy write did not alias DiscreteMap (not zero-copy)" + print(" numpy zero-copy verified") + except ImportError: + print(" numpy not bundled; memoryview path verified") + + # Buffer over a live entity perspective_map (the #294 use case) + scene = mcrfpy.Scene("t334") + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(8, 6), texture=tex) + scene.children.append(grid) + ent = mcrfpy.Entity((2, 2), grid=grid) + pm = ent.perspective_map # DiscreteMap sized to grid + if pm is not None: + pmv = memoryview(pm) + assert pmv.shape == (6, 8), f"perspective buffer shape {pmv.shape}, expected (6, 8)" + + print("PASS") + sys.exit(0) + + +if __name__ == "__main__": + main()