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<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>
This commit is contained in:
parent
0c61c70ccd
commit
8af3bf45b6
3 changed files with 115 additions and 0 deletions
|
|
@ -1317,6 +1317,49 @@ PyObject* PyDiscreteMap::mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(arg
|
||||||
return PyMemoryView_FromMemory(reinterpret_cast<char*>(self->values), len, PyBUF_WRITE);
|
return PyMemoryView_FromMemory(reinterpret_cast<char*>(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<DiscreteMap>;
|
||||||
|
// 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<PyDiscreteMapObject*>(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<Py_ssize_t>(self->w) * static_cast<Py_ssize_t>(self->h);
|
||||||
|
view->readonly = 0;
|
||||||
|
view->itemsize = 1;
|
||||||
|
view->format = (flags & PyBUF_FORMAT) ? const_cast<char*>("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
|
// Serialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ typedef struct {
|
||||||
uint8_t* values; // cached data->data()
|
uint8_t* values; // cached data->data()
|
||||||
int w, h; // cached data->width()/height()
|
int w, h; // cached data->width()/height()
|
||||||
PyObject* enum_type; // Optional Python IntEnum for value interpretation
|
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;
|
} PyDiscreteMapObject;
|
||||||
|
|
||||||
class PyDiscreteMap
|
class PyDiscreteMap
|
||||||
|
|
@ -74,6 +76,10 @@ public:
|
||||||
static PyObject* to_bool(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
|
static PyObject* to_bool(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
|
||||||
static PyObject* mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
|
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
|
// Serialization
|
||||||
static PyObject* to_bytes(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
|
static PyObject* to_bytes(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
|
||||||
static PyObject* from_bytes(PyTypeObject* type, PyObject* args, PyObject* kwds);
|
static PyObject* from_bytes(PyTypeObject* type, PyObject* args, PyObject* kwds);
|
||||||
|
|
@ -99,6 +105,7 @@ namespace mcrfpydef {
|
||||||
.tp_dealloc = (destructor)PyDiscreteMap::dealloc,
|
.tp_dealloc = (destructor)PyDiscreteMap::dealloc,
|
||||||
.tp_repr = PyDiscreteMap::repr,
|
.tp_repr = PyDiscreteMap::repr,
|
||||||
.tp_as_mapping = &PyDiscreteMap::mapping_methods, // dmap[x, y] subscript
|
.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_flags = Py_TPFLAGS_DEFAULT,
|
||||||
.tp_doc = PyDoc_STR(
|
.tp_doc = PyDoc_STR(
|
||||||
"DiscreteMap(size: tuple[int, int], fill: int = 0, enum: type[IntEnum] = None)\n\n"
|
"DiscreteMap(size: tuple[int, int], fill: int = 0, enum: type[IntEnum] = None)\n\n"
|
||||||
|
|
|
||||||
65
tests/regression/issue_334_discretemap_buffer_test.py
Normal file
65
tests/regression/issue_334_discretemap_buffer_test.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue