diff --git a/src/PyDiscreteMap.cpp b/src/PyDiscreteMap.cpp index 1939de7..e93bc3f 100644 --- a/src/PyDiscreteMap.cpp +++ b/src/PyDiscreteMap.cpp @@ -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(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(self->w) * static_cast(self->h); + return PyBytes_FromStringAndSize(reinterpret_cast(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(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(w) * static_cast(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 // ============================================================================ diff --git a/src/PyDiscreteMap.h b/src/PyDiscreteMap.h index 3541f2b..dd074ff 100644 --- a/src/PyDiscreteMap.h +++ b/src/PyDiscreteMap.h @@ -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); diff --git a/tests/unit/discretemap_serialization_test.py b/tests/unit/discretemap_serialization_test.py new file mode 100644 index 0000000..40fa776 --- /dev/null +++ b/tests/unit/discretemap_serialization_test.py @@ -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)