Add DiscreteMap to_bytes/from_bytes serialization, closes #293

to_bytes() returns raw uint8 cell data as Python bytes object.
from_bytes(data, size, enum=None) is a classmethod that constructs
a new DiscreteMap from serialized data with dimension validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 02:06:02 -04:00
commit e0bcee12a3
3 changed files with 203 additions and 0 deletions

View file

@ -320,6 +320,24 @@ PyMethodDef PyDiscreteMap::methods[] = {
MCRF_DESC("Get raw uint8_t data as memoryview for libtcod compatibility."),
MCRF_RETURNS("memoryview: Direct access to internal buffer (read/write)")
)},
// Serialization
{"to_bytes", (PyCFunction)PyDiscreteMap::to_bytes, METH_NOARGS,
MCRF_METHOD(DiscreteMap, to_bytes,
MCRF_SIG("()", "bytes"),
MCRF_DESC("Serialize map data to bytes (row-major, one byte per cell)."),
MCRF_RETURNS("bytes: Raw cell data, length = width * height")
)},
{"from_bytes", (PyCFunction)PyDiscreteMap::from_bytes, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(DiscreteMap, from_bytes,
MCRF_SIG("(data: bytes, size: tuple[int, int], *, enum: type = None)", "DiscreteMap"),
MCRF_DESC("Create a DiscreteMap from raw byte data."),
MCRF_ARGS_START
MCRF_ARG("data", "Raw cell data (one byte per cell, row-major)")
MCRF_ARG("size", "(width, height) dimensions")
MCRF_ARG("enum", "Optional IntEnum class for value interpretation")
MCRF_RETURNS("DiscreteMap: new map initialized from data")
MCRF_RAISES("ValueError", "Data length does not match width * height")
)},
// HeightMap integration
{"from_heightmap", (PyCFunction)PyDiscreteMap::from_heightmap, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(DiscreteMap, from_heightmap,
@ -1299,6 +1317,97 @@ PyObject* PyDiscreteMap::mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(arg
return PyMemoryView_FromMemory(reinterpret_cast<char*>(self->values), len, PyBUF_WRITE);
}
// ============================================================================
// Serialization
// ============================================================================
PyObject* PyDiscreteMap::to_bytes(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args))
{
if (!self->values) {
PyErr_SetString(PyExc_RuntimeError, "DiscreteMap not initialized");
return nullptr;
}
Py_ssize_t len = static_cast<Py_ssize_t>(self->w) * static_cast<Py_ssize_t>(self->h);
return PyBytes_FromStringAndSize(reinterpret_cast<const char*>(self->values), len);
}
PyObject* PyDiscreteMap::from_bytes(PyTypeObject* type, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"data", "size", "enum", nullptr};
Py_buffer buffer;
PyObject* size_obj = nullptr;
PyObject* enum_type = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "y*O|O", const_cast<char**>(kwlist),
&buffer, &size_obj, &enum_type)) {
return nullptr;
}
int w = 0, h = 0;
if (!PyArg_ParseTuple(size_obj, "ii", &w, &h)) {
PyErr_Clear();
if (PyTuple_Check(size_obj) && PyTuple_GET_SIZE(size_obj) == 2) {
w = (int)PyLong_AsLong(PyTuple_GET_ITEM(size_obj, 0));
h = (int)PyLong_AsLong(PyTuple_GET_ITEM(size_obj, 1));
if (PyErr_Occurred()) {
PyBuffer_Release(&buffer);
return nullptr;
}
} else {
PyBuffer_Release(&buffer);
PyErr_SetString(PyExc_TypeError, "size must be a (width, height) tuple");
return nullptr;
}
}
if (w <= 0 || h <= 0 || w > 8192 || h > 8192) {
PyBuffer_Release(&buffer);
PyErr_SetString(PyExc_ValueError, "dimensions must be positive and <= 8192");
return nullptr;
}
Py_ssize_t expected = static_cast<Py_ssize_t>(w) * static_cast<Py_ssize_t>(h);
if (buffer.len != expected) {
PyBuffer_Release(&buffer);
PyErr_Format(PyExc_ValueError,
"data length (%zd) does not match size %d x %d = %zd",
buffer.len, w, h, expected);
return nullptr;
}
if (enum_type && enum_type != Py_None && !PyType_Check(enum_type)) {
PyBuffer_Release(&buffer);
PyErr_SetString(PyExc_TypeError, "enum must be a type (IntEnum subclass)");
return nullptr;
}
auto obj = (PyDiscreteMapObject*)type->tp_alloc(type, 0);
if (!obj) {
PyBuffer_Release(&buffer);
return nullptr;
}
obj->w = w;
obj->h = h;
obj->values = new (std::nothrow) uint8_t[expected];
if (!obj->values) {
PyBuffer_Release(&buffer);
Py_DECREF(obj);
return PyErr_NoMemory();
}
std::memcpy(obj->values, buffer.buf, expected);
PyBuffer_Release(&buffer);
if (enum_type && enum_type != Py_None) {
Py_INCREF(enum_type);
obj->enum_type = enum_type;
} else {
obj->enum_type = nullptr;
}
return (PyObject*)obj;
}
// ============================================================================
// HeightMap Integration
// ============================================================================

