* chore(ci): add clang-format gate, CODEOWNERS and agent collaboration rules * refactor(modules): clear remaining back-edges and split the largest TUs Three known dependency back-edges are gone (module_depgraph.py KNOWN_BACK_EDGES is now empty; --check passes with 0 violations, 0 cycles): - window -> graphics: window queries the new IWindowSurfaceHost capability (common/WindowSurfaceHost.h) instead of holding a Graphics pointer; Window::setGraphics was removed and all call sites updated. - window -> image: Window::setIcon was replaced by setIconRGBA(const uint8_t*, w, h); the window no longer decodes ImageData. - scene -> graphics: pickScreenAt / collectFrustumIdsAt implementations moved to graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge); scene now depends only on spatial. TU splits (pure moves, verified identical method sets and brace balance): - vulkan/Graphics.cpp (5118 lines) -> Graphics.cpp (lifecycle), GraphicsPipeline, Graphics2D, Graphics3D, GraphicsMesh + shared GraphicsInternal.h. - scene/Scene.cpp -> Scene.cpp (script layer / host lifecycle / serialization) + SceneNodes.cpp + shared SceneInternal.h. Verified with a real build (MSVC 19.51, prebuilt third-party): full build green; window.* and Scene.* suites pass, including scene picking/culling and GPU cases. * refactor(graphics): narrow Graphics interfaces and clear public-header leaks Step 4 — interface segregation: - New lean interfaces: IGraphics2D, IGraphics3D, IResourceFactory, IPostFX (pure virtuals, forward-declared types only; graphics/Color.h extracted from Canvas.h so modules that only need the color type stop pulling Canvas.h). - Graphics now implements all four facets; consumers can depend on the narrow interface they use instead of the 129-method god class. - Migrated voxel (VoxelWorld::drawVisible -> IGraphics3D*) and model3d (ModelRenderer::buildRenderable(s) -> IResourceFactory&). - Graphics.h slims from 24 sub-header includes to a handful of small type headers + forward declarations; the destructor moved out-of-line so unique_ptr effect members are destroyed where types are complete. Step 5 — header leaks (module_depgraph.py '*' marks: 14 -> 7): - map/TileLayer.h, particles/ParticleEmitter.h, avatar/AvatarInstance.h, animation/SpineAnim.h, map/TileSystem.h, model3d/ModelData.h, model3d/ModelRenderer.h, audio/Audio.h + Source.h, image/ImageData.h + Image.h, dialogue/Dialogue.h, procgen Palette/OutputSpec/TextureRecipe/ PbrMaterial -> forward declarations, includes moved to .cpp. - voxel/VoxelWorld.h: terrain sampler Pimpl'd (unique_ptr + out-of-line destructor), removing the procgen header dependency. - gpgpu/vulkan backend headers stop pulling graphics/vulkan/Graphics.h. - Remaining '*' marks are deliberate: tiny POD/enum headers (GridConfig, MapObject, BlendMode, Color), an internal backend header (GraphicsInternal.h -> filesystem), and lean interface references. Verified with a full clean build (MSVC) and tests: window/Scene/voxel/ RenderSystem/Quad/shader/TextureSampler/resourceLifetime pass; broad suite only shows the pre-existing PHYSFS-init filesystem-environment failures. * feat(resources): revive ResourceManager as unified IAssetReloader-backed cache * fix(resources): leak ResourceManager singleton to avoid exit-time teardown crashes Cached CPU resources may own third-party handles (FreeType faces, Assimp scenes, image decode handlers) whose libraries are torn down at process exit in an unspecified TU order; destroying cached entries from the singleton destructor can segfault at exit (seen on CI Windows in model3d.materialApi.objMtl). Keep the singleton alive until OS reclaim; explicit unload()/clear() still release entries during the run. * refactor(devtools): consume business modules through capability interfaces DevTools / cmdline no longer include business-module headers, so trimming scene / physics / procgen / particles / audio / ui / graphics no longer requires disabling devtools, and devtools can be switched off with -DEVENGINE_MODULE_DEVTOOLS=OFF. New capability interfaces in common/: - ISceneQuery (scene) - host/node inspection + mutation + transform sync - IRenderCapture (graphics) - render status, PNG capture, camera control, visible-entity snapshot, entity-ID mask, G-buffer PNGs - IPhysicsQuery (physics) - 2D world lifecycle + ray casts - IProcgenQuery (procgen) - recipe listing, map/mesh generation - IParticlesQuery (particles) - emitter status + one-shot emit - IAudioQuery (audio) - volume / stop-all - IEditorHost (ui) - declarative editor host (used by cmdline too) Providers register in their module constructors. DevTools rewires McpServer (render/scene/procgen/physics/particles/audio/host tools), SceneInspect (now delegates to ISceneQuery + IRenderCapture) and RenderVision (uses IRenderCapture::capturePngDataUrl); cmdline/Mcp.cpp uses IEditorHost. manifest: devtools DEPS graphics scene physics procgen particles audio ui image event filesystem -> event; cmdline DEPS filesystem ui -> filesystem. Verified: full build green; devtools.mcp.* tests pass; broad suite shows only the pre-existing PHYSFS-init filesystem-environment failures. * style: apply clang-format to changed lines after dev merge * fix(webgpu): include RenderControl/TextureSampler explicitly after Graphics.h slimming * fix(webgpu): provide newFont/print stubs to satisfy IGraphics2D/IResourceFactory vtables * fix(common): make ProcgenQuery.h self-sufficient (cstdint/utility for GCC)
325 lines
11 KiB
C++
325 lines
11 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "filesystem/Filesystem.h"
|
|
#include "filesystem/HotReload.h"
|
|
|
|
#include <Poco/Exception.h>
|
|
#include <Poco/Net/ServerSocket.h>
|
|
#include <Poco/Net/SocketAddress.h>
|
|
#include <Poco/Net/StreamSocket.h>
|
|
#include <Poco/Timespan.h>
|
|
#include "filesystem/FileData.h"
|
|
|
|
#include <atomic>
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
eve::filesystem::Filesystem *fs() { return eve::filesystem::Filesystem::create(); }
|
|
|
|
void useIdentity(const char *id) {
|
|
auto *f = fs();
|
|
REQUIRE(f->setIdentity(id, true));
|
|
REQUIRE(f->setupWriteDirectory());
|
|
}
|
|
|
|
// Minimal HTTP file server matching the `eve dev` protocol:
|
|
// GET /manifest → JSON [{path,size,mtime}, ...]
|
|
// GET /raw/<rel> → file bytes
|
|
class TestHttpServer {
|
|
public:
|
|
~TestHttpServer() { stop(); }
|
|
|
|
bool start(std::string root) {
|
|
try {
|
|
server_ = std::make_unique<Poco::Net::ServerSocket>(Poco::Net::SocketAddress(0));
|
|
port_ = static_cast<uint16_t>(server_->address().port());
|
|
} catch (...) {
|
|
return false;
|
|
}
|
|
root_ = std::move(root);
|
|
running_.store(true);
|
|
thread_ = std::thread([this]() { loop(); });
|
|
return true;
|
|
}
|
|
|
|
void stop() {
|
|
if (!running_.load()) {
|
|
if (thread_.joinable()) thread_.join();
|
|
return;
|
|
}
|
|
running_.store(false);
|
|
// On Linux, closing a socket does NOT wake a thread blocked in accept().
|
|
// A self-connection is the reliable cross-platform way to unblock it.
|
|
try {
|
|
Poco::Net::StreamSocket wake(Poco::Net::SocketAddress("127.0.0.1", port_));
|
|
} catch (...) {
|
|
}
|
|
if (thread_.joinable()) thread_.join();
|
|
}
|
|
|
|
uint16_t port() const { return port_; }
|
|
|
|
private:
|
|
void loop() {
|
|
while (running_.load()) {
|
|
Poco::Net::StreamSocket sock;
|
|
try {
|
|
sock = server_->acceptConnection();
|
|
} catch (...) {
|
|
if (!running_.load()) break;
|
|
continue;
|
|
}
|
|
// Swallow any per-connection error (e.g. receiveBytes timeout on a
|
|
// half-open client) so a bad request cannot crash the server thread.
|
|
try {
|
|
handle(sock);
|
|
} catch (...) {
|
|
}
|
|
try {
|
|
sock.close();
|
|
} catch (...) {
|
|
}
|
|
}
|
|
}
|
|
|
|
void handle(Poco::Net::StreamSocket &sock) {
|
|
sock.setReceiveTimeout(Poco::Timespan(2, 0));
|
|
std::string req;
|
|
char buf[4096];
|
|
while (req.find("\r\n\r\n") == std::string::npos) {
|
|
int n = sock.receiveBytes(buf, sizeof(buf));
|
|
if (n <= 0) break;
|
|
req.append(buf, static_cast<size_t>(n));
|
|
}
|
|
|
|
std::string path;
|
|
if (!req.empty()) {
|
|
const size_t sp1 = req.find(' ');
|
|
const size_t sp2 = req.find(' ', sp1 + 1);
|
|
if (sp1 != std::string::npos && sp2 != std::string::npos)
|
|
path = req.substr(sp1 + 1, sp2 - sp1 - 1);
|
|
}
|
|
|
|
std::string body;
|
|
int status = 404;
|
|
if (path == "/manifest") {
|
|
body = manifest();
|
|
status = 200;
|
|
} else if (path.rfind("/raw/", 0) == 0) {
|
|
const std::string real = root_ + "/" + path.substr(5);
|
|
std::error_code ec;
|
|
if (std::filesystem::is_regular_file(real, ec)) {
|
|
std::ifstream ifs(real, std::ios::binary);
|
|
std::ostringstream oss;
|
|
oss << ifs.rdbuf();
|
|
body = oss.str();
|
|
status = 200;
|
|
}
|
|
}
|
|
|
|
std::ostringstream head;
|
|
head << "HTTP/1.1 " << status << (status == 200 ? " OK" : " Not Found") << "\r\n"
|
|
<< "Content-Length: " << body.size() << "\r\n"
|
|
<< "Connection: close\r\n\r\n";
|
|
const std::string out = head.str() + body;
|
|
try {
|
|
sock.sendBytes(out.data(), static_cast<int>(out.size()));
|
|
} catch (...) {
|
|
}
|
|
try {
|
|
sock.close();
|
|
} catch (...) {
|
|
}
|
|
}
|
|
|
|
std::string manifest() {
|
|
std::ostringstream oss;
|
|
oss << "[";
|
|
bool first = true;
|
|
std::error_code ec;
|
|
for (auto it = std::filesystem::recursive_directory_iterator(root_, ec);
|
|
it != std::filesystem::recursive_directory_iterator() && !ec; it.increment(ec)) {
|
|
if (it->is_regular_file(ec)) {
|
|
const std::string rel =
|
|
std::filesystem::relative(it->path(), root_).generic_string();
|
|
if (rel == ".eve-manifest.json") continue;
|
|
const auto mt = std::filesystem::last_write_time(it->path(), ec);
|
|
if (ec) {
|
|
ec.clear();
|
|
continue;
|
|
}
|
|
if (!first) oss << ",";
|
|
first = false;
|
|
oss << "{\"path\":\"" << rel << "\",\"size\":"
|
|
<< static_cast<long long>(it->file_size(ec)) << ",\"mtime\":"
|
|
<< static_cast<long long>(
|
|
std::chrono::duration_cast<std::chrono::seconds>(
|
|
mt.time_since_epoch())
|
|
.count())
|
|
<< "}";
|
|
}
|
|
}
|
|
oss << "]";
|
|
return oss.str();
|
|
}
|
|
|
|
std::string root_;
|
|
std::unique_ptr<Poco::Net::ServerSocket> server_;
|
|
std::thread thread_;
|
|
std::atomic<bool> running_{false};
|
|
uint16_t port_ = 0;
|
|
};
|
|
|
|
std::filesystem::path tempDir(const char *tag) {
|
|
const auto p = std::filesystem::temp_directory_path() / tag;
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(p, ec);
|
|
std::filesystem::create_directories(p, ec);
|
|
return p;
|
|
}
|
|
|
|
void writeFile(const std::filesystem::path &p, const std::string &content) {
|
|
std::filesystem::create_directories(p.parent_path());
|
|
std::ofstream ofs(p, std::ios::binary | std::ios::trunc);
|
|
ofs << content;
|
|
}
|
|
|
|
bool waitFor(const std::function<bool()> &cond, int ms = 5000) {
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
if (cond()) return true;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
return cond();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hotreload.mountRealDirectoryShadow") {
|
|
useIdentity("ev_ut_hot_overlay");
|
|
auto *f = fs();
|
|
|
|
const char *name = "overlay_test.nut";
|
|
const char *bundle = "bundle-version";
|
|
f->write(name, bundle, std::strlen(bundle));
|
|
|
|
const auto overlay = tempDir("eve_ut_hot_overlay");
|
|
writeFile(overlay / name, "overlay-version");
|
|
|
|
REQUIRE(f->mountRealDirectory(overlay.string(), "/", false));
|
|
eve::filesystem::FileData *fd = f->read(name);
|
|
REQUIRE(fd != nullptr);
|
|
const std::string shadowed(static_cast<char *>(fd->getData()), fd->getSize());
|
|
CHECK(shadowed == "overlay-version");
|
|
delete fd;
|
|
|
|
REQUIRE(f->unmountRealDirectory(overlay.string()));
|
|
fd = f->read(name);
|
|
REQUIRE(fd != nullptr);
|
|
const std::string back(static_cast<char *>(fd->getData()), fd->getSize());
|
|
CHECK(back == "bundle-version");
|
|
delete fd;
|
|
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(overlay, ec);
|
|
}
|
|
|
|
TEST_CASE("hotreload.remoteSyncDownload") {
|
|
useIdentity("ev_ut_hot_remote");
|
|
auto *f = fs();
|
|
|
|
const auto root = tempDir("eve_ut_hot_remote_root");
|
|
writeFile(root / "main.nut", "print('main v1')\n");
|
|
writeFile(root / "scripts" / "a.nut", "a <- 1\n");
|
|
writeFile(root / "fx.json", R"({"emissionRate":5,"particleLifetime":[1,1]})");
|
|
|
|
TestHttpServer server;
|
|
REQUIRE(server.start(root.string()));
|
|
|
|
const auto overlay = tempDir("eve_ut_hot_remote_hot");
|
|
|
|
auto *hot = eve::filesystem::HotReload::create();
|
|
hot->setRemoteHotDir(overlay.string());
|
|
REQUIRE(hot->startRemoteSync("http://127.0.0.1:" + std::to_string(server.port()), 60));
|
|
|
|
REQUIRE(waitFor([&]() { return hot->remoteSyncStatus() == "synced"; }));
|
|
|
|
// All three files should be queued as changes.
|
|
std::vector<std::string> changes;
|
|
while (true) {
|
|
const std::string p = hot->pollRemoteChange();
|
|
if (p.empty()) break;
|
|
changes.push_back(p);
|
|
}
|
|
std::sort(changes.begin(), changes.end());
|
|
REQUIRE(changes.size() == 3);
|
|
CHECK(changes[0] == "fx.json");
|
|
CHECK(changes[1] == "main.nut");
|
|
CHECK(changes[2] == "scripts/a.nut");
|
|
|
|
// Files landed in the overlay dir.
|
|
REQUIRE(std::filesystem::is_regular_file(overlay / "main.nut"));
|
|
REQUIRE(std::filesystem::is_regular_file(overlay / "scripts" / "a.nut"));
|
|
REQUIRE(std::filesystem::is_regular_file(overlay / "fx.json"));
|
|
|
|
// Overlay is mounted at "/" so the virtual FS resolves the synced copy.
|
|
eve::filesystem::FileData *fd = f->read("main.nut");
|
|
REQUIRE(fd != nullptr);
|
|
const std::string synced(static_cast<char *>(fd->getData()), fd->getSize());
|
|
CHECK(synced.find("v1") != std::string::npos);
|
|
delete fd;
|
|
|
|
// Touch main.nut on the server → a new change event after the next poll.
|
|
// Use a different length so the manifest (size + second-granularity mtime)
|
|
// reliably reports a change even when both edits land in the same second.
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
writeFile(root / "main.nut", "print('main v2 -- longer edit')\n");
|
|
REQUIRE(waitFor([&]() {
|
|
while (true) {
|
|
const std::string p = hot->pollRemoteChange();
|
|
if (p.empty()) return false;
|
|
if (p == "main.nut") return true;
|
|
}
|
|
}));
|
|
|
|
fd = f->read("main.nut");
|
|
REQUIRE(fd != nullptr);
|
|
const std::string v2(static_cast<char *>(fd->getData()), fd->getSize());
|
|
CHECK(v2.find("v2") != std::string::npos);
|
|
delete fd;
|
|
|
|
hot->stopRemoteSync();
|
|
f->unmountRealDirectory(overlay.string());
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(root, ec);
|
|
std::filesystem::remove_all(overlay, ec);
|
|
}
|
|
|
|
TEST_CASE("hotreload.remoteSyncUnreachableRecovers") {
|
|
useIdentity("ev_ut_hot_remote_unreach");
|
|
const auto overlay = tempDir("eve_ut_hot_remote_unreach_hot");
|
|
|
|
auto *hot = eve::filesystem::HotReload::create();
|
|
hot->setRemoteHotDir(overlay.string());
|
|
// Nothing listening on this port → sync should start but stay not-synced.
|
|
REQUIRE(hot->startRemoteSync("http://127.0.0.1:1", 50));
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
|
CHECK(hot->remoteSyncStatus() != "synced");
|
|
hot->stopRemoteSync();
|
|
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(overlay, ec);
|
|
}
|