Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.0] - 2026-07-08 — ue_x3d_* : the harness on the wire

The v0.3.0 UE↔X3D harness (`x3d_bridge`) is now exposed as MCP tools, so Claude
can export a live level to X3D, validate it, apply transform edits back, and
preview it — inside the editor. All four are **CORE-tier** (mounted by default);
Epic's MCP has no X3D surface.

### Added
- **`ue_x3d_export`** — serialize the current level to X3D. Reads each actor's
quaternion transform, static mesh, material, and attach parent; converts UE
(Z-up, cm) → X3D (Y-up, m). `mesh_only` by default.
- **`ue_x3d_validate`** — the paper boundary as a tool: rejects out-of-grammar
nodes, dangling `USE`, wrong arity, non-X3D roots, NaN/inf — without touching
the editor.
- **`ue_x3d_apply`** — apply an X3D document's actor transforms back to the live
level (validated, guids sanitized via `sanitize_object_path`, emitted through
`ue_execute_python`). Transforms in v0.4.0; spawn / material / reparent apply
are documented follow-ups.
- **`ue_x3d_preview`** — render X3D to a standalone X_ITE browser page.
- `tests/test_x3d_async.py` + exec-sim coverage for the two codegen tools (stub
extended with `Quat` / `Transform` / `get_actor_transform`). Tier table pinned
at 60 tools / 22 CORE.

### Notes
- Editor-independent and CI-green; the **live** round-trip against a real 5.8
scene is gated on `ue_preflight` reading green (i.e. `bAllowAnyRemoteFunctionCall`
applied). `B` remains analytic (not glTF-calibrated) — the round trip is
basis-agnostic, so that is fidelity-only.

## [0.3.1] - 2026-07-08 — 5.8 survival: the Capability Ladder

The bridge was **silently broken on UE 5.8**: 5.8 defaults
Expand Down
34 changes: 34 additions & 0 deletions tests/exec_sim/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ class ToolEntry:
notes: str = ""


# Minimal valid X3D for the DIRECT x3d tools, and an apply sentinel whose DEF is a
# seeded stub actor so the generated SetTransform resolves to a real actor.
_X3D_MIN = '<X3D profile="Interchange" version="4.0"><Scene></Scene></X3D>'
_X3D_APPLY_SENTINEL = (
'<X3D profile="Interchange" version="4.0"><Scene>'
'<Transform DEF="' + SENTINEL_ACTOR_PATH + '"'
' translation="1.0 2.0 3.0" rotation="0.0 0.0 1.0 0.0" scale="1.0 1.0 1.0"/>'
"</Scene></X3D>"
)


_ENTRIES = [
# ------------------------------------------------------------- actors.py
ToolEntry(
Expand Down Expand Up @@ -375,6 +386,29 @@ class ToolEntry:
expect_error=True,
notes="honest not-implemented report: an error RESULT is the CORRECT behavior",
),
# ----------------------------------------------------------------- x3d.py
ToolEntry(
"ue_x3d_export", CODEGEN,
kwargs=dict(mesh_only=True),
notes="reads every level actor via a static script; no user args interpolated",
),
ToolEntry(
"ue_x3d_validate", DIRECT,
kwargs=dict(x3d=_X3D_MIN),
notes="pure x3d_bridge.validate; never touches the editor",
),
ToolEntry(
"ue_x3d_apply", CODEGEN,
kwargs=dict(x3d=_X3D_APPLY_SENTINEL),
# translation/rotation/scale pass through x3d_bridge.coordinates (X3D->UE
# basis) before emission, so they are NOT literal in the generated source.
notes="applies transforms; DEF must be a real actor path (SENTINEL_ACTOR_PATH)",
),
ToolEntry(
"ue_x3d_preview", DIRECT,
kwargs=dict(x3d=_X3D_MIN),
notes="renders X3D to HTML in-process; never touches the editor",
),
]

REGISTRY: dict[str, ToolEntry] = {e.tool_name: e for e in _ENTRIES}
Expand Down
19 changes: 19 additions & 0 deletions tests/exec_sim/unreal_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ def __init__(self, pitch=0.0, yaw=0.0, roll=0.0):
self.pitch, self.yaw, self.roll = float(pitch), float(yaw), float(roll)


class Quat:
def __init__(self, x=0.0, y=0.0, z=0.0, w=1.0):
self.x, self.y, self.z, self.w = float(x), float(y), float(z), float(w)

def rotator(self):
return Rotator()


