Files
西风逍遥游 34d1fdb63b refactor(modules): clear remaining back-edges and split the largest TUs (#127)
* 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)
2026-08-20 05:53:33 +08:00

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);
}