View file

@ -64,6 +64,10 @@ public:
static PyObject* to_bool(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
// Serialization
static PyObject* to_bytes(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
static PyObject* from_bytes(PyTypeObject* type, PyObject* args, PyObject* kwds);
// HeightMap integration
static PyObject* from_heightmap(PyTypeObject* type, PyObject* args, PyObject* kwds);
static PyObject* to_heightmap(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);

View file

@ -0,0 +1,90 @@
"""Test DiscreteMap to_bytes/from_bytes serialization - issue #293"""
import mcrfpy
import sys
PASS = True
def check(name, condition):
global PASS
if not condition:
print(f"FAIL: {name}")
PASS = False
else:
print(f" ok: {name}")
# Test 1: Basic to_bytes
dmap = mcrfpy.DiscreteMap((10, 10), fill=42)
data = dmap.to_bytes()
check("to_bytes returns bytes", isinstance(data, bytes))
check("to_bytes length matches dimensions", len(data) == 100)
check("to_bytes values correct", all(b == 42 for b in data))
# Test 2: to_bytes with varied data
dmap2 = mcrfpy.DiscreteMap((5, 3), fill=0)
dmap2.set(0, 0, 10)
dmap2.set(4, 2, 200)
dmap2.set(2, 1, 128)
data2 = dmap2.to_bytes()
check("varied data length", len(data2) == 15)
check("varied data [0,0]=10", data2[0] == 10)
check("varied data [4,2]=200", data2[14] == 200)
check("varied data [2,1]=128", data2[7] == 128)
# Test 3: from_bytes basic roundtrip
dmap3 = mcrfpy.DiscreteMap.from_bytes(data2, (5, 3))
check("from_bytes creates DiscreteMap", isinstance(dmap3, mcrfpy.DiscreteMap))
check("from_bytes size correct", dmap3.size == (5, 3))
check("from_bytes [0,0] preserved", dmap3.get(0, 0) == 10)
check("from_bytes [4,2] preserved", dmap3.get(4, 2) == 200)
check("from_bytes [2,1] preserved", dmap3.get(2, 1) == 128)
check("from_bytes [1,0] is zero", dmap3.get(1, 0) == 0)
# Test 4: Full roundtrip preserves all data
dmap4 = mcrfpy.DiscreteMap((20, 15), fill=0)
for y in range(15):
for x in range(20):
dmap4.set(x, y, (x * 7 + y * 13) % 256)
data4 = dmap4.to_bytes()
dmap4_restored = mcrfpy.DiscreteMap.from_bytes(data4, (20, 15))
data4_check = dmap4_restored.to_bytes()
check("full roundtrip data identical", data4 == data4_check)
# Test 5: from_bytes with enum
from enum import IntEnum
class Terrain(IntEnum):
WATER = 0
GRASS = 1
MOUNTAIN = 2
raw = bytes([0, 1, 2, 1, 0, 2])
dmap5 = mcrfpy.DiscreteMap.from_bytes(raw, (3, 2), enum=Terrain)
check("from_bytes with enum", dmap5.enum_type is Terrain)
val = dmap5.get(1, 0)
check("enum value returned", val == Terrain.GRASS)
# Test 6: Error - wrong data length
try:
mcrfpy.DiscreteMap.from_bytes(b"\x00\x01\x02", (2, 2))
check("rejects wrong length", False)
except ValueError:
check("rejects wrong length", True)
# Test 7: Error - invalid dimensions
try:
mcrfpy.DiscreteMap.from_bytes(b"\x00", (0, 1))
check("rejects zero dimension", False)
except ValueError:
check("rejects zero dimension", True)
# Test 8: Large map roundtrip
big = mcrfpy.DiscreteMap((100, 100), fill=255)
big_data = big.to_bytes()
check("large map serializes", len(big_data) == 10000)
big_restored = mcrfpy.DiscreteMap.from_bytes(big_data, (100, 100))
check("large map roundtrips", big_restored.to_bytes() == big_data)
if PASS:
print("PASS")
sys.exit(0)
else:
sys.exit(1)