class Transform:
def __init__(self, translation=None, rotation=None, scale3d=None):
self.translation = translation if translation is not None else Vector()
self.rotation = rotation if rotation is not None else Quat()
self.scale3d = scale3d if scale3d is not None else Vector(1.0, 1.0, 1.0)


class LinearColor:
def __init__(self, r=0.0, g=0.0, b=0.0, a=1.0):
self.r, self.g, self.b, self.a = float(r), float(g), float(b), float(a)
Expand Down Expand Up @@ -354,6 +369,9 @@ def set_actor_scale3d(self, v):
self._scale = v
return True

def get_actor_transform(self):
return Transform(self._location, Quat(), self._scale)

def get_actor_bounds(self, only_colliding, *args):
return (Vector(0.0, 0.0, 100.0), Vector(50.0, 50.0, 100.0))

Expand Down Expand Up @@ -702,6 +720,7 @@ def find_object(outer, path):

exported = {
"Vector": Vector, "Vector4": Vector4, "Rotator": Rotator,
"Quat": Quat, "Transform": Transform,
"LinearColor": LinearColor, "Color": Color, "Name": Name,
"EditorActorSubsystem": EditorActorSubsystem,
"UnrealEditorSubsystem": UnrealEditorSubsystem,
Expand Down
4 changes: 2 additions & 2 deletions tests/test_registry_tiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ def test_matrix_arithmetic_is_pinned():
"""docs/EPIC_MCP_MATRIX.md §4: 36 RETIRE; KEEP/KEEP-PARTIAL = 18 CORE here
(ue_status/ue_health_check live in mcp_server.py, outside this registry);
undo/redo are the two honest not-implemented EXPERIMENTAL slots."""
assert len(TIERS) == 56
assert len(TIERS) == 60
assert len(LEGACY_NAMES) == 36
assert len(CORE_NAMES) == 18
assert len(CORE_NAMES) == 22
assert EXP_NAMES == {"ue_undo", "ue_redo"}


Expand Down
135 changes: 135 additions & 0 deletions tests/test_x3d_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Async tests for ue_mcp/tools/x3d.py -- the ue_x3d_* MCP tools.

Mocked bridge; the exec-sim harness proves the generated read/apply scripts run
against the strict fake `unreal`. Live round-trip is proven by ue_preflight +
smoke_live against a real editor.
"""

import ast
import json

import pytest
from mcp.server.fastmcp import FastMCP

from ue_mcp.tools.x3d import register


@pytest.fixture
def server(mock_ue):
s = FastMCP("test")
register(s, mock_ue)
return s


def _fn(server, name):
return server._tool_manager._tools[name].fn


_ACTOR_ROW = {
"guid": "/Game/Maps/M.M:PersistentLevel.Rock_1",
"mesh": "/Game/Meshes/SM_Rock",
"material": "/Game/Mat/M_Granite",
"t": [420.0, 0.0, 155.0],
"r": [0.0, 0.0, 0.0, 1.0],
"s": [1.0, 1.0, 1.0],
"parent": None,
}

_APPLY_X3D = (
'<X3D profile="Interchange" version="4.0"><Scene>'
'<Transform DEF="/Game/Maps/M.M:PersistentLevel.A_1" translation="1.0 2.0 3.0" '
'rotation="0.0 0.0 1.0 0.0" scale="1.0 1.0 1.0"/></Scene></X3D>'
)


class TestExport:
@pytest.mark.asyncio
async def test_serializes_scene(self, server, mock_ue):
mock_ue.execute_python.return_value = {"result": [_ACTOR_ROW], "output": "", "error": None}
data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True))
assert "error" not in data
assert data["actor_count"] == 1
assert 'DEF="/Game/Maps/M.M:PersistentLevel.Rock_1"' in data["x3d"]
assert "/Game/Meshes/SM_Rock" in data["x3d"]
mock_ue.execute_python.assert_awaited_once()

@pytest.mark.asyncio
async def test_read_script_is_valid_python(self, server, mock_ue):
mock_ue.execute_python.return_value = {"result": [], "output": "", "error": None}
await _fn(server, "ue_x3d_export")()
code = mock_ue.execute_python.call_args[0][0]
ast.parse(code) # generated UE Python must compile
assert "get_all_level_actors" in code
assert "get_actor_transform" in code

@pytest.mark.asyncio
async def test_mesh_only_filters_meshless(self, server, mock_ue):
rows = [_ACTOR_ROW, {**_ACTOR_ROW, "guid": "Light_1", "mesh": ""}]
mock_ue.execute_python.return_value = {"result": rows, "output": "", "error": None}
data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True))
assert data["actor_count"] == 1

@pytest.mark.asyncio
async def test_reports_read_failure(self, server, mock_ue):
mock_ue.execute_python.return_value = {"result": None, "output": "", "error": "boom"}
data = json.loads(await _fn(server, "ue_x3d_export")())
assert "error" in data


class TestValidate:
@pytest.mark.asyncio
async def test_accepts_good(self, server, mock_ue):
x3d = '<X3D><Scene><Material DEF="m"/><Material USE="m"/></Scene></X3D>'
data = json.loads(await _fn(server, "ue_x3d_validate")(x3d=x3d))
assert data["ok"] is True
mock_ue.execute_python.assert_not_awaited()

@pytest.mark.asyncio
async def test_rejects_out_of_grammar(self, server, mock_ue):
data = json.loads(await _fn(server, "ue_x3d_validate")(x3d="<X3D><Scene><Frobnicate/></Scene></X3D>"))
assert data["ok"] is False
assert data["errors"]


class TestApply:
@pytest.mark.asyncio
async def test_emits_set_transform(self, server, mock_ue):
mock_ue.execute_python.return_value = {"result": {"applied": 1}, "output": "", "error": None}
await _fn(server, "ue_x3d_apply")(x3d=_APPLY_X3D)
mock_ue.execute_python.assert_awaited_once()
code = mock_ue.execute_python.call_args[0][0]
ast.parse(code)
assert "set_actor_location" in code
assert "RESULT:" in code # the emitted ops are wrapped with a result line

@pytest.mark.asyncio
async def test_rejects_invalid_x3d_before_editor(self, server, mock_ue):
data = json.loads(await _fn(server, "ue_x3d_apply")(x3d="<X3D><Scene><Frobnicate/></Scene></X3D>"))
assert "error" in data
mock_ue.execute_python.assert_not_awaited()

@pytest.mark.asyncio
async def test_dangerous_guid_never_reaches_codegen(self, server, mock_ue):
# a DEF carrying an injection attempt must be rejected, or at minimum not
# appear verbatim in the generated Python.
x3d = (
'<X3D profile="Interchange" version="4.0"><Scene>'
'<Transform DEF="a&quot;)&#10;import os#" translation="1.0 2.0 3.0" '
'rotation="0.0 0.0 1.0 0.0" scale="1.0 1.0 1.0"/></Scene></X3D>'
)
result = await _fn(server, "ue_x3d_apply")(x3d=x3d)
data = json.loads(result)
if "error" not in data:
code = mock_ue.execute_python.call_args[0][0]
assert "import os" not in code


class TestPreview:
@pytest.mark.asyncio
async def test_embeds_x3d_no_editor(self, server, mock_ue):
x3d = '<X3D><Scene><Transform DEF="A"/></Scene></X3D>'
data = json.loads(await _fn(server, "ue_x3d_preview")(x3d=x3d))
assert data["chars"] > 0
assert "<x3d-canvas>" in data["html"]
assert 'DEF="A"' in data["html"]
mock_ue.execute_python.assert_not_awaited()
4 changes: 2 additions & 2 deletions ue_mcp/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
numbering was never released and was retired at the Epic-MCP-era reset.
"""

__version__ = "0.3.1"
__version_info__ = (0, 3, 1)
__version__ = "0.4.0"
__version_info__ = (0, 4, 0)
7 changes: 7 additions & 0 deletions ue_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .scene import register as register_scene
from .sequencer import register as register_sequencer
from .spatial import register as register_spatial
from .x3d import register as register_x3d


class Tier(StrEnum):
Expand Down Expand Up @@ -116,6 +117,11 @@ class Tier(StrEnum):
"ue_play_sequence": _LEGACY,
"ue_add_actor_to_sequence": _LEGACY,
"ue_add_keyframe": _LEGACY,
# x3d.py — UE<->X3D thin-slice harness (no Epic counterpart)
"ue_x3d_export": _CORE,
"ue_x3d_validate": _CORE,
"ue_x3d_apply": _CORE,
"ue_x3d_preview": _CORE,
}

PROFILES: dict[str, set[Tier]] = {
Expand Down Expand Up @@ -172,6 +178,7 @@ def tool(self, *, name: str, description: str, annotations: dict | None = None):
register_materials,
register_editor,
register_sequencer,
register_x3d,
)


Expand Down
Loading
Loading