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:
parent
061b29a07a
commit
e0bcee12a3
3 changed files with 203 additions and 0 deletions
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
90
tests/unit/discretemap_serialization_test.py
Normal file
90
tests/unit/discretemap_serialization_test.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue