Files
EVEngine/scripts/module_depgraph.py
T
西风逍遥游 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

263 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cross-module dependency report for src/modules.
Scans #include directives and reports the module dependency graph, its
topological layering and any dependency cycles. See
docs/dev/模块编排与裁剪架构.md for how the output is interpreted.
python3 scripts/module_depgraph.py # graph + layers
python3 scripts/module_depgraph.py --cycles # cycles only
python3 scripts/module_depgraph.py --check # exit 1 on unlisted back-edge
python3 scripts/module_depgraph.py --check-layers
# exit 1 when an include edge
# climbs above the LAYER the
# module declares in the
# module manifest
Only sub-path includes ("map/Map.h") count as a cross-module dependency;
a bare <map> / <thread> / <filesystem> is a standard library header.
"""
import argparse
import os
import re
import sys
from collections import defaultdict
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODULES_DIR = os.path.join(REPO, "src", "modules")
MANIFEST_FILE = os.path.join(REPO, "cmake", "module_manifest.cmake")
INCLUDE_RE = re.compile(r'^\s*#\s*include\s*[<"]([^">]+)[">]', re.M)
SOURCE_EXT = (".cpp", ".cc", ".h", ".hpp")
# Upward edges that break the layering. Each entry is a known debt item; --check
# fails on anything outside this list so no new back-edge slips in, and reports
# entries that are no longer needed so the list shrinks as debt is paid off.
#
# All three original back-edges are fixed:
# window -> graphics: the window queries the IWindowSurfaceHost capability
# (common/WindowSurfaceHost.h) instead of holding a Graphics pointer.
# window -> image: Window::setIconRGBA takes raw RGBA8 pixels; the image
# module does not cross the window boundary.
# scene -> graphics: the Camera3D-based picking entry points are implemented
# in graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge).
KNOWN_BACK_EDGES = set()
# eve_declare_module(NAME <m> ... LAYER <n> ...) blocks in the module manifest.
DECLARE_RE = re.compile(r"eve_declare_module\((.*?)\)", re.S)
NAME_RE = re.compile(r"NAME\s+(\S+)")
LAYER_RE = re.compile(r"LAYER\s+(-?\d+)")
def declared_layers():
"""Return {module: declared LAYER} parsed from cmake/module_manifest.cmake.
The manifest is the single source of truth for the *declared* module
layers. The computed layers printed by the default report are derived from
the include graph instead; this check exists to keep the two in step.
"""
with open(MANIFEST_FILE, encoding="utf-8") as handle:
text = handle.read()
result = {}
for block in DECLARE_RE.findall(text):
name = NAME_RE.search(block)
layer = LAYER_RE.search(block)
if name and layer:
result[name.group(1)] = int(layer.group(1))
return result
def scan():
"""Return (modules, edges, header_edges).
edges[a][b] is the set of b's headers included anywhere in a;
header_edges[a][b] narrows that to includes in a's own headers, which
leak into a's public API and are harder to decouple.
"""
modules = sorted(
d for d in os.listdir(MODULES_DIR) if os.path.isdir(os.path.join(MODULES_DIR, d))
)
known = set(modules)
edges = defaultdict(lambda: defaultdict(set))
header_edges = defaultdict(lambda: defaultdict(set))
for module in modules:
for dirpath, _, files in os.walk(os.path.join(MODULES_DIR, module)):
for name in files:
if not name.endswith(SOURCE_EXT):
continue
path = os.path.join(dirpath, name)
with open(path, encoding="utf-8", errors="ignore") as handle:
text = handle.read()
for include in INCLUDE_RE.findall(text):
head, _, _ = include.partition("/")
if not _:
continue
if head not in known or head == module:
continue
edges[module][head].add(include)
if name.endswith((".h", ".hpp")):
header_edges[module][head].add(include)
return modules, edges, header_edges
def layers(modules, edges, ignore):
"""Longest-path depth per module once `ignore` edges are dropped."""
graph = {a: {b for b in deps if (a, b) not in ignore} for a, deps in edges.items()}
depth = {}
def visit(node, stack):
if node in depth:
return depth[node]
if node in stack:
return 0
depth[node] = 0
depth[node] = max((visit(d, stack | {node}) + 1 for d in graph.get(node, ())), default=0)
return depth[node]
for module in modules:
visit(module, set())
return depth
def cycles(edges, ignore=frozenset()):
graph = {a: {b for b in deps if (a, b) not in ignore} for a, deps in edges.items()}
found = []
def walk(start, node, path, seen):
for nxt in sorted(graph.get(node, ())):
if nxt == start and len(path) > 1:
found.append(list(path))
# Only extend through nodes ordered after `start` so each cycle is
# reported once, from its alphabetically first member.
elif nxt not in seen and nxt > start:
walk(start, nxt, path + [nxt], seen | {nxt})
for start in sorted(graph):
walk(start, start, [start], {start})
return found
def check_declared_layers(edges, header_edges):
"""Exit 1 when an include edge climbs above the declared module LAYER.
The manifest declares each module's layer (cmake/module_manifest.cmake).
A module may only include headers from modules declared at the same or a
lower layer; an edge toward a *strictly higher* layer is a layering
violation and is reported (KNOWN_BACK_EDGES whitelists accepted debt).
Same-layer edges (e.g. window -> image) are allowed, matching the DEPS the
manifest itself declares.
"""
layers_map = declared_layers()
missing = sorted(m for m in set(edges) | {b for deps in edges.values() for b in deps}
if m not in layers_map)
if missing:
print("warning: modules missing a LAYER declaration in the manifest: "
+ ", ".join(missing), file=sys.stderr)
actual = {(a, b) for a, deps in edges.items() for b in deps}
offenders = sorted(
(a, b) for (a, b) in actual
if a in layers_map and b in layers_map
and layers_map[b] > layers_map[a]
and (a, b) not in KNOWN_BACK_EDGES
)
stale = sorted(
(a, b) for (a, b) in KNOWN_BACK_EDGES
if (a, b) in actual
and (a not in layers_map or b not in layers_map
or layers_map[b] <= layers_map[a])
)
for a, b in offenders:
marker = "*" if (a, b) in header_edges else " "
print(f"error: layer violation {a}(L{layers_map[a]}) -> {b}(L{layers_map[b]}){marker}",
file=sys.stderr)
for a, b in stale:
print(f"note: {a} -> {b} no longer violates the declared layers; "
"drop it from KNOWN_BACK_EDGES", file=sys.stderr)
if offenders:
return 1
print(f"ok: no declared-layer violations "
f"({len(KNOWN_BACK_EDGES)} known back-edges, {len(stale)} now stale)")
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--cycles", action="store_true", help="report dependency cycles only")
parser.add_argument("--check", action="store_true",
help="fail when a back-edge outside KNOWN_BACK_EDGES appears")
parser.add_argument("--check-layers", action="store_true",
help="fail when an include edge climbs above the module's "
"declared manifest LAYER")
args = parser.parse_args()
modules, edges, header_edges = scan()
if args.cycles:
found = cycles(edges)
print(f"dependency cycles: {len(found)}")
for cycle in sorted(found, key=len):
print(" " + " -> ".join(cycle) + " -> " + cycle[0])
remaining = cycles(edges, KNOWN_BACK_EDGES)
print(f"\nafter removing {len(KNOWN_BACK_EDGES)} known back-edges: {len(remaining)}")
return 0
depth = layers(modules, edges, KNOWN_BACK_EDGES)
if args.check:
actual = {(a, b) for a, deps in edges.items() for b in deps}
# A back-edge points from a lower layer up to a higher one.
offenders = sorted(
(a, b) for (a, b) in actual
if depth[a] <= depth[b] and (a, b) not in KNOWN_BACK_EDGES
)
stale = sorted(KNOWN_BACK_EDGES - actual)
for a, b in offenders:
print(f"error: layering violation {a}(L{depth[a]}) -> {b}(L{depth[b]})", file=sys.stderr)
for a, b in stale:
print(f"note: {a} -> {b} is fixed; drop it from KNOWN_BACK_EDGES", file=sys.stderr)
if offenders:
return 1
print(f"ok: no new back-edges ({len(KNOWN_BACK_EDGES)} known, {len(stale)} now stale)")
return 0
if args.check_layers:
return check_declared_layers(edges, header_edges)
by_layer = defaultdict(list)
for module in modules:
by_layer[depth[module]].append(module)
print("=== layers (back-edges removed) ===")
for layer in sorted(by_layer):
print(f"L{layer} ({len(by_layer[layer])}): " + ", ".join(sorted(by_layer[layer])))
print("\n=== dependencies (* = leaks into the module's own headers) ===")
for module in sorted(modules, key=lambda m: (-len(edges[m]), m)):
if not edges[module]:
continue
deps = [b + ("*" if b in header_edges[module] else "") for b in sorted(edges[module])]
print(f"{module:16s} L{depth[module]} ({len(deps)}): " + ", ".join(deps))
reverse = defaultdict(set)
for a, deps in edges.items():
for b in deps:
reverse[b].add(a)
print("\n=== dependents ===")
for module in sorted(modules, key=lambda m: (-len(reverse[m]), m)):
if reverse[module]:
print(f"{module:16s} ({len(reverse[module])}): " + ", ".join(sorted(reverse[module])))
isolated = [m for m in modules if not edges[m] and not reverse[m]]
print("\n=== isolated (trimmable with no graph impact) ===")
print(", ".join(isolated) if isolated else "(none)")
return 0
if __name__ == "__main__":
sys.exit(main())