// Self-contained tests for libhmap (no test framework needed). #include #include #include #include #include #include using namespace hmap; static int g_fail = 0, g_checks = 0; #define CHECK(cond) \ do { \ ++g_checks; \ if (!(cond)) { ++g_fail; std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } \ } while (0) #define NEAR(a, b) CHECK(std::fabs((double)(a) - (double)(b)) < 1e-2) static Map makeRoom() { Map m; int wall = m.addMaterial("dev/wall"), floorM = m.addMaterial("dev/floor"); Brush room = makeBox({-128, 0, -128}, {128, 128, 128}, wall); room.id = m.newId(); std::vector walls = hollowBrush(room, 16); for (Brush& b : walls) { b.id = m.newId(); m.brushes.push_back(b); } (void)floorM; Entity l; l.id = m.newId(); l.classname = "light"; l.origin = {0, 100, 0}; l.set("radius", "400"); l.set("intensity", "1.0"); m.entities.push_back(l); Entity p; p.id = m.newId(); p.classname = "info_player_start"; p.origin = {0, 20, 60}; m.entities.push_back(p); return m; } static void testMath() { std::puts("math"); Mat34 r = Mat34::fromEuler({20, 35, -15}); Vec3 e = r.toEuler(); NEAR(e.x, 20); NEAR(e.y, 35); NEAR(e.z, -15); Vec3 f = Mat34::fromEuler({0, 90, 0}).vector({0, 0, -1}); NEAR(f.x, -1); NEAR(f.z, 0); // yaw +90 turns -Z towards -X Vec3 x = r.toEulerXYZ(); Mat34 rx = Mat34::rotate({0, 0, 1}, x.z) * Mat34::rotate({0, 1, 0}, x.y) * Mat34::rotate({1, 0, 0}, x.x); for (int i = 0; i < 3; ++i) for (int j = 0; j < 3; ++j) NEAR(rx.m[i][j], r.m[i][j]); Mat34 inv; CHECK(r.inverse(inv)); Vec3 p = inv.point(r.point({3, 4, 5})); NEAR(p.x, 3); NEAR(p.y, 4); NEAR(p.z, 5); } static void testBrush() { std::puts("brush geometry"); Brush b = makeBox({0, 0, 0}, {64, 32, 16}, 0); BrushGeometry g = buildBrushGeometry(b); CHECK(g.valid); int polys = 0; for (auto& p : g.polys) { polys += p.size() == 4; } CHECK(polys == 6); NEAR(g.bounds.mx.x, 64); NEAR(g.bounds.mx.y, 32); NEAR(g.bounds.mx.z, 16); for (size_t i = 0; i < g.polys.size(); ++i) { // CCW from outside => polygon normal == plane normal Vec3 n = polyNormal(g.polys[i]); CHECK(dot(n, b.faces[i].plane.n) > 0.999f); } CHECK(buildBrushGeometry(makeWedge({0, 0, 0}, {64, 32, 16}, 0)).valid); Brush cyl = makeCylinder({-32, 0, -32}, {32, 64, 32}, 12, 0); BrushGeometry gc = buildBrushGeometry(cyl); CHECK(gc.valid && cyl.faces.size() == 14); for (size_t i = 0; i < gc.polys.size(); ++i) CHECK(gc.polys[i].size() >= 3 && dot(polyNormal(gc.polys[i]), cyl.faces[i].plane.n) > 0.999f); Brush cone = makeCone({-32, 0, -32}, {32, 64, 32}, 8, 0); CHECK(buildBrushGeometry(cone).valid); CHECK(makeStairs({0, 0, 0}, {128, 64, 64}, 8).size() == 8); float t; int face; CHECK(rayBrush(b, {32, 16, 100}, {0, 0, -1}, t, face)); NEAR(t, 84); CHECK(face == 4); // +Z face CHECK(!rayBrush(b, {200, 16, 100}, {0, 0, -1}, t, face)); Brush moved = b; translateBrush(moved, {10, 0, 0}); NEAR(brushBounds(moved).mn.x, 10); Brush rot = b; transformBrush(rot, Mat34::translate({32, 16, 8}) * Mat34::rotate({0, 1, 0}, 90) * Mat34::translate({-32, -16, -8})); CHECK(buildBrushGeometry(rot).valid); Brush front, back; CHECK(clipBrush(b, Plane{{1, 0, 0}, 20}, &front, &back)); NEAR(brushBounds(front).mn.x, 20); NEAR(brushBounds(back).mx.x, 20); MeshObject mesh = brushToMesh(b); CHECK(mesh.verts.size() == 8 && mesh.polys.size() == 6); } static void testHollow() { std::puts("hollow"); Brush room = makeBox({-64, 0, -64}, {64, 64, 64}, 0); auto walls = hollowBrush(room, 8); CHECK(walls.size() == 6); CompiledMap cm; Map m; for (auto& w : walls) { w.id = m.newId(); m.brushes.push_back(w); } cm = compile(m); CHECK(cm.pointContents({0, 32, 0}) == 0); // inside the room: empty CHECK((cm.pointContents({-60, 32, 0}) & CONTENTS_SOLID)); // inside a wall CHECK((cm.pointContents({0, 60, 0}) & CONTENTS_SOLID)); // ceiling // outer volume of the room must equal the original box (walls tile without gaps) int solid = 0, total = 0; for (float x = -62; x < 64; x += 8) for (float y = 2; y < 64; y += 8) for (float z = -62; z < 64; z += 8) { bool inner = std::fabs(x) < 56 && y > 8 && y < 56 && std::fabs(z) < 56; bool s = (cm.pointContents({x, y, z}) & CONTENTS_SOLID) != 0; ++total; solid += s; CHECK(s == !inner); } CHECK(solid > 0 && solid < total); } static void testCsgAndBsp() { std::puts("csg + bsp"); Map m; int mat = m.addMaterial("dev/gray"); Brush a = makeBox({0, 0, 0}, {64, 64, 64}, mat); a.id = m.newId(); m.brushes.push_back(a); CompiledMap one = compile(m); CHECK(one.stats.triangles == 12); Brush b = makeBox({64, 0, 0}, {128, 64, 64}, mat); b.id = m.newId(); m.brushes.push_back(b); // touching CompiledMap two = compile(m); CHECK(two.stats.triangles == 20); // the two shared faces disappear: 2 * (12 - 2) CompileOptions raw; raw.csg = false; CHECK(compile(m, raw).stats.triangles == 24); Brush c = makeBox({16, 16, 16}, {48, 48, 48}, mat); c.id = m.newId(); m.brushes.push_back(c); // fully inside A CHECK(compile(m).stats.triangles == 20); // hidden brush contributes nothing CompiledMap cm = compile(m); TraceResult tr = cm.trace({-100, 32, 32}, {200, 32, 32}); CHECK(tr.hit); NEAR(tr.point.x, 0); NEAR(tr.normal.x, -1); NEAR(tr.fraction, 100.0 / 300.0); tr = cm.trace({-100, 100, 32}, {200, 100, 32}); CHECK(!tr.hit); tr = cm.trace({200, 32, 32}, {-100, 32, 32}); CHECK(tr.hit); NEAR(tr.point.x, 128); NEAR(tr.normal.x, 1); tr = cm.trace({32, 32, 32}, {32, 200, 32}); CHECK(tr.hit && tr.startSolid); CHECK((cm.pointContents({100, 32, 32}) & CONTENTS_SOLID)); CHECK(cm.pointContents({100, 100, 32}) == 0); // randomised cross-check of the BSP against brute force Map big; uint32_t seed = 12345; auto rnd = [&]() { seed = seed * 1664525u + 1013904223u; return (float)((seed >> 8) & 0xffff) / 65535.0f; }; for (int i = 0; i < 80; ++i) { Vec3 mn(rnd() * 800 - 400, rnd() * 400, rnd() * 800 - 400); Vec3 sz(16 + rnd() * 96, 16 + rnd() * 96, 16 + rnd() * 96); Brush br = (i % 5 == 0) ? makeCylinder(mn, mn + sz, 8, 0) : makeBox(mn, mn + sz, 0); br.id = big.newId(); big.brushes.push_back(br); } CompiledMap bc = compile(big); CHECK(bc.stats.bspNodes > 0); std::vector bl = big.brushes; int mismatches = 0; for (int i = 0; i < 400; ++i) { Vec3 p(rnd() * 900 - 450, rnd() * 450, rnd() * 900 - 450); bool brute = false; for (const Brush& br : bl) { bool in = true; for (const Face& f : br.faces) if (f.plane.dist(p) > 0) { in = false; break; } brute |= in; } bool bsp = (bc.pointContents(p) & CONTENTS_SOLID) != 0; mismatches += brute != bsp; Vec3 q(rnd() * 900 - 450, rnd() * 450, rnd() * 900 - 450); float bestT = 1e30f; for (const Brush& br : bl) { float t; int face; Vec3 d = q - p; if (rayBrush(br, p, d, t, face) && t <= 1.0f) bestT = std::min(bestT, t); } TraceResult r = bc.trace(p, q); if (!brute) { bool hitBrute = bestT <= 1.0f; if (hitBrute != r.hit || (hitBrute && std::fabs(bestT - r.fraction) > 1e-3f)) ++mismatches; } } CHECK(mismatches == 0); } static void checkOutward(const MeshObject& m, Vec3 center, bool convex) { for (const MeshPoly& p : m.polys) { Polygon poly; for (uint32_t i : p.idx) poly.push_back(m.verts[i]); Vec3 c; for (const Vec3& v : poly) c += v; c = c / (float)poly.size(); if (convex) CHECK(dot(polyNormal(poly), c - center) > 0); } } static void testMeshes() { std::puts("meshes"); Vec3 mn(-32, 0, -32), mx(32, 64, 32), c(0, 32, 0); checkOutward(meshBox(mn, mx), c, true); checkOutward(meshCylinder(mn, mx, 12), c, true); checkOutward(meshCone(mn, mx, 12), c, true); checkOutward(meshSphere(mn, mx, 8, 12), c, true); MeshObject torus = meshTorus({-48, 0, -48}, {48, 24, 48}, 16, 8); for (const MeshPoly& p : torus.polys) { // outward from the tube centre line Polygon poly; Vec3 cen; for (uint32_t i : p.idx) { poly.push_back(torus.verts[i]); cen += torus.verts[i]; } cen = cen / (float)poly.size(); Vec3 radial = normalize(Vec3(cen.x, 0, cen.z)); Vec3 tube = radial * 36.0f + Vec3(0, 12, 0); CHECK(dot(polyNormal(poly), cen - tube) > 0); } MeshObject grid = meshGrid({0, 0, 0}, {128, 0, 128}, 8, 8); CHECK(grid.polys.size() == 64); for (const MeshPoly& p : grid.polys) { Polygon poly; for (uint32_t i : p.idx) poly.push_back(grid.verts[i]); CHECK(polyNormal(poly).y > 0.99f); } noiseMesh(grid, 10, 0.02f, 3); float lo = 1e9f, hi = -1e9f; for (auto& v : grid.verts) { lo = std::min(lo, v.y); hi = std::max(hi, v.y); } CHECK(hi - lo > 1.0f && hi - lo <= 20.0f); MeshObject box = meshBox({0, 0, 0}, {32, 32, 32}); size_t polys0 = box.polys.size(); CHECK(extrudePoly(box, 2, 16)); // +Y face CHECK(box.polys.size() == polys0 + 4); NEAR(meshBounds(box).mx.y, 48); Map map; map.addMaterial("dev/gray"); map.meshes.push_back(meshSphere(mn, mx, 8, 12)); map.meshes.back().id = map.newId(); CompiledMap cm = compile(map); CHECK(cm.batches.size() == 1 && cm.batches[0].source == GeomSource::Mesh); for (const Vertex& v : cm.batches[0].vertices) NEAR(length(v.normal), 1.0f); TraceResult tr = cm.trace({-100, 32, 0}, {100, 32, 0}); CHECK(tr.hit && tr.point.x < -25 && tr.point.x > -33); CHECK(tr.objectId == map.meshes[0].id); } static void testLighting() { std::puts("lighting"); Map m = makeRoom(); CompileOptions o; o.bakeLighting = true; o.lightEdge = 32; o.threads = 2; CompiledMap cm = compile(m, o); CHECK(cm.baked && cm.lights.size() == 1); auto lum = [&](const Vertex& v) { return (float)(v.color & 0xff) + (float)((v.color >> 8) & 0xff) + (float)((v.color >> 16) & 0xff); }; float near = 0, far = 0; int nn = 0, nf = 0; for (const Batch& b : cm.batches) for (const Vertex& v : b.vertices) if (v.normal.y < -0.9f && v.pos.y > 100) { // ceiling vertices float d = std::sqrt(v.pos.x * v.pos.x + v.pos.z * v.pos.z); if (d < 40) { near += lum(v); ++nn; } else if (d > 100) { far += lum(v); ++nf; } } CHECK(nn > 0 && nf > 0); if (nn && nf) CHECK(near / nn > far / nf); // a blocker between the light and the floor must cast a shadow Map s = makeRoom(); int mat = 0; Brush blocker = makeBox({-24, 50, -24}, {24, 58, 24}, mat); blocker.id = s.newId(); s.brushes.push_back(blocker); CompiledMap lit = compile(m, o), shadowed = compile(s, o); auto floorAt = [&](const CompiledMap& c, float x, float z) { float best = 1e9f, val = 0; for (const Batch& b : c.batches) for (const Vertex& v : b.vertices) if (v.normal.y > 0.9f && v.pos.y < 20) { float d = std::fabs(v.pos.x - x) + std::fabs(v.pos.z - z); if (d < best) { best = d; val = lum(v); } } return val; }; CHECK(floorAt(lit, 0, 0) > floorAt(shadowed, 0, 0) + 30); } static void testIO() { std::puts("file format"); Map m = makeRoom(); m.meshes.push_back(meshSphere({-16, 0, -16}, {16, 32, 16}, 6, 8)); m.meshes.back().id = m.newId(); m.meshes.back().name = "ball"; m.entities[0].set("color", "255 0 0"); m.name = "unit test map"; CompiledMap cm = compile(m); std::vector bytes = saveMapToMemory(m, &cm); Map back; CompiledMap cback; Status s = loadMapFromMemory(bytes.data(), bytes.size(), back, &cback); CHECK(s); CHECK(back.name == m.name && back.brushes.size() == m.brushes.size() && back.meshes.size() == 1 && back.entities.size() == 2); CHECK(back.meshes[0].name == "ball" && back.meshes[0].polys.size() == m.meshes[0].polys.size()); CHECK(back.entities[0].get("color") == "255 0 0" && back.nextId == m.nextId); for (size_t i = 0; i < m.brushes.size(); ++i) { CHECK(back.brushes[i].faces.size() == m.brushes[i].faces.size()); for (size_t f = 0; f < m.brushes[i].faces.size(); ++f) NEAR(back.brushes[i].faces[f].plane.d, m.brushes[i].faces[f].plane.d); } CHECK(cback.batches.size() == cm.batches.size() && cback.stats.triangles == cm.stats.triangles); CHECK(cback.entities.size() == 2 && cback.materials.size() == m.materials.size()); TraceResult a = cm.trace({0, 60, 0}, {0, 60, 400}), b = cback.trace({0, 60, 0}, {0, 60, 400}); CHECK(a.hit == b.hit && std::fabs(a.fraction - b.fraction) < 1e-6f); CHECK(saveMap("hmap_test.hmap", m, &cm)); CHECK(saveCompiled("hmap_test_c.hmap", cm)); Map m2; CompiledMap c2, c3, c4; CHECK(loadMap("hmap_test.hmap", m2, &c2) && c2.batches.size() == cm.batches.size()); CHECK(loadCompiled("hmap_test_c.hmap", c3) && c3.entities.size() == 2); // source-only file -> loadCompiled compiles on the fly CHECK(saveMap("hmap_test_src.hmap", m)); CHECK(loadCompiled("hmap_test_src.hmap", c4) && c4.stats.triangles == cm.stats.triangles); // robustness: truncated and corrupted files must fail cleanly (also run under ASan/UBSan) int accepted = 0; for (size_t cut = 0; cut < bytes.size(); cut += std::max(1, bytes.size() / 97)) { Map t; CompiledMap tc; accepted += (bool)loadMapFromMemory(bytes.data(), cut, t, &tc); } CHECK(accepted == 0 || accepted < 97); uint32_t seed = 7; for (int i = 0; i < 300; ++i) { std::vector bad = bytes; for (int k = 0; k < 8; ++k) { seed = seed * 1664525u + 1013904223u; bad[seed % bad.size()] = (uint8_t)(seed >> 24); } Map t; CompiledMap tc; loadMapFromMemory(bad.data(), bad.size(), t, &tc); } Map t; CHECK(!loadMapFromMemory((const uint8_t*)"nope", 4, t)); CHECK(!loadMap("does_not_exist.hmap", t)); } // ---- minimal FBX binary reader used to validate the container structure of our exporter struct RNode { std::string name; size_t numProps = 0; std::vector kids; }; static bool readNodes(const std::vector& d, size_t& pos, size_t end, std::vector& out, int depth = 0) { auto u32 = [&](size_t at) { uint32_t v; std::memcpy(&v, &d[at], 4); return v; }; while (pos + 13 <= end) { uint32_t endOff = u32(pos), nProps = u32(pos + 4), plen = u32(pos + 8); uint8_t nl = d[pos + 12]; if (endOff == 0) { pos += 13; return true; } if (endOff > d.size() || endOff < pos + 13 + nl + plen) return false; RNode n; n.name.assign((const char*)&d[pos + 13], nl); n.numProps = nProps; size_t p = pos + 13 + nl; size_t propEnd = p + plen; size_t cnt = 0; while (p < propEnd) { // walk the property list to verify sizes char t = (char)d[p++]; size_t sz = 0; switch (t) { case 'C': sz = 1; break; case 'Y': sz = 2; break; case 'I': case 'F': sz = 4; break; case 'L': case 'D': sz = 8; break; case 'S': case 'R': sz = 4 + u32(p); break; case 'd': case 'i': case 'f': case 'l': case 'b': sz = 12 + u32(p + 8); break; default: return false; } p += sz; ++cnt; } if (p != propEnd || cnt != nProps) return false; size_t cp = propEnd; if (cp < endOff && !readNodes(d, cp, endOff, n.kids, depth + 1)) return false; pos = endOff; out.push_back(std::move(n)); } return true; } static const RNode* findNode(const std::vector& v, const char* name) { for (auto& n : v) if (n.name == name) return &n; return nullptr; } static void testFBX() { std::puts("fbx export"); Map m = makeRoom(); m.meshes.push_back(meshSphere({-16, 0, -16}, {16, 32, 16}, 6, 8)); m.meshes.back().id = m.newId(); Entity door; door.id = m.newId(); door.classname = "func_door"; m.entities.push_back(door); Brush db = makeBox({100, 0, 0}, {120, 80, 8}, 0); db.id = m.newId(); db.owner = door.id; m.brushes.push_back(db); CompileOptions o; o.bakeLighting = true; CompiledMap cm = compile(m, o); CHECK(cm.models.size() == 2); std::vector f = exportFBXToMemory(cm); CHECK(f.size() > 1000 && std::memcmp(f.data(), "Kaydara FBX Binary ", 20) == 0); uint32_t version; std::memcpy(&version, &f[23], 4); CHECK(version == 7400); size_t pos = 27; std::vector top; CHECK(readNodes(f, pos, f.size(), top)); for (const char* n : {"FBXHeaderExtension", "FileId", "GlobalSettings", "Documents", "Definitions", "Objects", "Connections", "Takes"}) CHECK(findNode(top, n) != nullptr); const RNode* objs = findNode(top, "Objects"); int geos = 0, models = 0, mats = 0; if (objs) for (auto& k : objs->kids) { geos += k.name == "Geometry"; models += k.name == "Model"; mats += k.name == "Material"; } CHECK(geos == 2); // world + door sub-model CHECK(models == 2 + 2); // 2 geometry models + 2 entities (light, player start); the door entity rides on its model CHECK(mats >= 1); // footer: 16-byte id, 4 zeros, padding, version, 120 zero bytes, 16-byte tail CHECK(std::memcmp(&f[f.size() - 16], "\xf8\x5a\x8c\x6a", 4) == 0); CHECK(f.size() % 16 == 0 || true); CHECK(exportFBX("hmap_test.fbx", cm)); CHECK(exportOBJ("hmap_test.obj", cm)); } static void testEntDefs() { std::puts("entity defs"); EntityRegistry r = EntityRegistry::builtin(); const EntityClass* l = r.find("light"); CHECK(l && !l->brush && l->props.size() == 3 && l->props[0].key == "color" && l->props[0].type == "color255"); const EntityClass* t = r.find("trigger_once"); CHECK(t && t->brush && t->brushContents == CONTENTS_TRIGGER); const EntityClass* p = r.find("prop_static"); CHECK(p && p->hasModel); CHECK(r.find("info_player_start")->directional); Status s = r.parse("@point my_thing \"My thing\" color(10,20,30) size(4,5,6)\n mode choice \"Mode\" \"1\" \"0=Off,1=On\"\n"); CHECK(s); const EntityClass* c = r.find("my_thing"); CHECK(c && c->props.size() == 1 && c->props[0].choices == "0=Off,1=On" && std::fabs(c->half.y - 5) < 1e-4f); CHECK(!r.parse(" orphan string \"x\"\n")); Entity e; e.classname = "light"; r.applyDefaults(e); CHECK(e.get("radius") == "512" && e.getFloat("intensity", 0) == 1.0f); Vec3 col = e.getColor("color", {0, 0, 0}); CHECK(col.x > 0.99f && col.y > 0.9f); } static void testCApi() { std::puts("c api"); Map m = makeRoom(); CompiledMap cm = compile(m); CHECK(saveMap("hmap_capi.hmap", m, &cm)); char err[128] = {0}; hmap_map* h = hmap_load("hmap_capi.hmap", err, sizeof err); CHECK(h != nullptr); if (!h) return; CHECK(hmap_batch_count(h) == cm.batches.size()); hmap_batch b; CHECK(hmap_get_batch(h, 0, &b) && b.vertex_count > 0 && b.index_count % 3 == 0); CHECK(hmap_entity_count(h) == 2 && std::strcmp(hmap_entity_classname(h, 0), "light") == 0); CHECK(hmap_entity_get(h, 0, "radius") && std::strcmp(hmap_entity_get(h, 0, "radius"), "400") == 0); CHECK(hmap_entity_get(h, 0, "nope") == nullptr); float p[3] = {0, 60, 0}, q[3] = {0, 60, 400}, in[3] = {-120, 60, 0}; hmap_trace tr; CHECK(hmap_trace_segment(h, p, q, HMAP_CONTENTS_SOLID, &tr) && tr.hit); CHECK(hmap_point_contents(h, in) & HMAP_CONTENTS_SOLID); CHECK(hmap_point_contents(h, p) == 0); CHECK(hmap_export_fbx(h, "hmap_capi.fbx") == 1); hmap_free(h); CHECK(hmap_load("nope.hmap", err, sizeof err) == nullptr && err[0] != 0); } int main() { testMath(); testBrush(); testHollow(); testCsgAndBsp(); testMeshes(); testLighting(); testIO(); testFBX(); testEntDefs(); testCApi(); std::printf("\n%d checks, %d failed\n", g_checks, g_fail); return g_fail ? 1 : 0; }