Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset: variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring). Made fully project- and machine-agnostic — no hardcoded paths: - New src/projectPaths.js auto-detects the host .uproject (walk-up), project name, Editor build target, log file, and engine install (EngineAssociation via launcher manifest/registry, else installed-engine scan). All overridable via UE_* env vars. - Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver. - Voxel made optional: Build.cs auto-detects the Voxel plugin (env UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL, so the module builds in projects without Voxel; .uplugin marks Voxel optional. - De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded node_modules/Binaries/Intermediate/__pycache__/secrets from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@ -74,3 +74,20 @@ Plugins/**/Intermediate/*
|
||||
# Cache files for the editor to use
|
||||
DerivedDataCache/*
|
||||
|
||||
# ---> MCP server / Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ---> Python workers
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ---> Secrets & local config (NEVER commit these)
|
||||
# auth token for the agent gateway — copy auth.env.example to auth.env locally
|
||||
auth.env
|
||||
agent-gateway/auth.env
|
||||
agent-gateway/.generated-audio/
|
||||
# real API keys live in a local, untracked copy
|
||||
integrations.local.json
|
||||
|
||||
|
||||
173
FUTURE_MCP_TOOLS.md
Normal file
173
FUTURE_MCP_TOOLS.md
Normal file
@ -0,0 +1,173 @@
|
||||
# UEBlueprintMCP — TODO / Future Tool Coverage
|
||||
|
||||
Domains still missing from the MCP surface. Ordered by likely value.
|
||||
|
||||
## High value (likely soon)
|
||||
|
||||
### pcg_op — mesh-entry & volume helpers (gap found building a PCG graph)
|
||||
- `set_mesh_entries{asset_path, node, entries:[{mesh, weight?}]}` — populate a
|
||||
PCGStaticMeshSpawner's weighted selector. Today this needs raw py: the
|
||||
`mesh_selector_parameters` instance is READ-ONLY (can't reassign), so you must
|
||||
mutate it in place — `msp.set_editor_property("mesh_entries", [...])` where each
|
||||
entry is a `PCGMeshSelectorWeightedEntry` and the mesh goes on
|
||||
`entry.descriptor.static_mesh` (struct is value-copy → set descriptor back).
|
||||
- `set_node_property` struct coercion already handles Vector vs Rotator (3-num
|
||||
list, Vector tried then Rotator(roll,pitch,yaw)) — extend to Vector2D/Color/
|
||||
Transform as needed.
|
||||
- `spawn_pcg_volume{graph, location, scale, name?}` + `generate{actor}` — wrap
|
||||
PCGVolume spawn + `PCGComponent.set_graph` + `generate(True)`. Note: partitioned
|
||||
worlds route instances to PCGPartitionActors async; count there, not on the volume.
|
||||
|
||||
### Sequencer / Cinematics
|
||||
- create_level_sequence{path}
|
||||
- add_binding{seq, actor_name}, remove_binding{seq, guid}
|
||||
- add_track{seq, binding_guid, track_class} (Transform, Float, Visibility, ...)
|
||||
- add_key{seq, track, time, value}
|
||||
- set_playback_range{seq, start, end}
|
||||
- bake_to_anim_sequence{seq, binding}
|
||||
- spawn_camera_actor{location, rotation, fov?}
|
||||
- export_video / export_image_sequence (MovieRenderQueue)
|
||||
|
||||
### GAS — Gameplay Ability System
|
||||
- create_gameplay_ability_bp{path, parent?}
|
||||
- create_attribute_set_bp{path}
|
||||
- create_gameplay_effect_bp{path}
|
||||
- add_gameplay_tag{tag}, remove_gameplay_tag{tag}, list_gameplay_tags{prefix?}
|
||||
- gameplay_cue_create{path}
|
||||
|
||||
### Data-driven assets
|
||||
- create_gameplay_tags_table{path}
|
||||
- data_table_add_row{path, row_name, fields}
|
||||
- data_table_set_cell{path, row, column, value}
|
||||
- data_table_read{path, row?}
|
||||
- curve_table_create / add_curve
|
||||
- data_asset_set_property{path, property, value}
|
||||
- enum_add_value, struct_add_field
|
||||
|
||||
### Physics / Chaos
|
||||
- create_physics_asset{path, skeletal_mesh}
|
||||
- create_physical_material{path, friction?, restitution?, density?}
|
||||
- physics_constraint_set{component, properties}
|
||||
- chaos_destruction_field_spawn
|
||||
|
||||
### Landscape / Foliage / World Partition
|
||||
- landscape_create{section_size, components_per_section, ...}
|
||||
- landscape_apply_material{path}
|
||||
- landscape_paint_layer{layer_name, brush_op:'add|remove', position, radius}
|
||||
- foliage_add_type{static_mesh, density?, ...}
|
||||
- foliage_paint{location, radius}
|
||||
- world_partition_set_loading_range{actor, range}
|
||||
- hlod_build{level_path}
|
||||
|
||||
### Lighting
|
||||
- build_lighting{quality:'preview|medium|high|production'}
|
||||
- set_lumen{enabled, software_raytracing?, hardware_raytracing?}
|
||||
- set_vsm{enabled, resolution?}
|
||||
- light_set_property{actor_name, property, value}
|
||||
|
||||
### PIE (Play-In-Editor) control
|
||||
- pie_play{mode:'selected|standalone|spawn_player', num_players?}
|
||||
- pie_stop{}
|
||||
- pie_pause / pie_resume
|
||||
- pie_eject
|
||||
- pie_screenshot{path}
|
||||
- pie_console_command_in_session{command}
|
||||
- pie_get_log{tail?, errors_only?}
|
||||
|
||||
### Editor / Project settings
|
||||
- ini_get{config:'Engine|Game|Editor|Input', section, key}
|
||||
- ini_set{config, section, key, value}
|
||||
- plugin_enable{name} / plugin_disable{name} / plugin_list{}
|
||||
- platform_target_settings{platform, key, value}
|
||||
|
||||
### Source Control (Perforce/Git/Plastic)
|
||||
- sc_status{paths}
|
||||
- sc_checkout{paths}
|
||||
- sc_revert{paths}
|
||||
- sc_submit{paths, description}
|
||||
- sc_get_user_settings
|
||||
|
||||
### Cook / Package / Build
|
||||
- cook_project{platform, maps?, options?}
|
||||
- package_project{platform, build_config:'Development|Shipping', output_dir}
|
||||
- build_lighting_then_save{level}
|
||||
- check_cook_errors{platform}
|
||||
|
||||
## Medium value
|
||||
|
||||
### Niagara — extending the existing niagara_op
|
||||
- set_module_input (currently stubbed) — write parameters into a module
|
||||
- promote_to_user_parameter
|
||||
- niagara_simulation_cache_record / play
|
||||
|
||||
### UMG — extending widget_op
|
||||
- bind_property{widget, property, function_path}
|
||||
- create_animation{widget, name, duration}
|
||||
- add_animation_track{...}
|
||||
- compile_and_preview
|
||||
|
||||
### Materials — extending material_op
|
||||
- material_function_create
|
||||
- material_layer_create / material_layer_blend_create
|
||||
- substrate_topology helpers (if Substrate enabled)
|
||||
- material_parameter_collection_create
|
||||
|
||||
### Localization
|
||||
- loc_target_create / loc_gather / loc_compile
|
||||
- text_add_to_target{namespace, key, source}
|
||||
|
||||
### Replication / Networking helpers
|
||||
- bp_set_replication{bp_path, replicates?, replicates_movement?}
|
||||
- bp_variable_set_replication{bp_path, name, replication:'None|Replicated|RepNotify'}
|
||||
|
||||
### Online subsystem / OSS / EOS
|
||||
- launch_dedicated_pie_server
|
||||
- set_steam_appid / set_eos_credentials
|
||||
|
||||
### Editor utility / Pipeline
|
||||
- editor_utility_widget_run{path}
|
||||
- editor_utility_blueprint_run{path, function}
|
||||
- exec_python_file{path}
|
||||
- exec_python_inline{code} — gated, dev-only
|
||||
|
||||
### Test / Automation
|
||||
- automation_run{tests:'filter'}
|
||||
- gauntlet_run{config}
|
||||
- unit_test_blueprint{bp_path}
|
||||
|
||||
## Low priority / nice-to-have
|
||||
|
||||
- Composure (in-camera VFX)
|
||||
- USD import/export
|
||||
- Datasmith
|
||||
- Pixel Streaming setup
|
||||
- nDisplay
|
||||
- VR/AR specific (OpenXR session, head tracking)
|
||||
- Web Browser widget
|
||||
- Movie Render Queue presets
|
||||
- AI behavior debugging (LogVisualizer)
|
||||
- Replay / DemoNetDriver
|
||||
|
||||
## Architecture improvements
|
||||
|
||||
- **Common helpers module** (`_common.py`) for `_tee`, `_load`, `_split_pkg`, `_vec`, `_rot` — every module currently duplicates these.
|
||||
- **Versioned envelope** — include schema_version in every result for client compatibility.
|
||||
- **Streaming results** — for long-running ops (cook, lighting build), stream progress via a separate channel instead of one blocking call.
|
||||
- **Auto-discovery of ops** — generate the JSON schemas in server.js from python module introspection, so adding an op in python automatically exposes it.
|
||||
- **Per-op permissions** — read-only vs mutating tag in schema so a client policy can block destructive ops.
|
||||
- **Undo grouping** — wrap each entrypoint in `unreal.ScopedEditorTransaction` so every mutation is one undo step.
|
||||
|
||||
## BUG: apply_graph `spawn_actor` kind hard-crashes the editor (2026-06-22)
|
||||
|
||||
`apply_graph` node kind `spawn_actor` → `UMCPGraphLibrary::SpawnSpawnActorFromClassNode()`
|
||||
(`MCPGraphLibrary.cpp:716`) calls `PlaceNode<UK2Node_SpawnActorFromClass>`, whose blind
|
||||
`AllocateDefaultPins()` trips `check(Result)` in `EdGraphNode.h:586` and **takes the whole
|
||||
editor down** (assertion → StaticShutdownAfterError). Reproduced 3x.
|
||||
|
||||
Workaround used: spawn from C++ instead (`UWorld::SpawnActor`) exposed via a
|
||||
`UBlueprintFunctionLibrary`, or build the deferred-spawn pair manually with `call_function`
|
||||
nodes (`GameplayStatics.BeginDeferredActorSpawnFromClass` + `FinishSpawningActor`).
|
||||
|
||||
Proper fix: construct `UK2Node_SpawnActorFromClass` via `FGraphNodeCreator<>` /
|
||||
set the Class pin before `AllocateDefaultPins`, or guard the assert. Until fixed, **do not
|
||||
use the `spawn_actor` kind**.
|
||||
120
README.md
120
README.md
@ -1,2 +1,120 @@
|
||||
# unreal-engine-mcp-system-plugin
|
||||
# Unreal Engine MCP System Plugin
|
||||
|
||||
A drop-in Unreal Engine **editor plugin** that exposes the UE editor to an
|
||||
[MCP](https://modelcontextprotocol.io) client (Claude Code, Cursor, etc.) as a
|
||||
rich set of tools: Blueprint graph CRUD, UMG/Slate authoring with a screenshot
|
||||
vision loop, materials, Niagara, PCG (incl. PCGEx), assets, member variables,
|
||||
C++ scaffolding & rebuild, headless asset/scene capture, Unreal Insights
|
||||
analysis, the Project Launcher, and more.
|
||||
|
||||
It has two halves that ship together:
|
||||
|
||||
| Part | Lives in | What it is |
|
||||
|------|----------|------------|
|
||||
| **C++ editor module** | `Source/UEBlueprintMCPEditor/` | `UEBlueprintMCPEditor` — Blueprint function libraries the Python workers call. |
|
||||
| **MCP server** | `src/` (Node.js) + `ue_side/` (Python) | A stdio MCP server that drives the editor over Remote Control. |
|
||||
|
||||
> **Portable by design.** Nothing is hardcoded to a project, user, or machine.
|
||||
> The server discovers the host `.uproject`, project name, Editor build target,
|
||||
> log file, and engine install automatically from where the plugin sits on disk.
|
||||
> Every value can still be overridden with an environment variable (below).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
1. Clone (or submodule) this repo into your project's `Plugins/` folder. The
|
||||
folder name doesn't matter to UE — the plugin is identified by
|
||||
`UEBlueprintMCP.uplugin`:
|
||||
|
||||
```
|
||||
<YourProject>/Plugins/UEBlueprintMCP/ <- this repo
|
||||
```
|
||||
|
||||
2. Enable the engine plugins it builds against (most are on by default):
|
||||
**Python Editor Script Plugin**, **Remote Control API**, **Niagara**.
|
||||
*(Voxel is optional — see below.)*
|
||||
|
||||
3. Generate project files and build the editor (the plugin has a C++ module).
|
||||
|
||||
4. In the editor, enable **Remote Control** and start its web server
|
||||
(`WebControl.StartServer`, or set `WebControl.EnableServerOnStartup=true`).
|
||||
|
||||
5. Install the MCP server deps:
|
||||
|
||||
```bash
|
||||
cd Plugins/UEBlueprintMCP
|
||||
npm install
|
||||
```
|
||||
|
||||
6. Point your MCP client at `src/server.js`, e.g.:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ue-blueprint": {
|
||||
"command": "node",
|
||||
"args": ["<abs-path>/Plugins/UEBlueprintMCP/src/server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `ping_ue` first to confirm the editor is reachable.
|
||||
|
||||
---
|
||||
|
||||
## Zero-config path resolution
|
||||
|
||||
`src/projectPaths.js` resolves everything from the plugin's location:
|
||||
|
||||
- **Project** — walks up from the plugin to the nearest `*.uproject`.
|
||||
- **Project name / Editor target / log file** — derived from that `.uproject`
|
||||
(`<Name>`, `<Name>Editor`, `Saved/Logs/<Name>.log`).
|
||||
- **Engine** — read from the project's `EngineAssociation`, resolved via the
|
||||
Epic launcher manifest / registry, falling back to a scan of installed
|
||||
engines.
|
||||
|
||||
### Environment overrides
|
||||
|
||||
| Variable | Overrides |
|
||||
|----------|-----------|
|
||||
| `UE_PROJECT_DIR` | the `.uproject` parent folder |
|
||||
| `UE_UPROJECT_NAME` | e.g. `MyGame.uproject` |
|
||||
| `UE_BUILD_TARGET` | e.g. `MyGameEditor` |
|
||||
| `UE_BUILD_CONFIG` | build config (default `Development`) |
|
||||
| `UE_ENGINE_DIR` | engine root (the folder containing `/Engine`) |
|
||||
| `UE_INSIGHTS_EXE` | full path to `UnrealInsights.exe` |
|
||||
| `UE_REMOTE_CONTROL_URL` | Remote Control base URL (default `http://127.0.0.1:30010`) |
|
||||
| `UE_MCP_WITH_VOXEL` | `1`/`0` to force the optional Voxel integration on/off |
|
||||
|
||||
---
|
||||
|
||||
## Optional: Voxel integration
|
||||
|
||||
The `voxel_graph_op` tool authors [Voxel Plugin](https://voxelplugin.com) graphs.
|
||||
Voxel is heavy and most projects don't have it, so the plugin **compiles with or
|
||||
without it**:
|
||||
|
||||
- The build auto-detects the Voxel plugin. If present, voxel support is enabled
|
||||
(`WITH_MCP_VOXEL=1`); otherwise the C++ compiles to stubs and `voxel_graph_op`
|
||||
returns a "Voxel not enabled" error.
|
||||
- Force it with `UE_MCP_WITH_VOXEL=1` (errors if Voxel is missing) or disable
|
||||
with `UE_MCP_WITH_VOXEL=0`.
|
||||
|
||||
---
|
||||
|
||||
## Optional: in-engine agent gateway
|
||||
|
||||
`agent-gateway/` is a small HTTP gateway that lets an in-engine WebBrowser widget
|
||||
talk to the MCP server through the Claude Agent SDK. It is **not required** for
|
||||
MCP clients — see `agent-gateway/README.md`. Copy `agent-gateway/auth.env.example`
|
||||
to `agent-gateway/auth.env` and add your token; that file is git-ignored.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
- `UI_WORKFLOW.md` — the mandatory protocol for any UMG/UI work (theme tokens,
|
||||
components, the screenshot vision loop).
|
||||
- `FUTURE_MCP_TOOLS.md` — backlog of tool ideas.
|
||||
|
||||
202
Source/UEBlueprintMCPEditor/Private/MCPCaptureLibrary.cpp
Normal file
202
Source/UEBlueprintMCPEditor/Private/MCPCaptureLibrary.cpp
Normal file
@ -0,0 +1,202 @@
|
||||
#include "MCPCaptureLibrary.h"
|
||||
|
||||
#include "CanvasTypes.h"
|
||||
#include "Components/SceneCaptureComponent2D.h"
|
||||
#include "Editor.h"
|
||||
#include "Engine/TextureRenderTarget2D.h"
|
||||
#include "Engine/World.h"
|
||||
#include "HAL/PlatformFileManager.h"
|
||||
#include "IImageWrapper.h"
|
||||
#include "IImageWrapperModule.h"
|
||||
#include "Misc/App.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
#include "ObjectTools.h"
|
||||
#include "RenderingThread.h"
|
||||
#include "Misc/ObjectThumbnail.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Encode an array of BGRA8 pixels to a PNG on disk.
|
||||
bool SaveBGRAToPNG(
|
||||
const uint8* BGRA,
|
||||
int32 NumBytes,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
FString& OutFullPath,
|
||||
FString& OutError)
|
||||
{
|
||||
if (!BGRA || NumBytes < Width * Height * 4)
|
||||
{
|
||||
OutError = TEXT("empty / undersized pixel buffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
IImageWrapperModule& Mod =
|
||||
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
|
||||
TSharedPtr<IImageWrapper> PNG = Mod.CreateImageWrapper(EImageFormat::PNG);
|
||||
if (!PNG.IsValid() ||
|
||||
!PNG->SetRaw(BGRA, Width * Height * 4, Width, Height, ERGBFormat::BGRA, 8))
|
||||
{
|
||||
OutError = TEXT("PNG SetRaw failed");
|
||||
return false;
|
||||
}
|
||||
const TArray64<uint8>& Compressed = PNG->GetCompressed(100);
|
||||
|
||||
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
|
||||
if (!PF.DirectoryExists(*OutputAbsPath))
|
||||
{
|
||||
PF.CreateDirectoryTree(*OutputAbsPath);
|
||||
}
|
||||
|
||||
const FString FullPath = FPaths::Combine(OutputAbsPath, FileNameNoExt + TEXT(".png"));
|
||||
if (!FFileHelper::SaveArrayToFile(Compressed, *FullPath))
|
||||
{
|
||||
OutError = FString::Printf(TEXT("SaveArrayToFile failed: %s"), *FullPath);
|
||||
return false;
|
||||
}
|
||||
OutFullPath = FullPath;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool UMCPCaptureLibrary::RenderAssetThumbnailToPNG(
|
||||
const FString& AssetPath,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError)
|
||||
{
|
||||
OutFullPath.Reset();
|
||||
OutError.Reset();
|
||||
|
||||
const int32 W = FMath::Clamp(Width, 16, 2048);
|
||||
const int32 H = FMath::Clamp(Height, 16, 2048);
|
||||
|
||||
UObject* Object = StaticLoadObject(UObject::StaticClass(), nullptr, *AssetPath);
|
||||
if (!Object)
|
||||
{
|
||||
OutError = FString::Printf(TEXT("could not load asset: %s"), *AssetPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Render the asset through its registered thumbnail renderer. This is exactly
|
||||
// what the Content Browser uses, so it works for every asset type and needs
|
||||
// no open editor. RenderThumbnail allocates its own render target when we
|
||||
// pass null and fills the thumbnail with uncompressed BGRA8 pixels.
|
||||
FObjectThumbnail Thumbnail;
|
||||
ThumbnailTools::RenderThumbnail(
|
||||
Object,
|
||||
static_cast<uint32>(W),
|
||||
static_cast<uint32>(H),
|
||||
ThumbnailTools::EThumbnailTextureFlushMode::AlwaysFlush,
|
||||
nullptr,
|
||||
&Thumbnail);
|
||||
|
||||
const TArray<uint8>& Bytes = Thumbnail.GetUncompressedImageData();
|
||||
if (Bytes.Num() == 0)
|
||||
{
|
||||
OutError = FString::Printf(
|
||||
TEXT("thumbnail renderer produced no pixels for %s (no renderer for this asset type?)"),
|
||||
*AssetPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return SaveBGRAToPNG(
|
||||
Bytes.GetData(),
|
||||
Bytes.Num(),
|
||||
Thumbnail.GetImageWidth(),
|
||||
Thumbnail.GetImageHeight(),
|
||||
OutputAbsPath,
|
||||
FileNameNoExt,
|
||||
OutFullPath,
|
||||
OutError);
|
||||
}
|
||||
|
||||
bool UMCPCaptureLibrary::CaptureEditorSceneToPNG(
|
||||
FVector Location,
|
||||
FRotator Rotation,
|
||||
float FOV,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError)
|
||||
{
|
||||
OutFullPath.Reset();
|
||||
OutError.Reset();
|
||||
|
||||
UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
|
||||
if (!World)
|
||||
{
|
||||
OutError = TEXT("no editor world available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 W = FMath::Clamp(Width, 16, 4096);
|
||||
const int32 H = FMath::Clamp(Height, 16, 4096);
|
||||
|
||||
UTextureRenderTarget2D* RT = NewObject<UTextureRenderTarget2D>(GetTransientPackage());
|
||||
RT->ClearColor = FLinearColor::Black;
|
||||
RT->TargetGamma = 2.2f;
|
||||
RT->InitCustomFormat(W, H, PF_B8G8R8A8, /*bForceLinearGamma=*/false);
|
||||
RT->UpdateResourceImmediate(true);
|
||||
|
||||
// Transient capture component bound to the editor world — no actor, no viewport.
|
||||
USceneCaptureComponent2D* Capture =
|
||||
NewObject<USceneCaptureComponent2D>(World->GetWorldSettings());
|
||||
if (!Capture)
|
||||
{
|
||||
OutError = TEXT("failed to create SceneCaptureComponent2D");
|
||||
return false;
|
||||
}
|
||||
Capture->TextureTarget = RT;
|
||||
Capture->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
|
||||
Capture->FOVAngle = FMath::Clamp(FOV, 5.f, 170.f);
|
||||
Capture->bCaptureEveryFrame = false;
|
||||
Capture->bCaptureOnMovement = false;
|
||||
Capture->bAlwaysPersistRenderingState = true;
|
||||
Capture->RegisterComponentWithWorld(World);
|
||||
Capture->SetWorldLocationAndRotation(Location, Rotation);
|
||||
|
||||
Capture->CaptureScene();
|
||||
FlushRenderingCommands();
|
||||
|
||||
FTextureRenderTargetResource* RTResource = RT->GameThread_GetRenderTargetResource();
|
||||
bool bOk = false;
|
||||
if (RTResource)
|
||||
{
|
||||
TArray<FColor> Pixels;
|
||||
Pixels.SetNumUninitialized(W * H);
|
||||
FReadSurfaceDataFlags ReadFlags(RCM_UNorm);
|
||||
ReadFlags.SetLinearToGamma(false);
|
||||
if (RTResource->ReadPixels(Pixels, ReadFlags))
|
||||
{
|
||||
// FColor is BGRA in memory.
|
||||
bOk = SaveBGRAToPNG(
|
||||
reinterpret_cast<const uint8*>(Pixels.GetData()),
|
||||
Pixels.Num() * sizeof(FColor),
|
||||
W, H, OutputAbsPath, FileNameNoExt, OutFullPath, OutError);
|
||||
}
|
||||
else
|
||||
{
|
||||
OutError = TEXT("ReadPixels failed");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
OutError = TEXT("RT resource null after capture");
|
||||
}
|
||||
|
||||
// Tear down the transient component.
|
||||
Capture->UnregisterComponent();
|
||||
Capture->DestroyComponent();
|
||||
|
||||
return bOk;
|
||||
}
|
||||
2372
Source/UEBlueprintMCPEditor/Private/MCPGraphLibrary.cpp
Normal file
2372
Source/UEBlueprintMCPEditor/Private/MCPGraphLibrary.cpp
Normal file
File diff suppressed because it is too large
Load Diff
338
Source/UEBlueprintMCPEditor/Private/MCPMaterialLibrary.cpp
Normal file
338
Source/UEBlueprintMCPEditor/Private/MCPMaterialLibrary.cpp
Normal file
@ -0,0 +1,338 @@
|
||||
#include "MCPMaterialLibrary.h"
|
||||
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Materials/Material.h"
|
||||
#include "Materials/MaterialExpression.h"
|
||||
#include "Materials/MaterialFunction.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
FString JsonStringifyMaterial(const TSharedRef<FJsonObject>& Object)
|
||||
{
|
||||
FString Out;
|
||||
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out);
|
||||
FJsonSerializer::Serialize(Object, Writer);
|
||||
return Out;
|
||||
}
|
||||
|
||||
FString ExpressionId(const UMaterialExpression* Expression)
|
||||
{
|
||||
if (!Expression)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
#if WITH_EDITORONLY_DATA
|
||||
if (Expression->MaterialExpressionGuid.IsValid())
|
||||
{
|
||||
return Expression->MaterialExpressionGuid.ToString(EGuidFormats::DigitsWithHyphensInBraces);
|
||||
}
|
||||
#endif
|
||||
|
||||
return Expression->GetPathName();
|
||||
}
|
||||
|
||||
FString OutputNameForIndex(UMaterialExpression* Expression, int32 OutputIndex)
|
||||
{
|
||||
if (!Expression || OutputIndex < 0)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
|
||||
if (!Outputs.IsValidIndex(OutputIndex))
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
return Outputs[OutputIndex].OutputName.ToString();
|
||||
}
|
||||
|
||||
int32 ResolveOutputIndex(UMaterialExpression* Expression, const FString& OutputNameOrIndex)
|
||||
{
|
||||
if (!Expression)
|
||||
{
|
||||
return INDEX_NONE;
|
||||
}
|
||||
|
||||
FString Needle = OutputNameOrIndex;
|
||||
Needle.TrimStartAndEndInline();
|
||||
if (Needle.IsEmpty() || Needle.Equals(TEXT("None"), ESearchCase::IgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32 NumericIndex = INDEX_NONE;
|
||||
if (LexTryParseString(NumericIndex, *Needle))
|
||||
{
|
||||
return Expression->GetOutputs().IsValidIndex(NumericIndex) ? NumericIndex : INDEX_NONE;
|
||||
}
|
||||
|
||||
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
|
||||
for (int32 OutputIndex = 0; OutputIndex < Outputs.Num(); ++OutputIndex)
|
||||
{
|
||||
if (Outputs[OutputIndex].OutputName.ToString().Equals(Needle, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return OutputIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return Outputs.Num() == 1 ? 0 : INDEX_NONE;
|
||||
}
|
||||
|
||||
int32 ResolveInputIndex(UMaterialExpression* Expression, const FString& InputNameOrIndex)
|
||||
{
|
||||
if (!Expression)
|
||||
{
|
||||
return INDEX_NONE;
|
||||
}
|
||||
|
||||
FString Needle = InputNameOrIndex;
|
||||
Needle.TrimStartAndEndInline();
|
||||
if (Needle.IsEmpty() || Needle.Equals(TEXT("None"), ESearchCase::IgnoreCase))
|
||||
{
|
||||
return Expression->CountInputs() == 1 ? 0 : INDEX_NONE;
|
||||
}
|
||||
|
||||
int32 NumericIndex = INDEX_NONE;
|
||||
if (LexTryParseString(NumericIndex, *Needle))
|
||||
{
|
||||
return NumericIndex >= 0 && NumericIndex < Expression->CountInputs() ? NumericIndex : INDEX_NONE;
|
||||
}
|
||||
|
||||
for (int32 InputIndex = 0; InputIndex < Expression->CountInputs(); ++InputIndex)
|
||||
{
|
||||
if (Expression->GetInputName(InputIndex).ToString().Equals(Needle, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return InputIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return INDEX_NONE;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> MakeStatusJson(bool bOk, const FString& Error = FString())
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("ok"), bOk);
|
||||
if (!Error.IsEmpty())
|
||||
{
|
||||
Root->SetStringField(TEXT("error"), Error);
|
||||
}
|
||||
return Root;
|
||||
}
|
||||
|
||||
void AppendExpressionJson(UMaterialExpression* Expression, TArray<TSharedPtr<FJsonValue>>& Nodes, TArray<TSharedPtr<FJsonValue>>& Edges)
|
||||
{
|
||||
if (!Expression)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Node = MakeShared<FJsonObject>();
|
||||
Node->SetStringField(TEXT("guid"), ExpressionId(Expression));
|
||||
Node->SetStringField(TEXT("name"), Expression->GetName());
|
||||
Node->SetStringField(TEXT("class"), Expression->GetClass()->GetPathName());
|
||||
#if WITH_EDITORONLY_DATA
|
||||
Node->SetNumberField(TEXT("x"), Expression->MaterialExpressionEditorX);
|
||||
Node->SetNumberField(TEXT("y"), Expression->MaterialExpressionEditorY);
|
||||
if (!Expression->Desc.IsEmpty())
|
||||
{
|
||||
Node->SetStringField(TEXT("desc"), Expression->Desc);
|
||||
}
|
||||
#endif
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Inputs;
|
||||
const int32 InputCount = Expression->CountInputs();
|
||||
for (int32 InputIndex = 0; InputIndex < InputCount; ++InputIndex)
|
||||
{
|
||||
const FExpressionInput* Input = Expression->GetInput(InputIndex);
|
||||
const FName InputName = Expression->GetInputName(InputIndex);
|
||||
|
||||
TSharedRef<FJsonObject> InputJson = MakeShared<FJsonObject>();
|
||||
InputJson->SetStringField(TEXT("name"), InputName.ToString());
|
||||
InputJson->SetNumberField(TEXT("index"), InputIndex);
|
||||
InputJson->SetBoolField(TEXT("connected"), Input && Input->Expression);
|
||||
Inputs.Add(MakeShared<FJsonValueObject>(InputJson));
|
||||
|
||||
if (Input && Input->Expression)
|
||||
{
|
||||
TSharedRef<FJsonObject> Edge = MakeShared<FJsonObject>();
|
||||
TArray<TSharedPtr<FJsonValue>> From;
|
||||
From.Add(MakeShared<FJsonValueString>(ExpressionId(Input->Expression)));
|
||||
From.Add(MakeShared<FJsonValueString>(OutputNameForIndex(Input->Expression, Input->OutputIndex)));
|
||||
TArray<TSharedPtr<FJsonValue>> To;
|
||||
To.Add(MakeShared<FJsonValueString>(ExpressionId(Expression)));
|
||||
To.Add(MakeShared<FJsonValueString>(InputName.ToString()));
|
||||
Edge->SetArrayField(TEXT("from"), From);
|
||||
Edge->SetArrayField(TEXT("to"), To);
|
||||
Edges.Add(MakeShared<FJsonValueObject>(Edge));
|
||||
}
|
||||
}
|
||||
Node->SetArrayField(TEXT("inputs"), Inputs);
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> OutputsJson;
|
||||
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
|
||||
for (int32 OutputIndex = 0; OutputIndex < Outputs.Num(); ++OutputIndex)
|
||||
{
|
||||
TSharedRef<FJsonObject> OutputJson = MakeShared<FJsonObject>();
|
||||
OutputJson->SetStringField(TEXT("name"), Outputs[OutputIndex].OutputName.ToString());
|
||||
OutputJson->SetNumberField(TEXT("index"), OutputIndex);
|
||||
OutputsJson.Add(MakeShared<FJsonValueObject>(OutputJson));
|
||||
}
|
||||
Node->SetArrayField(TEXT("outputs"), OutputsJson);
|
||||
|
||||
Nodes.Add(MakeShared<FJsonValueObject>(Node));
|
||||
}
|
||||
}
|
||||
|
||||
TArray<UMaterialExpression*> UMCPMaterialLibrary::GetMaterialExpressions(UObject* MaterialOrFunction)
|
||||
{
|
||||
TArray<UMaterialExpression*> Out;
|
||||
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
|
||||
{
|
||||
for (TObjectPtr<UMaterialExpression> Expression : Material->GetExpressionCollection().Expressions)
|
||||
{
|
||||
if (Expression)
|
||||
{
|
||||
Out.Add(Expression.Get());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (UMaterialFunction* Function = Cast<UMaterialFunction>(MaterialOrFunction))
|
||||
{
|
||||
for (TObjectPtr<UMaterialExpression> Expression : Function->GetExpressionCollection().Expressions)
|
||||
{
|
||||
if (Expression)
|
||||
{
|
||||
Out.Add(Expression.Get());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
UMaterialExpression* UMCPMaterialLibrary::FindMaterialExpression(UObject* MaterialOrFunction, const FString& NodeId)
|
||||
{
|
||||
for (UMaterialExpression* Expression : GetMaterialExpressions(MaterialOrFunction))
|
||||
{
|
||||
if (!Expression)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ExpressionId(Expression) == NodeId || Expression->GetName() == NodeId || Expression->GetPathName() == NodeId)
|
||||
{
|
||||
return Expression;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
FString UMCPMaterialLibrary::DumpMaterialGraphToJson(UObject* MaterialOrFunction)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
if (!MaterialOrFunction)
|
||||
{
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), TEXT("null material/function"));
|
||||
return JsonStringifyMaterial(Root);
|
||||
}
|
||||
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("asset"), MaterialOrFunction->GetPathName());
|
||||
Root->SetStringField(TEXT("class"), MaterialOrFunction->GetClass()->GetPathName());
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Nodes;
|
||||
TArray<TSharedPtr<FJsonValue>> Edges;
|
||||
for (UMaterialExpression* Expression : GetMaterialExpressions(MaterialOrFunction))
|
||||
{
|
||||
AppendExpressionJson(Expression, Nodes, Edges);
|
||||
}
|
||||
Root->SetArrayField(TEXT("nodes"), Nodes);
|
||||
Root->SetArrayField(TEXT("edges"), Edges);
|
||||
|
||||
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
|
||||
{
|
||||
TSharedRef<FJsonObject> Outputs = MakeShared<FJsonObject>();
|
||||
for (int32 PropertyIndex = 0; PropertyIndex < static_cast<int32>(MP_MAX); ++PropertyIndex)
|
||||
{
|
||||
const EMaterialProperty Property = static_cast<EMaterialProperty>(PropertyIndex);
|
||||
FExpressionInput* Input = Material->GetExpressionInputForProperty(Property);
|
||||
if (!Input || !Input->Expression)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Ref = MakeShared<FJsonObject>();
|
||||
TArray<TSharedPtr<FJsonValue>> From;
|
||||
From.Add(MakeShared<FJsonValueString>(ExpressionId(Input->Expression)));
|
||||
From.Add(MakeShared<FJsonValueString>(OutputNameForIndex(Input->Expression, Input->OutputIndex)));
|
||||
Ref->SetArrayField(TEXT("from"), From);
|
||||
Outputs->SetObjectField(UEnum::GetValueAsString(Property), Ref);
|
||||
}
|
||||
Root->SetObjectField(TEXT("outputs"), Outputs);
|
||||
}
|
||||
|
||||
return JsonStringifyMaterial(Root);
|
||||
}
|
||||
|
||||
FString UMCPMaterialLibrary::ConnectMaterialExpressionsRaw(UObject* MaterialOrFunction, UMaterialExpression* FromExpression, const FString& FromOutput, UMaterialExpression* ToExpression, const FString& ToInput)
|
||||
{
|
||||
if (!MaterialOrFunction)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null material/function")));
|
||||
}
|
||||
if (!FromExpression)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null source expression")));
|
||||
}
|
||||
if (!ToExpression)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null target expression")));
|
||||
}
|
||||
|
||||
const int32 OutputIndex = ResolveOutputIndex(FromExpression, FromOutput);
|
||||
if (OutputIndex == INDEX_NONE)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("source output not found: %s.%s"), *FromExpression->GetName(), *FromOutput)));
|
||||
}
|
||||
|
||||
const int32 InputIndex = ResolveInputIndex(ToExpression, ToInput);
|
||||
if (InputIndex == INDEX_NONE)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("target input not found: %s.%s"), *ToExpression->GetName(), *ToInput)));
|
||||
}
|
||||
|
||||
FExpressionInput* Input = ToExpression->GetInput(InputIndex);
|
||||
if (!Input)
|
||||
{
|
||||
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("target input is null: %s.%s"), *ToExpression->GetName(), *ToInput)));
|
||||
}
|
||||
|
||||
Input->Expression = FromExpression;
|
||||
Input->OutputIndex = OutputIndex;
|
||||
|
||||
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
|
||||
{
|
||||
Material->PreEditChange(nullptr);
|
||||
Material->PostEditChange();
|
||||
Material->MarkPackageDirty();
|
||||
}
|
||||
else if (UMaterialFunction* Function = Cast<UMaterialFunction>(MaterialOrFunction))
|
||||
{
|
||||
Function->PreEditChange(nullptr);
|
||||
Function->PostEditChange();
|
||||
Function->MarkPackageDirty();
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Root = MakeStatusJson(true);
|
||||
Root->SetStringField(TEXT("from"), FromExpression->GetName());
|
||||
Root->SetStringField(TEXT("from_output"), OutputNameForIndex(FromExpression, OutputIndex));
|
||||
Root->SetStringField(TEXT("to"), ToExpression->GetName());
|
||||
Root->SetStringField(TEXT("to_input"), ToExpression->GetInputName(InputIndex).ToString());
|
||||
return JsonStringifyMaterial(Root);
|
||||
}
|
||||
275
Source/UEBlueprintMCPEditor/Private/MCPNiagaraLibrary.cpp
Normal file
275
Source/UEBlueprintMCPEditor/Private/MCPNiagaraLibrary.cpp
Normal file
@ -0,0 +1,275 @@
|
||||
#include "MCPNiagaraLibrary.h"
|
||||
|
||||
#include "AssetToolsModule.h"
|
||||
#include "IAssetTools.h"
|
||||
#include "AssetRegistry/AssetData.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
#include "UObject/Package.h"
|
||||
#include "UObject/UObjectGlobals.h"
|
||||
|
||||
#include "NiagaraSystem.h"
|
||||
#include "NiagaraSystemFactoryNew.h"
|
||||
#include "NiagaraEmitter.h"
|
||||
#include "NiagaraEmitterHandle.h"
|
||||
#include "NiagaraScript.h"
|
||||
#include "NiagaraRendererProperties.h"
|
||||
#include "NiagaraScriptSource.h"
|
||||
#include "NiagaraGraph.h"
|
||||
#include "NiagaraNodeOutput.h"
|
||||
#include "NiagaraNodeFunctionCall.h"
|
||||
#include "NiagaraCommon.h"
|
||||
#include "ViewModels/Stack/NiagaraStackGraphUtilities.h"
|
||||
#include "NiagaraEditorUtilities.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
struct FStackLocation
|
||||
{
|
||||
UNiagaraNodeOutput* OutputNode = nullptr;
|
||||
bool bValid() const { return OutputNode != nullptr; }
|
||||
};
|
||||
|
||||
ENiagaraScriptUsage ResolveUsage(const FString& GroupName, bool& bOutIsSystem)
|
||||
{
|
||||
bOutIsSystem = false;
|
||||
const FString G = GroupName.TrimStartAndEnd();
|
||||
if (G.Equals(TEXT("EmitterSpawn"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::EmitterSpawnScript;
|
||||
if (G.Equals(TEXT("EmitterUpdate"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::EmitterUpdateScript;
|
||||
if (G.Equals(TEXT("ParticleSpawn"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::ParticleSpawnScript;
|
||||
if (G.Equals(TEXT("ParticleUpdate"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::ParticleUpdateScript;
|
||||
if (G.Equals(TEXT("SystemSpawn"), ESearchCase::IgnoreCase)) { bOutIsSystem = true; return ENiagaraScriptUsage::SystemSpawnScript; }
|
||||
if (G.Equals(TEXT("SystemUpdate"), ESearchCase::IgnoreCase)) { bOutIsSystem = true; return ENiagaraScriptUsage::SystemUpdateScript; }
|
||||
return ENiagaraScriptUsage::ParticleSpawnScript;
|
||||
}
|
||||
|
||||
UNiagaraNodeOutput* FindOutputNodeFromScript(UNiagaraScript* Script, ENiagaraScriptUsage Usage)
|
||||
{
|
||||
if (!Script) return nullptr;
|
||||
UNiagaraScriptSourceBase* SourceBase = Script->GetLatestSource();
|
||||
UNiagaraScriptSource* Source = Cast<UNiagaraScriptSource>(SourceBase);
|
||||
if (!Source || !Source->NodeGraph) return nullptr;
|
||||
// FindOutputNode is not NIAGARAEDITOR_API; FindEquivalentOutputNode is.
|
||||
return Source->NodeGraph->FindEquivalentOutputNode(Usage, FGuid());
|
||||
}
|
||||
|
||||
void GatherFunctionCallNodes(UNiagaraNodeOutput* OutputNode, TArray<UNiagaraNodeFunctionCall*>& Out)
|
||||
{
|
||||
if (!OutputNode) return;
|
||||
UEdGraph* Graph = OutputNode->GetGraph();
|
||||
if (!Graph) return;
|
||||
for (UEdGraphNode* Node : Graph->Nodes)
|
||||
{
|
||||
if (UNiagaraNodeFunctionCall* FC = Cast<UNiagaraNodeFunctionCall>(Node))
|
||||
{
|
||||
// Filter to modules attached to this output's usage. GetCalledUsage()
|
||||
// returns the usage of the inner module script (usually Module).
|
||||
// Without param-map walking we list all function-calls in the graph
|
||||
// tied to the same output usage — good enough for list/remove.
|
||||
Out.Add(FC);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FNiagaraEmitterHandle* FindEmitterHandleMutable(UNiagaraSystem* System, const FString& EmitterName)
|
||||
{
|
||||
if (!System) return nullptr;
|
||||
TArray<FNiagaraEmitterHandle>& Handles = System->GetEmitterHandles();
|
||||
for (FNiagaraEmitterHandle& H : Handles)
|
||||
{
|
||||
if (H.GetName().ToString().Equals(EmitterName, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return &H;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
FStackLocation LocateStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName)
|
||||
{
|
||||
FStackLocation Loc;
|
||||
bool bIsSystem = false;
|
||||
const ENiagaraScriptUsage Usage = ResolveUsage(GroupName, bIsSystem);
|
||||
|
||||
if (!System) return Loc;
|
||||
|
||||
if (bIsSystem)
|
||||
{
|
||||
UNiagaraScript* Script = (Usage == ENiagaraScriptUsage::SystemSpawnScript)
|
||||
? System->GetSystemSpawnScript()
|
||||
: System->GetSystemUpdateScript();
|
||||
Loc.OutputNode = FindOutputNodeFromScript(Script, Usage);
|
||||
return Loc;
|
||||
}
|
||||
|
||||
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
|
||||
if (!Handle) return Loc;
|
||||
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
|
||||
FVersionedNiagaraEmitterData* Data = Versioned.GetEmitterData();
|
||||
if (!Data) return Loc;
|
||||
UNiagaraScript* Script = Data->GetScript(Usage, FGuid());
|
||||
Loc.OutputNode = FindOutputNodeFromScript(Script, Usage);
|
||||
return Loc;
|
||||
}
|
||||
}
|
||||
|
||||
bool UMCPNiagaraLibrary::CreateNiagaraSystem(const FString& PackagePath)
|
||||
{
|
||||
if (PackagePath.IsEmpty()) return false;
|
||||
|
||||
FString PackageName, AssetName;
|
||||
if (!PackagePath.Split(TEXT("/"), &PackageName, &AssetName, ESearchCase::IgnoreCase, ESearchDir::FromEnd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
|
||||
UNiagaraSystemFactoryNew* Factory = NewObject<UNiagaraSystemFactoryNew>();
|
||||
UObject* Created = AssetTools.CreateAsset(AssetName, PackageName, UNiagaraSystem::StaticClass(), Factory);
|
||||
return Created != nullptr;
|
||||
}
|
||||
|
||||
TArray<FString> UMCPNiagaraLibrary::GetEmitterHandleNames(UNiagaraSystem* System)
|
||||
{
|
||||
TArray<FString> Out;
|
||||
if (!System) return Out;
|
||||
for (const FNiagaraEmitterHandle& H : System->GetEmitterHandles())
|
||||
{
|
||||
Out.Add(H.GetName().ToString());
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
FString UMCPNiagaraLibrary::AddEmitterToSystem(UNiagaraSystem* System, UNiagaraEmitter* SourceEmitter, const FString& EmitterName)
|
||||
{
|
||||
if (!System || !SourceEmitter) return FString();
|
||||
|
||||
System->Modify();
|
||||
// Use the editor-utility path that performs full setup (merge, renderer
|
||||
// initialization, parameter refresh). The raw UNiagaraSystem::AddEmitterHandle
|
||||
// skips most of this and leaves the emitter non-functional in preview.
|
||||
const FGuid HandleId = FNiagaraEditorUtilities::AddEmitterToSystem(
|
||||
*System, *SourceEmitter, FGuid(), /*bCreateCopy=*/true);
|
||||
if (!HandleId.IsValid()) return FString();
|
||||
|
||||
// Find the handle we just added by id and (optionally) rename it.
|
||||
for (FNiagaraEmitterHandle& H : System->GetEmitterHandles())
|
||||
{
|
||||
if (H.GetId() == HandleId)
|
||||
{
|
||||
if (!EmitterName.IsEmpty())
|
||||
{
|
||||
H.SetName(FName(*EmitterName), *System);
|
||||
}
|
||||
return H.GetName().ToString();
|
||||
}
|
||||
}
|
||||
return FString();
|
||||
}
|
||||
|
||||
bool UMCPNiagaraLibrary::RemoveEmitterFromSystem(UNiagaraSystem* System, const FString& EmitterName)
|
||||
{
|
||||
if (!System) return false;
|
||||
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
|
||||
if (!Handle) return false;
|
||||
System->Modify();
|
||||
System->RemoveEmitterHandle(*Handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
TArray<FString> UMCPNiagaraLibrary::ListModules(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName)
|
||||
{
|
||||
TArray<FString> Out;
|
||||
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
|
||||
if (!Loc.bValid()) return Out;
|
||||
|
||||
TArray<UNiagaraNodeFunctionCall*> ModuleNodes;
|
||||
GatherFunctionCallNodes(Loc.OutputNode, ModuleNodes);
|
||||
for (UNiagaraNodeFunctionCall* N : ModuleNodes)
|
||||
{
|
||||
if (N) Out.Add(N->GetFunctionName());
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
FString UMCPNiagaraLibrary::AddModuleToStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, UNiagaraScript* ModuleScript, int32 Index)
|
||||
{
|
||||
if (!ModuleScript) return FString();
|
||||
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
|
||||
if (!Loc.bValid()) return FString();
|
||||
|
||||
System->Modify();
|
||||
UNiagaraNodeFunctionCall* Added = FNiagaraStackGraphUtilities::AddScriptModuleToStack(
|
||||
ModuleScript, *Loc.OutputNode, Index, FString(), FGuid());
|
||||
if (!Added) return FString();
|
||||
return Added->GetFunctionName();
|
||||
}
|
||||
|
||||
bool UMCPNiagaraLibrary::RemoveModuleFromStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, const FString& ModuleName)
|
||||
{
|
||||
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
|
||||
if (!Loc.bValid()) return false;
|
||||
|
||||
TArray<UNiagaraNodeFunctionCall*> ModuleNodes;
|
||||
GatherFunctionCallNodes(Loc.OutputNode, ModuleNodes);
|
||||
|
||||
for (UNiagaraNodeFunctionCall* N : ModuleNodes)
|
||||
{
|
||||
if (N && N->GetFunctionName().Equals(ModuleName, ESearchCase::IgnoreCase))
|
||||
{
|
||||
System->Modify();
|
||||
UEdGraph* Graph = N->GetGraph();
|
||||
if (Graph)
|
||||
{
|
||||
Graph->Modify();
|
||||
N->BreakAllNodeLinks();
|
||||
Graph->RemoveNode(N);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
FString UMCPNiagaraLibrary::AddRendererToEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass)
|
||||
{
|
||||
if (!RendererClass || !RendererClass->IsChildOf(UNiagaraRendererProperties::StaticClass())) return FString();
|
||||
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
|
||||
if (!Handle) return FString();
|
||||
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
|
||||
UNiagaraEmitter* Emitter = Versioned.Emitter;
|
||||
if (!Emitter) return FString();
|
||||
|
||||
Emitter->Modify();
|
||||
UNiagaraRendererProperties* Renderer = NewObject<UNiagaraRendererProperties>(Emitter, RendererClass, NAME_None, RF_Transactional);
|
||||
Emitter->AddRenderer(Renderer, Versioned.Version);
|
||||
return RendererClass->GetName();
|
||||
}
|
||||
|
||||
int32 UMCPNiagaraLibrary::RemoveRenderersFromEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass)
|
||||
{
|
||||
if (!RendererClass) return 0;
|
||||
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
|
||||
if (!Handle) return 0;
|
||||
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
|
||||
UNiagaraEmitter* Emitter = Versioned.Emitter;
|
||||
FVersionedNiagaraEmitterData* Data = Versioned.GetEmitterData();
|
||||
if (!Emitter || !Data) return 0;
|
||||
|
||||
Emitter->Modify();
|
||||
TArray<UNiagaraRendererProperties*> ToRemove;
|
||||
for (UNiagaraRendererProperties* R : Data->GetRenderers())
|
||||
{
|
||||
if (R && R->IsA(RendererClass)) ToRemove.Add(R);
|
||||
}
|
||||
for (UNiagaraRendererProperties* R : ToRemove)
|
||||
{
|
||||
Emitter->RemoveRenderer(R, Versioned.Version);
|
||||
}
|
||||
return ToRemove.Num();
|
||||
}
|
||||
|
||||
bool UMCPNiagaraLibrary::RequestCompile(UNiagaraSystem* System)
|
||||
{
|
||||
if (!System) return false;
|
||||
return System->RequestCompile(true);
|
||||
}
|
||||
948
Source/UEBlueprintMCPEditor/Private/MCPVoxelGraphLibrary.cpp
Normal file
948
Source/UEBlueprintMCPEditor/Private/MCPVoxelGraphLibrary.cpp
Normal file
@ -0,0 +1,948 @@
|
||||
#include "MCPVoxelGraphLibrary.h"
|
||||
|
||||
#include "MCPGraphLibrary.h"
|
||||
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "EdGraph/EdGraphNode.h"
|
||||
#include "EdGraph/EdGraphPin.h"
|
||||
#include "EdGraphNode_Comment.h"
|
||||
#include "Subsystems/AssetEditorSubsystem.h"
|
||||
#include "Editor.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
|
||||
// WITH_MCP_VOXEL is set by UEBlueprintMCPEditor.Build.cs: 1 when the Voxel plugin
|
||||
// is present (auto-detected, or forced via env UE_MCP_WITH_VOXEL), 0 otherwise.
|
||||
// When 0 the whole Voxel integration compiles down to harmless stubs so the
|
||||
// plugin builds in any project — with or without Voxel installed.
|
||||
#ifndef WITH_MCP_VOXEL
|
||||
#define WITH_MCP_VOXEL 0
|
||||
#endif
|
||||
|
||||
#if WITH_MCP_VOXEL
|
||||
|
||||
#include "VoxelGraph.h"
|
||||
#include "VoxelGraphToolkit.h"
|
||||
#include "VoxelGraphTracker.h"
|
||||
#include "VoxelTerminalGraph.h"
|
||||
#include "VoxelTerminalGraphRuntime.h"
|
||||
|
||||
#include "VoxelMinimal.h"
|
||||
#include "VoxelNode.h"
|
||||
#include "VoxelFunctionLibrary.h"
|
||||
#include "Nodes/VoxelNode_UFunction.h"
|
||||
#include "VoxelMinimal/VoxelInstancedStruct.h"
|
||||
#include "VoxelMinimal/VoxelObjectHelpers.h"
|
||||
#include "VoxelParameter.h"
|
||||
#include "VoxelPinType.h"
|
||||
|
||||
// Private headers of the VoxelGraphEditor module (reachable via PrivateIncludePaths in Build.cs).
|
||||
// We only use inline/virtual members from these, so no cross-module link symbols are required.
|
||||
#include "Nodes/VoxelGraphNode_Struct.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
static FString VoxelJsonStringify(const TSharedRef<FJsonObject>& Object)
|
||||
{
|
||||
FString Out;
|
||||
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out);
|
||||
FJsonSerializer::Serialize(Object, Writer);
|
||||
return Out;
|
||||
}
|
||||
|
||||
static UVoxelGraph* AsVoxelGraph(UObject* Asset)
|
||||
{
|
||||
return Cast<UVoxelGraph>(Asset);
|
||||
}
|
||||
|
||||
static void FillTerminalJson(TSharedRef<FJsonObject> Json, const UVoxelTerminalGraph& TerminalGraph)
|
||||
{
|
||||
Json->SetStringField(TEXT("guid"), TerminalGraph.GetGuid().ToString());
|
||||
Json->SetStringField(TEXT("name"), TerminalGraph.GetDisplayName());
|
||||
Json->SetBoolField(TEXT("is_main"), TerminalGraph.IsMainTerminalGraph());
|
||||
Json->SetBoolField(TEXT("is_editor"), TerminalGraph.IsEditorTerminalGraph());
|
||||
|
||||
const UEdGraph& EdGraph = TerminalGraph.GetEdGraph();
|
||||
Json->SetStringField(TEXT("ed_graph"), EdGraph.GetName());
|
||||
Json->SetNumberField(TEXT("node_count"), EdGraph.Nodes.Num());
|
||||
}
|
||||
|
||||
static UVoxelTerminalGraph* FindTerminalGraph(UVoxelGraph* Graph, const FString& Terminal)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (Terminal.IsEmpty() ||
|
||||
Terminal.Equals(TEXT("main"), ESearchCase::IgnoreCase) ||
|
||||
Terminal.Equals(TEXT("Main"), ESearchCase::IgnoreCase))
|
||||
{
|
||||
return &Graph->GetMainTerminalGraph();
|
||||
}
|
||||
|
||||
if (Terminal.Equals(TEXT("editor"), ESearchCase::IgnoreCase))
|
||||
{
|
||||
return &Graph->GetEditorTerminalGraph();
|
||||
}
|
||||
|
||||
FGuid Guid;
|
||||
if (FGuid::Parse(Terminal, Guid))
|
||||
{
|
||||
return Graph->FindTerminalGraph(Guid);
|
||||
}
|
||||
|
||||
UVoxelTerminalGraph* Result = nullptr;
|
||||
Graph->ForeachTerminalGraph_NoInheritance([&](UVoxelTerminalGraph& Candidate)
|
||||
{
|
||||
if (!Result &&
|
||||
(Candidate.GetDisplayName().Equals(Terminal, ESearchCase::IgnoreCase) ||
|
||||
Candidate.GetEdGraph().GetName().Equals(Terminal, ESearchCase::IgnoreCase)))
|
||||
{
|
||||
Result = &Candidate;
|
||||
}
|
||||
});
|
||||
return Result;
|
||||
}
|
||||
|
||||
static TSharedRef<FJsonObject> VoxelNodeToJson(UEdGraphNode* Node)
|
||||
{
|
||||
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
||||
Json->SetStringField(TEXT("guid"), Node->NodeGuid.ToString());
|
||||
Json->SetStringField(TEXT("class"), Node->GetClass()->GetName());
|
||||
Json->SetNumberField(TEXT("x"), Node->NodePosX);
|
||||
Json->SetNumberField(TEXT("y"), Node->NodePosY);
|
||||
Json->SetStringField(TEXT("title"), Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
|
||||
if (!Node->NodeComment.IsEmpty())
|
||||
{
|
||||
Json->SetStringField(TEXT("comment"), Node->NodeComment);
|
||||
}
|
||||
if (const UEdGraphNode_Comment* CommentNode = Cast<UEdGraphNode_Comment>(Node))
|
||||
{
|
||||
Json->SetNumberField(TEXT("w"), CommentNode->NodeWidth);
|
||||
Json->SetNumberField(TEXT("h"), CommentNode->NodeHeight);
|
||||
}
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Pins;
|
||||
for (UEdGraphPin* Pin : Node->Pins)
|
||||
{
|
||||
if (!Pin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> PinJson = MakeShared<FJsonObject>();
|
||||
PinJson->SetStringField(TEXT("name"), Pin->PinName.ToString());
|
||||
PinJson->SetStringField(TEXT("dir"), Pin->Direction == EGPD_Input ? TEXT("in") : TEXT("out"));
|
||||
PinJson->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString());
|
||||
if (!Pin->PinType.PinSubCategory.IsNone())
|
||||
{
|
||||
PinJson->SetStringField(TEXT("subtype"), Pin->PinType.PinSubCategory.ToString());
|
||||
}
|
||||
if (Pin->PinType.PinSubCategoryObject.IsValid())
|
||||
{
|
||||
PinJson->SetStringField(TEXT("subobj"), Pin->PinType.PinSubCategoryObject->GetPathName());
|
||||
}
|
||||
if (!Pin->DefaultValue.IsEmpty())
|
||||
{
|
||||
PinJson->SetStringField(TEXT("default"), Pin->DefaultValue);
|
||||
}
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Links;
|
||||
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
|
||||
{
|
||||
if (!LinkedPin || !LinkedPin->GetOwningNode())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> LinkJson = MakeShared<FJsonObject>();
|
||||
LinkJson->SetStringField(TEXT("node"), LinkedPin->GetOwningNode()->NodeGuid.ToString());
|
||||
LinkJson->SetStringField(TEXT("pin"), LinkedPin->PinName.ToString());
|
||||
Links.Add(MakeShared<FJsonValueObject>(LinkJson));
|
||||
}
|
||||
if (Links.Num() > 0)
|
||||
{
|
||||
PinJson->SetArrayField(TEXT("links"), Links);
|
||||
}
|
||||
Pins.Add(MakeShared<FJsonValueObject>(PinJson));
|
||||
}
|
||||
Json->SetArrayField(TEXT("pins"), Pins);
|
||||
return Json;
|
||||
}
|
||||
|
||||
// Lazily-built catalogue of every spawnable Voxel node. Mirrors FVoxelNodeLibrary's
|
||||
// construction (which lives in VoxelGraphEditor/Private and is not exported), so we
|
||||
// rebuild it here from the exported reflection helpers.
|
||||
static const TArray<TSharedRef<const FVoxelNode>>& GetVoxelNodeCatalogue()
|
||||
{
|
||||
static TArray<TSharedRef<const FVoxelNode>> Nodes;
|
||||
static bool bBuilt = false;
|
||||
if (bBuilt)
|
||||
{
|
||||
return Nodes;
|
||||
}
|
||||
bBuilt = true;
|
||||
|
||||
// Struct nodes (FVoxelNode subclasses): Add, Multiply, noise, output nodes, etc.
|
||||
for (UScriptStruct* Struct : GetDerivedStructs<FVoxelNode>())
|
||||
{
|
||||
if (!Struct)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (Struct->HasMetaData(TEXT("Abstract")) || Struct->HasMetaData(TEXT("Internal")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Nodes.Add(MakeSharedStruct<FVoxelNode>(Struct));
|
||||
}
|
||||
|
||||
// Function-library nodes (UVoxelFunctionLibrary UFUNCTIONs wrapped as FVoxelNode_UFunction):
|
||||
// most math / position / curve nodes live here.
|
||||
for (const TSubclassOf<UVoxelFunctionLibrary>& Class : GetDerivedClasses<UVoxelFunctionLibrary>())
|
||||
{
|
||||
if (!Class)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (UFunction* Function : GetClassFunctions(Class))
|
||||
{
|
||||
if (!Function || Function->HasMetaData(TEXT("Internal")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Nodes.Add(FVoxelNode_UFunction::Make(Function));
|
||||
}
|
||||
}
|
||||
|
||||
return Nodes;
|
||||
}
|
||||
|
||||
// The UFunction backing a function-library node, or nullptr for plain struct nodes.
|
||||
static UFunction* GetNodeFunction(const FVoxelNode& Node)
|
||||
{
|
||||
if (Node.GetStruct() &&
|
||||
Node.GetStruct()->IsChildOf(FVoxelNode_UFunction::StaticStruct()))
|
||||
{
|
||||
return static_cast<const FVoxelNode_UFunction&>(Node).GetFunction();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Primary, stable identifier used to spawn a node.
|
||||
static FString GetNodeId(const FVoxelNode& Node)
|
||||
{
|
||||
if (const UFunction* Function = GetNodeFunction(Node))
|
||||
{
|
||||
const UClass* Owner = Function->GetOwnerClass();
|
||||
return FString::Printf(TEXT("%s.%s"),
|
||||
Owner ? *Owner->GetName() : TEXT("?"),
|
||||
*Function->GetName());
|
||||
}
|
||||
return Node.GetStruct() ? Node.GetStruct()->GetName() : FString();
|
||||
}
|
||||
|
||||
// Returns true if NodeId matches the node by any of its accepted aliases.
|
||||
static bool NodeMatchesId(const FVoxelNode& Node, const FString& NodeId)
|
||||
{
|
||||
if (GetNodeId(Node).Equals(NodeId, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Node.GetDisplayName().Equals(NodeId, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (const UFunction* Function = GetNodeFunction(Node))
|
||||
{
|
||||
if (Function->GetName().Equals(NodeId, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const UClass* Owner = Function->GetOwnerClass();
|
||||
if (Owner &&
|
||||
FString::Printf(TEXT("%s::%s"), *Owner->GetName(), *Function->GetName()).Equals(NodeId, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (const UScriptStruct* Struct = Node.GetStruct())
|
||||
{
|
||||
if (Struct->GetPathName().Equals(NodeId, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static TSharedPtr<const FVoxelNode> FindCatalogueNode(const FString& NodeId)
|
||||
{
|
||||
for (const TSharedRef<const FVoxelNode>& Node : GetVoxelNodeCatalogue())
|
||||
{
|
||||
if (NodeMatchesId(*Node, NodeId))
|
||||
{
|
||||
return Node;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Map a friendly string to a Voxel pin type. Defaults to float for unknown input.
|
||||
static FVoxelPinType PinTypeFromString(const FString& TypeStr)
|
||||
{
|
||||
const FString T = TypeStr.ToLower();
|
||||
if (T == TEXT("double")) return FVoxelPinType::Make<double>();
|
||||
if (T == TEXT("int") || T == TEXT("int32")) return FVoxelPinType::Make<int32>();
|
||||
if (T == TEXT("bool")) return FVoxelPinType::Make<bool>();
|
||||
if (T == TEXT("vector2d") || T == TEXT("vector2"))return FVoxelPinType::Make<FVector2D>();
|
||||
if (T == TEXT("vector") || T == TEXT("vector3d")) return FVoxelPinType::Make<FVector>();
|
||||
if (T == TEXT("color") || T == TEXT("linearcolor"))return FVoxelPinType::Make<FLinearColor>();
|
||||
if (T == TEXT("name")) return FVoxelPinType::Make<FName>();
|
||||
return FVoxelPinType::Make<float>();
|
||||
}
|
||||
|
||||
// Reflection-based spawn of a (private, non-exported) Voxel graph node class by its script path,
|
||||
// setting its FGuid "Guid" UPROPERTY. Only inline/virtual calls -> no cross-module link symbols.
|
||||
static UEdGraphNode* SpawnGuidNodeReflect(UEdGraph* Graph, const TCHAR* ClassPath, const FGuid& Guid, FVector2D Position)
|
||||
{
|
||||
UClass* NodeClass = FindObject<UClass>(nullptr, ClassPath);
|
||||
if (!Graph || !NodeClass)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Graph->Modify();
|
||||
UEdGraphNode* Node = NewObject<UEdGraphNode>(Graph, NodeClass, NAME_None, RF_Transactional);
|
||||
if (!Node)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (FStructProperty* GuidProp = FindFProperty<FStructProperty>(NodeClass, TEXT("Guid")))
|
||||
{
|
||||
*GuidProp->ContainerPtrToValuePtr<FGuid>(Node) = Guid;
|
||||
}
|
||||
|
||||
Node->NodePosX = static_cast<int32>(Position.X);
|
||||
Node->NodePosY = static_cast<int32>(Position.Y);
|
||||
Node->CreateNewGuid();
|
||||
Graph->AddNode(Node, true, false);
|
||||
Node->PostPlacedNewNode();
|
||||
if (Node->Pins.Num() == 0)
|
||||
{
|
||||
Node->AllocateDefaultPins();
|
||||
}
|
||||
return Node;
|
||||
}
|
||||
|
||||
static UVoxelTerminalGraph* OwningTerminal(UEdGraph* Graph)
|
||||
{
|
||||
return Graph ? Graph->GetTypedOuter<UVoxelTerminalGraph>() : nullptr;
|
||||
}
|
||||
|
||||
static FGuid ResolveParameterGuid(UVoxelGraph* Graph, const FString& Ref)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return FGuid();
|
||||
}
|
||||
FGuid Guid;
|
||||
if (FGuid::Parse(Ref, Guid) && Graph->FindParameter(Guid))
|
||||
{
|
||||
return Guid;
|
||||
}
|
||||
for (const FGuid& Candidate : Graph->GetParameters())
|
||||
{
|
||||
if (Graph->FindParameterChecked(Candidate).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return Candidate;
|
||||
}
|
||||
}
|
||||
return FGuid();
|
||||
}
|
||||
|
||||
// Resolve a terminal-graph property (kind: input/output/local) by guid or name.
|
||||
static FGuid ResolveTerminalPropGuid(UVoxelTerminalGraph* Terminal, const FString& Kind, const FString& Ref)
|
||||
{
|
||||
if (!Terminal)
|
||||
{
|
||||
return FGuid();
|
||||
}
|
||||
FGuid Guid;
|
||||
const bool bIsGuid = FGuid::Parse(Ref, Guid);
|
||||
|
||||
if (Kind == TEXT("input"))
|
||||
{
|
||||
if (bIsGuid && Terminal->FindInput(Guid)) return Guid;
|
||||
for (const FGuid& G : Terminal->GetFunctionInputs())
|
||||
if (Terminal->FindInputChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
|
||||
}
|
||||
else if (Kind == TEXT("output"))
|
||||
{
|
||||
if (bIsGuid && Terminal->FindOutput(Guid)) return Guid;
|
||||
for (const FGuid& G : Terminal->GetFunctionOutputs())
|
||||
if (Terminal->FindOutputChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
|
||||
}
|
||||
else if (Kind == TEXT("local"))
|
||||
{
|
||||
if (bIsGuid && Terminal->FindLocalVariable(Guid)) return Guid;
|
||||
for (const FGuid& G : Terminal->GetLocalVariables())
|
||||
if (Terminal->FindLocalVariableChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
|
||||
}
|
||||
return FGuid();
|
||||
}
|
||||
|
||||
static TSharedRef<FJsonObject> JsonError(const FString& Message)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), Message);
|
||||
return Root;
|
||||
}
|
||||
}
|
||||
|
||||
bool UMCPVoxelGraphLibrary::IsVoxelGraphAsset(UObject* Asset)
|
||||
{
|
||||
return AsVoxelGraph(Asset) != nullptr;
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::ListTerminalGraphsJson(UObject* Asset)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
UVoxelGraph* Graph = AsVoxelGraph(Asset);
|
||||
if (!Graph)
|
||||
{
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), TEXT("asset is not a UVoxelGraph"));
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("asset"), Graph->GetPathName());
|
||||
Root->SetStringField(TEXT("class"), Graph->GetClass()->GetPathName());
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Terminals;
|
||||
Graph->ForeachTerminalGraph_NoInheritance([&](UVoxelTerminalGraph& TerminalGraph)
|
||||
{
|
||||
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
|
||||
FillTerminalJson(Item, TerminalGraph);
|
||||
Terminals.Add(MakeShared<FJsonValueObject>(Item));
|
||||
});
|
||||
Root->SetArrayField(TEXT("terminals"), Terminals);
|
||||
Root->SetNumberField(TEXT("terminal_count"), Terminals.Num());
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
UEdGraph* UMCPVoxelGraphLibrary::FindTerminalEdGraph(UObject* Asset, const FString& Terminal)
|
||||
{
|
||||
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(AsVoxelGraph(Asset), Terminal);
|
||||
return TerminalGraph ? &TerminalGraph->GetEdGraph() : nullptr;
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::DumpTerminalGraphJson(UObject* Asset, const FString& Terminal)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
UVoxelGraph* Graph = AsVoxelGraph(Asset);
|
||||
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
|
||||
if (!Graph || !TerminalGraph)
|
||||
{
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), Graph ? TEXT("terminal graph not found") : TEXT("asset is not a UVoxelGraph"));
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("asset"), Graph->GetPathName());
|
||||
|
||||
TSharedRef<FJsonObject> TerminalJson = MakeShared<FJsonObject>();
|
||||
FillTerminalJson(TerminalJson, *TerminalGraph);
|
||||
Root->SetObjectField(TEXT("terminal"), TerminalJson);
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Nodes;
|
||||
UEdGraph& EdGraph = TerminalGraph->GetEdGraph();
|
||||
for (UEdGraphNode* Node : EdGraph.Nodes)
|
||||
{
|
||||
if (Node)
|
||||
{
|
||||
Nodes.Add(MakeShared<FJsonValueObject>(VoxelNodeToJson(Node)));
|
||||
}
|
||||
}
|
||||
Root->SetArrayField(TEXT("nodes"), Nodes);
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::GetSelectedNodesJson(UObject* Asset, const FString& Terminal)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
UEdGraph* EdGraph = FindTerminalEdGraph(Asset, Terminal);
|
||||
if (!EdGraph)
|
||||
{
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), TEXT("terminal graph not found"));
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
const TSharedPtr<FVoxelGraphToolkit> Toolkit = FVoxelGraphToolkit::Get(EdGraph);
|
||||
if (!Toolkit.IsValid())
|
||||
{
|
||||
Root->SetBoolField(TEXT("ok"), false);
|
||||
Root->SetStringField(TEXT("error"), TEXT("voxel graph editor is not open"));
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("graph"), EdGraph->GetName());
|
||||
TArray<TSharedPtr<FJsonValue>> Nodes;
|
||||
for (UEdGraphNode* Node : Toolkit->GetSelectedNodes())
|
||||
{
|
||||
if (Node)
|
||||
{
|
||||
Nodes.Add(MakeShared<FJsonValueObject>(VoxelNodeToJson(Node)));
|
||||
}
|
||||
}
|
||||
Root->SetArrayField(TEXT("selected"), Nodes);
|
||||
Root->SetNumberField(TEXT("selected_count"), Nodes.Num());
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::SpawnCommentNode(UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment, FLinearColor Color)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
Graph->Modify();
|
||||
UEdGraphNode_Comment* Node = NewObject<UEdGraphNode_Comment>(Graph);
|
||||
Node->CreateNewGuid();
|
||||
Node->NodePosX = static_cast<int32>(Position.X);
|
||||
Node->NodePosY = static_cast<int32>(Position.Y);
|
||||
Node->NodeWidth = FMath::Max(64, static_cast<int32>(Size.X));
|
||||
Node->NodeHeight = FMath::Max(64, static_cast<int32>(Size.Y));
|
||||
Node->NodeComment = Comment;
|
||||
Node->CommentColor = Color;
|
||||
Node->bCommentBubbleVisible = false;
|
||||
Graph->AddNode(Node, true, false);
|
||||
Node->PostPlacedNewNode();
|
||||
NotifyGraphChanged(Graph, false);
|
||||
return Node->NodeGuid.ToString();
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::ListNodeTypesJson(const FString& Filter, const int32 Limit)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
|
||||
const FString FilterLower = Filter.ToLower();
|
||||
const int32 EffectiveLimit = Limit > 0 ? Limit : MAX_int32;
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Items;
|
||||
int32 Total = 0;
|
||||
for (const TSharedRef<const FVoxelNode>& Node : GetVoxelNodeCatalogue())
|
||||
{
|
||||
const FString Id = GetNodeId(*Node);
|
||||
const FString DisplayName = Node->GetDisplayName();
|
||||
const FString Category = Node->GetCategory();
|
||||
|
||||
if (!FilterLower.IsEmpty())
|
||||
{
|
||||
const bool bMatches =
|
||||
Id.ToLower().Contains(FilterLower) ||
|
||||
DisplayName.ToLower().Contains(FilterLower) ||
|
||||
Category.ToLower().Contains(FilterLower);
|
||||
if (!bMatches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
++Total;
|
||||
if (Items.Num() >= EffectiveLimit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
|
||||
Item->SetStringField(TEXT("id"), Id);
|
||||
Item->SetStringField(TEXT("display_name"), DisplayName);
|
||||
Item->SetStringField(TEXT("category"), Category);
|
||||
Item->SetBoolField(TEXT("is_pure"), Node->IsPureNode());
|
||||
Item->SetStringField(TEXT("kind"), GetNodeFunction(*Node) ? TEXT("function") : TEXT("struct"));
|
||||
Items.Add(MakeShared<FJsonValueObject>(Item));
|
||||
}
|
||||
|
||||
Root->SetArrayField(TEXT("nodes"), Items);
|
||||
Root->SetNumberField(TEXT("returned"), Items.Num());
|
||||
Root->SetNumberField(TEXT("total"), Total);
|
||||
Root->SetBoolField(TEXT("truncated"), Total > Items.Num());
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::SpawnStructNode(UEdGraph* Graph, const FString& NodeId, FVector2D Position)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
const TSharedPtr<const FVoxelNode> Chosen = FindCatalogueNode(NodeId);
|
||||
if (!Chosen)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
UClass* NodeClass = FindObject<UClass>(nullptr, TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_Struct"));
|
||||
if (!NodeClass)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
Graph->Modify();
|
||||
|
||||
// NewObject with the reflected class avoids referencing the (non-exported) StaticClass()
|
||||
// symbol of UVoxelGraphNode_Struct; the static_cast is purely compile-time.
|
||||
UVoxelGraphNode_Struct* Node = static_cast<UVoxelGraphNode_Struct*>(
|
||||
NewObject<UEdGraphNode>(Graph, NodeClass, NAME_None, RF_Transactional));
|
||||
if (!Node)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
// Mirrors FVoxelGraphSchemaAction_NewStructNode::PerformAction: copy the catalogue
|
||||
// node into the graph node's instanced Struct, then let it build its pins.
|
||||
Node->Struct = FVoxelInstancedStruct::Make(*Chosen);
|
||||
Node->NodePosX = static_cast<int32>(Position.X);
|
||||
Node->NodePosY = static_cast<int32>(Position.Y);
|
||||
Node->CreateNewGuid();
|
||||
|
||||
Graph->AddNode(Node, true, false);
|
||||
|
||||
Node->PostPlacedNewNode();
|
||||
if (Node->Pins.Num() == 0)
|
||||
{
|
||||
Node->AllocateDefaultPins();
|
||||
}
|
||||
|
||||
return Node->NodeGuid.ToString();
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::AddGraphPropertyJson(UObject* Asset, const FString& Terminal, const FString& Kind, const FString& Name, const FString& TypeStr, const FString& Category)
|
||||
{
|
||||
UVoxelGraph* Graph = AsVoxelGraph(Asset);
|
||||
if (!Graph)
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(TEXT("asset is not a UVoxelGraph")));
|
||||
}
|
||||
|
||||
const FString KindLower = Kind.ToLower();
|
||||
const FVoxelPinType Type = PinTypeFromString(TypeStr);
|
||||
const FName PropName = Name.IsEmpty() ? FName(TEXT("NewProperty")) : FName(*Name);
|
||||
const FGuid Guid = FGuid::NewGuid();
|
||||
|
||||
if (KindLower == TEXT("parameter"))
|
||||
{
|
||||
FVoxelParameter Parameter;
|
||||
Parameter.Name = PropName;
|
||||
Parameter.Type = Type;
|
||||
#if WITH_EDITORONLY_DATA
|
||||
Parameter.Category = Category;
|
||||
#endif
|
||||
Parameter.Fixup();
|
||||
Graph->AddParameter(Guid, Parameter);
|
||||
Graph->Fixup();
|
||||
}
|
||||
else
|
||||
{
|
||||
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
|
||||
if (!TerminalGraph)
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(TEXT("terminal graph not found")));
|
||||
}
|
||||
|
||||
if (KindLower == TEXT("input"))
|
||||
{
|
||||
FVoxelGraphFunctionInput Input;
|
||||
Input.Name = PropName;
|
||||
Input.Type = Type;
|
||||
#if WITH_EDITORONLY_DATA
|
||||
Input.Category = Category;
|
||||
#endif
|
||||
Input.Fixup();
|
||||
TerminalGraph->AddFunctionInput(Guid, Input);
|
||||
}
|
||||
else if (KindLower == TEXT("output"))
|
||||
{
|
||||
FVoxelGraphFunctionOutput Output;
|
||||
Output.Name = PropName;
|
||||
Output.Type = Type;
|
||||
#if WITH_EDITORONLY_DATA
|
||||
Output.Category = Category;
|
||||
#endif
|
||||
TerminalGraph->AddFunctionOutput(Guid, Output);
|
||||
}
|
||||
else if (KindLower == TEXT("local"))
|
||||
{
|
||||
FVoxelGraphLocalVariable LocalVariable;
|
||||
LocalVariable.Name = PropName;
|
||||
LocalVariable.Type = Type;
|
||||
#if WITH_EDITORONLY_DATA
|
||||
LocalVariable.Category = Category;
|
||||
#endif
|
||||
TerminalGraph->AddLocalVariable(Guid, LocalVariable);
|
||||
}
|
||||
else
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(FString::Printf(TEXT("unknown kind %s (use parameter|input|output|local)"), *Kind)));
|
||||
}
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("guid"), Guid.ToString());
|
||||
Root->SetStringField(TEXT("name"), PropName.ToString());
|
||||
Root->SetStringField(TEXT("type"), Type.ToString());
|
||||
Root->SetStringField(TEXT("kind"), KindLower);
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::ListGraphPropertiesJson(UObject* Asset, const FString& Terminal, const FString& Kind)
|
||||
{
|
||||
UVoxelGraph* Graph = AsVoxelGraph(Asset);
|
||||
if (!Graph)
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(TEXT("asset is not a UVoxelGraph")));
|
||||
}
|
||||
|
||||
const FString KindLower = Kind.ToLower();
|
||||
TArray<TSharedPtr<FJsonValue>> Items;
|
||||
|
||||
const auto AddItem = [&Items](const FGuid& Guid, const FName& PropName, const FVoxelPinType& Type, const FString& Category)
|
||||
{
|
||||
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
|
||||
Item->SetStringField(TEXT("guid"), Guid.ToString());
|
||||
Item->SetStringField(TEXT("name"), PropName.ToString());
|
||||
Item->SetStringField(TEXT("type"), Type.ToString());
|
||||
Item->SetStringField(TEXT("category"), Category);
|
||||
Items.Add(MakeShared<FJsonValueObject>(Item));
|
||||
};
|
||||
|
||||
if (KindLower == TEXT("parameter"))
|
||||
{
|
||||
for (const FGuid& G : Graph->GetParameters())
|
||||
{
|
||||
const FVoxelParameter& P = Graph->FindParameterChecked(G);
|
||||
#if WITH_EDITORONLY_DATA
|
||||
AddItem(G, P.Name, P.Type, P.Category);
|
||||
#else
|
||||
AddItem(G, P.Name, P.Type, FString());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
|
||||
if (!TerminalGraph)
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(TEXT("terminal graph not found")));
|
||||
}
|
||||
|
||||
if (KindLower == TEXT("input"))
|
||||
{
|
||||
for (const FGuid& G : TerminalGraph->GetFunctionInputs())
|
||||
{
|
||||
const FVoxelGraphFunctionInput& P = TerminalGraph->FindInputChecked(G);
|
||||
#if WITH_EDITORONLY_DATA
|
||||
AddItem(G, P.Name, P.Type, P.Category);
|
||||
#else
|
||||
AddItem(G, P.Name, P.Type, FString());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
else if (KindLower == TEXT("output"))
|
||||
{
|
||||
for (const FGuid& G : TerminalGraph->GetFunctionOutputs())
|
||||
{
|
||||
const FVoxelGraphFunctionOutput& P = TerminalGraph->FindOutputChecked(G);
|
||||
#if WITH_EDITORONLY_DATA
|
||||
AddItem(G, P.Name, P.Type, P.Category);
|
||||
#else
|
||||
AddItem(G, P.Name, P.Type, FString());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
else if (KindLower == TEXT("local"))
|
||||
{
|
||||
for (const FGuid& G : TerminalGraph->GetLocalVariables())
|
||||
{
|
||||
const FVoxelGraphLocalVariable& P = TerminalGraph->FindLocalVariableChecked(G);
|
||||
#if WITH_EDITORONLY_DATA
|
||||
AddItem(G, P.Name, P.Type, P.Category);
|
||||
#else
|
||||
AddItem(G, P.Name, P.Type, FString());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return VoxelJsonStringify(JsonError(FString::Printf(TEXT("unknown kind %s (use parameter|input|output|local)"), *Kind)));
|
||||
}
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("ok"), true);
|
||||
Root->SetStringField(TEXT("kind"), KindLower);
|
||||
Root->SetArrayField(TEXT("properties"), Items);
|
||||
Root->SetNumberField(TEXT("count"), Items.Num());
|
||||
return VoxelJsonStringify(Root);
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::SpawnPropertyNode(UEdGraph* Graph, const FString& Kind, const FString& Ref, FVector2D Position, bool bDeclaration)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
const FString KindLower = Kind.ToLower();
|
||||
UVoxelTerminalGraph* Terminal = OwningTerminal(Graph);
|
||||
|
||||
FGuid Guid;
|
||||
const TCHAR* ClassPath = nullptr;
|
||||
|
||||
if (KindLower == TEXT("parameter"))
|
||||
{
|
||||
UVoxelGraph* OwnerGraph = Terminal ? &Terminal->GetGraph() : nullptr;
|
||||
Guid = ResolveParameterGuid(OwnerGraph, Ref);
|
||||
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_Parameter");
|
||||
}
|
||||
else if (KindLower == TEXT("input"))
|
||||
{
|
||||
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
|
||||
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_FunctionInput");
|
||||
}
|
||||
else if (KindLower == TEXT("output"))
|
||||
{
|
||||
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
|
||||
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_FunctionOutput");
|
||||
}
|
||||
else if (KindLower == TEXT("local"))
|
||||
{
|
||||
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
|
||||
ClassPath = bDeclaration
|
||||
? TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_LocalVariableDeclaration")
|
||||
: TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_LocalVariableUsage");
|
||||
}
|
||||
else
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
if (!Guid.IsValid())
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
UEdGraphNode* Node = SpawnGuidNodeReflect(Graph, ClassPath, Guid, Position);
|
||||
return Node ? Node->NodeGuid.ToString() : FString();
|
||||
}
|
||||
|
||||
FString UMCPVoxelGraphLibrary::SpawnCallFunctionNode(UEdGraph* Graph, const FString& FunctionRef, FVector2D Position)
|
||||
{
|
||||
UVoxelTerminalGraph* Terminal = OwningTerminal(Graph);
|
||||
UVoxelGraph* OwnerGraph = Terminal ? &Terminal->GetGraph() : nullptr;
|
||||
if (!OwnerGraph)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
UVoxelTerminalGraph* Function = FindTerminalGraph(OwnerGraph, FunctionRef);
|
||||
if (!Function || Function->IsMainTerminalGraph() || Function->IsEditorTerminalGraph())
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
UEdGraphNode* Node = SpawnGuidNodeReflect(
|
||||
Graph,
|
||||
TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_CallMemberFunction"),
|
||||
Function->GetGuid(),
|
||||
Position);
|
||||
return Node ? Node->NodeGuid.ToString() : FString();
|
||||
}
|
||||
|
||||
bool UMCPVoxelGraphLibrary::NotifyGraphChanged(UEdGraph* Graph, const bool bCompile)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UVoxelTerminalGraph* TerminalGraph = Graph->GetTypedOuter<UVoxelTerminalGraph>();
|
||||
if (!TerminalGraph)
|
||||
{
|
||||
Graph->MarkPackageDirty();
|
||||
return false;
|
||||
}
|
||||
|
||||
TerminalGraph->Modify();
|
||||
TerminalGraph->GetGraph().Modify();
|
||||
Graph->Modify();
|
||||
Graph->MarkPackageDirty();
|
||||
TerminalGraph->GetGraph().MarkPackageDirty();
|
||||
|
||||
if (GVoxelGraphTracker)
|
||||
{
|
||||
GVoxelGraphTracker->NotifyEdGraphChanged(*Graph);
|
||||
GVoxelGraphTracker->NotifyTerminalGraphChanged(TerminalGraph->GetGraph());
|
||||
}
|
||||
|
||||
if (bCompile)
|
||||
{
|
||||
TerminalGraph->GetRuntime().EnsureIsCompiled(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UMCPVoxelGraphLibrary::OpenVoxelGraphAsset(UObject* Asset)
|
||||
{
|
||||
if (!Asset || !GEditor)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();
|
||||
return AssetEditorSubsystem && AssetEditorSubsystem->OpenEditorForAsset(Asset);
|
||||
}
|
||||
|
||||
#else // !WITH_MCP_VOXEL
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voxel plugin not present in this project. Provide stub implementations so the
|
||||
// module always links. voxel_graph_op will report this error to the caller.
|
||||
// Rebuild with the Voxel plugin installed (or env UE_MCP_WITH_VOXEL=1) to enable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const TCHAR* MCPVoxelDisabledJson()
|
||||
{
|
||||
return TEXT("{\"ok\":false,\"error\":\"Voxel plugin not enabled in this build. Install the Voxel plugin and rebuild (UE_MCP_WITH_VOXEL=1) to use voxel_graph_op.\"}");
|
||||
}
|
||||
|
||||
bool UMCPVoxelGraphLibrary::IsVoxelGraphAsset(UObject*) { return false; }
|
||||
FString UMCPVoxelGraphLibrary::ListTerminalGraphsJson(UObject*) { return MCPVoxelDisabledJson(); }
|
||||
UEdGraph* UMCPVoxelGraphLibrary::FindTerminalEdGraph(UObject*, const FString&) { return nullptr; }
|
||||
FString UMCPVoxelGraphLibrary::DumpTerminalGraphJson(UObject*, const FString&) { return MCPVoxelDisabledJson(); }
|
||||
FString UMCPVoxelGraphLibrary::GetSelectedNodesJson(UObject*, const FString&) { return MCPVoxelDisabledJson(); }
|
||||
FString UMCPVoxelGraphLibrary::SpawnCommentNode(UEdGraph*, FVector2D, FVector2D, const FString&, FLinearColor) { return FString(); }
|
||||
FString UMCPVoxelGraphLibrary::ListNodeTypesJson(const FString&, int32) { return MCPVoxelDisabledJson(); }
|
||||
FString UMCPVoxelGraphLibrary::SpawnStructNode(UEdGraph*, const FString&, FVector2D) { return FString(); }
|
||||
FString UMCPVoxelGraphLibrary::AddGraphPropertyJson(UObject*, const FString&, const FString&, const FString&, const FString&, const FString&) { return MCPVoxelDisabledJson(); }
|
||||
FString UMCPVoxelGraphLibrary::ListGraphPropertiesJson(UObject*, const FString&, const FString&) { return MCPVoxelDisabledJson(); }
|
||||
FString UMCPVoxelGraphLibrary::SpawnPropertyNode(UEdGraph*, const FString&, const FString&, FVector2D, bool) { return FString(); }
|
||||
FString UMCPVoxelGraphLibrary::SpawnCallFunctionNode(UEdGraph*, const FString&, FVector2D) { return FString(); }
|
||||
bool UMCPVoxelGraphLibrary::NotifyGraphChanged(UEdGraph*, bool) { return false; }
|
||||
bool UMCPVoxelGraphLibrary::OpenVoxelGraphAsset(UObject*) { return false; }
|
||||
|
||||
#endif // WITH_MCP_VOXEL
|
||||
126
Source/UEBlueprintMCPEditor/Private/MCPWidgetRenderLibrary.cpp
Normal file
126
Source/UEBlueprintMCPEditor/Private/MCPWidgetRenderLibrary.cpp
Normal file
@ -0,0 +1,126 @@
|
||||
#include "MCPWidgetRenderLibrary.h"
|
||||
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "Blueprint/WidgetTree.h"
|
||||
#include "Editor.h"
|
||||
#include "Engine/TextureRenderTarget2D.h"
|
||||
#include "Engine/World.h"
|
||||
#include "HAL/PlatformFileManager.h"
|
||||
#include "IImageWrapper.h"
|
||||
#include "IImageWrapperModule.h"
|
||||
#include "ImageUtils.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
#include "RenderingThread.h"
|
||||
#include "Slate/WidgetRenderer.h"
|
||||
|
||||
bool UMCPWidgetRenderLibrary::RenderWidgetToPNG(
|
||||
TSubclassOf<UUserWidget> WidgetClass,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError)
|
||||
{
|
||||
OutFullPath.Reset();
|
||||
OutError.Reset();
|
||||
|
||||
if (!WidgetClass)
|
||||
{
|
||||
OutError = TEXT("WidgetClass is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
|
||||
if (!World)
|
||||
{
|
||||
OutError = TEXT("No editor world available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 W = FMath::Clamp(Width, 16, 8192);
|
||||
const int32 H = FMath::Clamp(Height, 16, 8192);
|
||||
|
||||
UUserWidget* Widget = CreateWidget<UUserWidget>(World, WidgetClass);
|
||||
if (!Widget)
|
||||
{
|
||||
OutError = TEXT("CreateWidget returned null");
|
||||
return false;
|
||||
}
|
||||
|
||||
// FWidgetRenderer wants a transient render target. Use a non-asset one.
|
||||
UTextureRenderTarget2D* RT = NewObject<UTextureRenderTarget2D>();
|
||||
RT->ClearColor = FLinearColor(0.f, 0.f, 0.f, 0.f);
|
||||
RT->TargetGamma = 1.f;
|
||||
RT->InitCustomFormat(W, H, PF_B8G8R8A8, /*bForceLinearGamma=*/false);
|
||||
RT->UpdateResourceImmediate(true);
|
||||
|
||||
// Use a software widget renderer so we don't need a live Slate window.
|
||||
FWidgetRenderer* Renderer = new FWidgetRenderer(/*bUseGammaCorrection=*/true);
|
||||
if (!Renderer)
|
||||
{
|
||||
OutError = TEXT("FWidgetRenderer alloc failed");
|
||||
return false;
|
||||
}
|
||||
Renderer->SetIsPrepassNeeded(true);
|
||||
|
||||
const FVector2D DrawSize(W, H);
|
||||
Renderer->DrawWidget(RT, Widget->TakeWidget(), DrawSize, 0.016f, /*bDeferRenderTargetUpdate=*/false);
|
||||
|
||||
// Wait for the GPU work to finish so we can read back pixels.
|
||||
FlushRenderingCommands();
|
||||
|
||||
// Read back pixels.
|
||||
FTextureRenderTargetResource* RTResource = RT->GameThread_GetRenderTargetResource();
|
||||
if (!RTResource)
|
||||
{
|
||||
BeginCleanup(Renderer);
|
||||
OutError = TEXT("RT resource null after render");
|
||||
return false;
|
||||
}
|
||||
|
||||
TArray<FColor> Pixels;
|
||||
Pixels.SetNumUninitialized(W * H);
|
||||
FReadSurfaceDataFlags ReadFlags(RCM_UNorm);
|
||||
ReadFlags.SetLinearToGamma(false);
|
||||
if (!RTResource->ReadPixels(Pixels, ReadFlags))
|
||||
{
|
||||
BeginCleanup(Renderer);
|
||||
OutError = TEXT("ReadPixels failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encode PNG via ImageWrapper.
|
||||
IImageWrapperModule& ImageWrapperModule =
|
||||
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
|
||||
TSharedPtr<IImageWrapper> PNG = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
|
||||
if (!PNG.IsValid() ||
|
||||
!PNG->SetRaw(Pixels.GetData(), Pixels.Num() * sizeof(FColor), W, H, ERGBFormat::BGRA, 8))
|
||||
{
|
||||
BeginCleanup(Renderer);
|
||||
OutError = TEXT("PNG encode failed");
|
||||
return false;
|
||||
}
|
||||
const TArray64<uint8>& Compressed = PNG->GetCompressed(100);
|
||||
|
||||
// Ensure directory.
|
||||
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
|
||||
if (!PF.DirectoryExists(*OutputAbsPath))
|
||||
{
|
||||
PF.CreateDirectoryTree(*OutputAbsPath);
|
||||
}
|
||||
|
||||
const FString FullPath = FPaths::Combine(OutputAbsPath, FileNameNoExt + TEXT(".png"));
|
||||
if (!FFileHelper::SaveArrayToFile(Compressed, *FullPath))
|
||||
{
|
||||
BeginCleanup(Renderer);
|
||||
OutError = FString::Printf(TEXT("SaveArrayToFile failed: %s"), *FullPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
BeginCleanup(Renderer);
|
||||
OutFullPath = FullPath;
|
||||
return true;
|
||||
}
|
||||
347
Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp
Normal file
347
Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp
Normal file
@ -0,0 +1,347 @@
|
||||
#include "UEBlueprintMCPEditor.h"
|
||||
|
||||
#include "HttpModule.h"
|
||||
#include "Interfaces/IHttpResponse.h"
|
||||
#include "Misc/CoreDelegates.h"
|
||||
#include "Framework/Notifications/NotificationManager.h"
|
||||
#include "Widgets/Notifications/SNotificationList.h"
|
||||
#include "BlueprintEditor.h"
|
||||
#include "BlueprintEditorContext.h"
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "EdGraph/EdGraphNode.h"
|
||||
#include "EdGraphNode_Comment.h"
|
||||
#include "Framework/Application/IInputProcessor.h"
|
||||
#include "Framework/Application/SlateApplication.h"
|
||||
#include "Framework/Commands/UIAction.h"
|
||||
#include "Kismet2/BlueprintEditorUtils.h"
|
||||
#include "ScopedTransaction.h"
|
||||
#include "SGraphPanel.h"
|
||||
#include "ToolMenu.h"
|
||||
#include "ToolMenuEntry.h"
|
||||
#include "ToolMenuSection.h"
|
||||
#include "ToolMenus.h"
|
||||
|
||||
#define LOCTEXT_NAMESPACE "UEBlueprintMCPEditor"
|
||||
|
||||
namespace
|
||||
{
|
||||
const TCHAR* MCPZoneCommentText = TEXT("MCP Zone");
|
||||
const TCHAR* LegacyMCPZoneCommentText = TEXT("MCP Draft Zone");
|
||||
|
||||
bool IsMCPZoneComment(const UEdGraphNode* Node)
|
||||
{
|
||||
const UEdGraphNode_Comment* CommentNode = Cast<UEdGraphNode_Comment>(Node);
|
||||
return CommentNode
|
||||
&& (CommentNode->NodeComment.Equals(MCPZoneCommentText, ESearchCase::IgnoreCase)
|
||||
|| CommentNode->NodeComment.Equals(LegacyMCPZoneCommentText, ESearchCase::IgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
class FMCPZoneInputProcessor : public IInputProcessor
|
||||
{
|
||||
public:
|
||||
explicit FMCPZoneInputProcessor(FUEBlueprintMCPEditorModule& InOwner)
|
||||
: Owner(InOwner)
|
||||
{
|
||||
}
|
||||
|
||||
virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override
|
||||
{
|
||||
}
|
||||
|
||||
virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
|
||||
{
|
||||
if (!Owner.bMCPZoneModeActive || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TSharedPtr<FBlueprintEditor> Editor = Owner.ActiveMCPZoneEditor.Pin();
|
||||
if (!Editor.IsValid() || !Editor->GetFocusedGraph())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Editor->GetFocusedGraph(), false);
|
||||
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
|
||||
if (!GraphPanel)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
|
||||
const FVector2f ScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
|
||||
if (!Geometry.IsUnderLocation(ScreenPosition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bDragging = true;
|
||||
StartScreenPosition = ScreenPosition;
|
||||
return true;
|
||||
}
|
||||
|
||||
virtual bool HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
|
||||
{
|
||||
return bDragging && Owner.bMCPZoneModeActive;
|
||||
}
|
||||
|
||||
virtual bool HandleMouseButtonUpEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
|
||||
{
|
||||
if (!bDragging || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bDragging = false;
|
||||
const FVector2f EndScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
|
||||
Owner.TryCreateMCPZoneFromScreenDrag(StartScreenPosition, EndScreenPosition);
|
||||
return true;
|
||||
}
|
||||
|
||||
virtual const TCHAR* GetDebugName() const override
|
||||
{
|
||||
return TEXT("MCPZoneInputProcessor");
|
||||
}
|
||||
|
||||
private:
|
||||
FUEBlueprintMCPEditorModule& Owner;
|
||||
bool bDragging = false;
|
||||
FVector2f StartScreenPosition = FVector2f::ZeroVector;
|
||||
};
|
||||
|
||||
IMPLEMENT_MODULE(FUEBlueprintMCPEditorModule, UEBlueprintMCPEditor)
|
||||
|
||||
void FUEBlueprintMCPEditorModule::StartupModule()
|
||||
{
|
||||
UToolMenus::RegisterStartupCallback(
|
||||
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FUEBlueprintMCPEditorModule::RegisterMenus));
|
||||
|
||||
FCoreDelegates::OnPostEngineInit.AddRaw(this, &FUEBlueprintMCPEditorModule::NotifyBridgeStatus);
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::NotifyBridgeStatus()
|
||||
{
|
||||
const FString Url = TEXT("http://127.0.0.1:30010/remote/info");
|
||||
TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
|
||||
Request->SetVerb(TEXT("GET"));
|
||||
Request->SetURL(Url);
|
||||
Request->SetTimeout(3.0f);
|
||||
Request->OnProcessRequestComplete().BindLambda(
|
||||
[](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bSucceeded)
|
||||
{
|
||||
const bool bOk = bSucceeded && Resp.IsValid() && Resp->GetResponseCode() == 200;
|
||||
FNotificationInfo Info(bOk
|
||||
? LOCTEXT("MCPBridgeUp", "UE Blueprint MCP bridge ready (Remote Control :30010)")
|
||||
: LOCTEXT("MCPBridgeDown", "UE Blueprint MCP bridge NOT reachable on :30010 — check Remote Control plugin"));
|
||||
Info.ExpireDuration = bOk ? 4.0f : 8.0f;
|
||||
Info.bUseSuccessFailIcons = true;
|
||||
Info.bFireAndForget = true;
|
||||
TSharedPtr<SNotificationItem> Item = FSlateNotificationManager::Get().AddNotification(Info);
|
||||
if (Item.IsValid())
|
||||
{
|
||||
Item->SetCompletionState(bOk ? SNotificationItem::CS_Success : SNotificationItem::CS_Fail);
|
||||
}
|
||||
});
|
||||
Request->ProcessRequest();
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::ShutdownModule()
|
||||
{
|
||||
FCoreDelegates::OnPostEngineInit.RemoveAll(this);
|
||||
|
||||
if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
|
||||
{
|
||||
FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor);
|
||||
MCPZoneInputProcessor.Reset();
|
||||
}
|
||||
|
||||
if (UToolMenus::IsToolMenuUIEnabled())
|
||||
{
|
||||
UToolMenus::UnRegisterStartupCallback(this);
|
||||
UToolMenus::UnregisterOwner(this);
|
||||
}
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::RegisterMenus()
|
||||
{
|
||||
FToolMenuOwnerScoped OwnerScoped(this);
|
||||
UToolMenu* Toolbar = UToolMenus::Get()->ExtendMenu("AssetEditor.BlueprintEditor.ToolBar");
|
||||
FToolMenuSection& Section = Toolbar->FindOrAddSection("Settings");
|
||||
|
||||
Section.AddDynamicEntry("MCPDraftZoneCommands", FNewToolMenuSectionDelegate::CreateLambda([this](FToolMenuSection& InSection)
|
||||
{
|
||||
UBlueprintEditorToolMenuContext* Context = InSection.FindContext<UBlueprintEditorToolMenuContext>();
|
||||
if (!Context || !Context->BlueprintEditor.IsValid() || !Context->GetBlueprintObj())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TWeakPtr<FBlueprintEditor> WeakEditor = Context->BlueprintEditor;
|
||||
FUIAction Action(
|
||||
FExecuteAction::CreateLambda([this, WeakEditor]()
|
||||
{
|
||||
ToggleMCPZoneMode(WeakEditor);
|
||||
}),
|
||||
FCanExecuteAction::CreateLambda([WeakEditor]()
|
||||
{
|
||||
return WeakEditor.IsValid() && WeakEditor.Pin()->GetFocusedGraph() != nullptr;
|
||||
}),
|
||||
FIsActionChecked::CreateLambda([this, WeakEditor]()
|
||||
{
|
||||
return IsMCPZoneModeActive(WeakEditor);
|
||||
}));
|
||||
|
||||
FToolMenuEntry Entry = FToolMenuEntry::InitToolBarButton(
|
||||
FName(TEXT("MCPDraftZone")),
|
||||
FToolUIActionChoice(Action),
|
||||
LOCTEXT("MCPDraftZone_Label", "MCP Zone"),
|
||||
LOCTEXT("MCPDraftZone_Tooltip", "Toggle MCP Zone mode. While active, left-mouse drag in the graph to create a generation zone. Toggle off to remove MCP Zone comments while keeping generated nodes."),
|
||||
FSlateIcon(),
|
||||
EUserInterfaceActionType::ToggleButton);
|
||||
InSection.AddEntry(Entry);
|
||||
}));
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor)
|
||||
{
|
||||
TSharedPtr<FBlueprintEditor> Editor = BlueprintEditor.Pin();
|
||||
if (!Editor.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == Editor)
|
||||
{
|
||||
RemoveMCPZones(Editor->GetBlueprintObj());
|
||||
bMCPZoneModeActive = false;
|
||||
ActiveMCPZoneEditor.Reset();
|
||||
if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
|
||||
{
|
||||
FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor);
|
||||
MCPZoneInputProcessor.Reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bMCPZoneModeActive)
|
||||
{
|
||||
if (TSharedPtr<FBlueprintEditor> PreviousEditor = ActiveMCPZoneEditor.Pin())
|
||||
{
|
||||
RemoveMCPZones(PreviousEditor->GetBlueprintObj());
|
||||
}
|
||||
}
|
||||
|
||||
bMCPZoneModeActive = true;
|
||||
ActiveMCPZoneEditor = Editor;
|
||||
if (!MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
|
||||
{
|
||||
MCPZoneInputProcessor = MakeShared<FMCPZoneInputProcessor>(*this);
|
||||
FSlateApplication::Get().RegisterInputPreProcessor(MCPZoneInputProcessor);
|
||||
}
|
||||
}
|
||||
|
||||
bool FUEBlueprintMCPEditorModule::IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const
|
||||
{
|
||||
return bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == BlueprintEditor.Pin();
|
||||
}
|
||||
|
||||
bool FUEBlueprintMCPEditorModule::TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition)
|
||||
{
|
||||
TSharedPtr<FBlueprintEditor> Editor = ActiveMCPZoneEditor.Pin();
|
||||
if (!Editor.IsValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UBlueprint* Blueprint = Editor->GetBlueprintObj();
|
||||
UEdGraph* Graph = Editor->GetFocusedGraph();
|
||||
if (!Blueprint || !Graph)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Graph, false);
|
||||
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
|
||||
if (!GraphPanel)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
|
||||
const FVector2f LocalStart = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(StartScreenPosition));
|
||||
const FVector2f LocalEnd = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(EndScreenPosition));
|
||||
const FVector2f GraphStart = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalStart));
|
||||
const FVector2f GraphEnd = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalEnd));
|
||||
|
||||
CreateMCPZone(Graph, Blueprint, GraphStart, GraphEnd);
|
||||
return true;
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::CreateMCPZone(UEdGraph* Graph, UBlueprint* Blueprint, const FVector2f& GraphStart, const FVector2f& GraphEnd) const
|
||||
{
|
||||
if (!Blueprint || !Graph)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const FScopedTransaction Transaction(LOCTEXT("CreateMCPDraftZone", "Create MCP Draft Zone"));
|
||||
Graph->Modify();
|
||||
Blueprint->Modify();
|
||||
|
||||
constexpr int32 MinWidth = 240;
|
||||
constexpr int32 MinHeight = 160;
|
||||
const int32 MinX = FMath::FloorToInt(FMath::Min(GraphStart.X, GraphEnd.X));
|
||||
const int32 MinY = FMath::FloorToInt(FMath::Min(GraphStart.Y, GraphEnd.Y));
|
||||
const int32 MaxX = FMath::CeilToInt(FMath::Max(GraphStart.X, GraphEnd.X));
|
||||
const int32 MaxY = FMath::CeilToInt(FMath::Max(GraphStart.Y, GraphEnd.Y));
|
||||
|
||||
UEdGraphNode_Comment* ZoneNode = NewObject<UEdGraphNode_Comment>(Graph);
|
||||
ZoneNode->CreateNewGuid();
|
||||
ZoneNode->NodeComment = MCPZoneCommentText;
|
||||
ZoneNode->bCommentBubbleVisible = false;
|
||||
ZoneNode->CommentColor = FLinearColor(0.05f, 0.32f, 0.85f, 0.35f);
|
||||
ZoneNode->NodePosX = MinX;
|
||||
ZoneNode->NodePosY = MinY;
|
||||
ZoneNode->NodeWidth = FMath::Max(MinWidth, MaxX - MinX);
|
||||
ZoneNode->NodeHeight = FMath::Max(MinHeight, MaxY - MinY);
|
||||
|
||||
Graph->AddNode(ZoneNode, true, false);
|
||||
ZoneNode->PostPlacedNewNode();
|
||||
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
|
||||
}
|
||||
|
||||
void FUEBlueprintMCPEditorModule::RemoveMCPZones(UBlueprint* Blueprint) const
|
||||
{
|
||||
if (!Blueprint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const FScopedTransaction Transaction(LOCTEXT("RemoveMCPDraftZones", "Remove MCP Draft Zones"));
|
||||
Blueprint->Modify();
|
||||
|
||||
TArray<UEdGraph*> Graphs;
|
||||
Blueprint->GetAllGraphs(Graphs);
|
||||
for (UEdGraph* Graph : Graphs)
|
||||
{
|
||||
if (!Graph)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Graph->Modify();
|
||||
TArray<UEdGraphNode*> Nodes = Graph->Nodes;
|
||||
for (UEdGraphNode* Node : Nodes)
|
||||
{
|
||||
if (IsMCPZoneComment(Node))
|
||||
{
|
||||
Graph->RemoveNode(Node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
79
Source/UEBlueprintMCPEditor/Public/MCPCaptureLibrary.h
Normal file
79
Source/UEBlueprintMCPEditor/Public/MCPCaptureLibrary.h
Normal file
@ -0,0 +1,79 @@
|
||||
// Headless capture helpers for the UEBlueprintMCP plugin.
|
||||
//
|
||||
// Lets external agents SEE editor content WITHOUT opening any editor tab/window:
|
||||
// * RenderAssetThumbnailToPNG — renders ANY asset (StaticMesh, SkeletalMesh,
|
||||
// Material, Texture, Blueprint, Niagara, AnimSequence, Sound, ...) using the
|
||||
// editor's per-class thumbnail renderer. No asset editor needs to be open.
|
||||
// * CaptureEditorSceneToPNG — renders the current editor world from an
|
||||
// arbitrary camera via an offscreen SceneCaptureComponent2D. No level
|
||||
// viewport / PIE required.
|
||||
//
|
||||
// Both write a PNG to disk and return its absolute path. Called from Python via
|
||||
// unreal.MCPCaptureLibrary.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPCaptureLibrary.generated.h"
|
||||
|
||||
UCLASS()
|
||||
class UEBLUEPRINTMCPEDITOR_API UMCPCaptureLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Render an asset's thumbnail to a PNG file, headlessly.
|
||||
* Works for every asset type that has a registered thumbnail renderer, which
|
||||
* is effectively all of them (meshes, materials, textures, blueprints,
|
||||
* niagara systems, anim sequences, sounds, data assets, ...). The asset
|
||||
* editor does NOT need to be open.
|
||||
*
|
||||
* @param AssetPath Object path, e.g. "/Game/Meshes/SM_Rock" (no class prefix needed).
|
||||
* @param OutputAbsPath Absolute directory to write into (created if missing).
|
||||
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended.
|
||||
* @param Width Pixels, clamped [16, 2048].
|
||||
* @param Height Pixels, clamped [16, 2048].
|
||||
* @param OutFullPath On success, absolute path of the written PNG.
|
||||
* @param OutError On failure, a short description.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Capture",
|
||||
meta = (DisplayName = "Render Asset Thumbnail To PNG"))
|
||||
static bool RenderAssetThumbnailToPNG(
|
||||
const FString& AssetPath,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the active editor world from an arbitrary camera to a PNG, headlessly,
|
||||
* using an offscreen SceneCaptureComponent2D. No level viewport is needed.
|
||||
*
|
||||
* @param Location World-space camera location.
|
||||
* @param Rotation World-space camera rotation (pitch/yaw/roll).
|
||||
* @param FOV Horizontal field of view in degrees (clamped [5, 170]).
|
||||
* @param OutputAbsPath Absolute directory to write into (created if missing).
|
||||
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended.
|
||||
* @param Width Pixels, clamped [16, 4096].
|
||||
* @param Height Pixels, clamped [16, 4096].
|
||||
* @param OutFullPath On success, absolute path of the written PNG.
|
||||
* @param OutError On failure, a short description.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Capture",
|
||||
meta = (DisplayName = "Capture Editor Scene To PNG"))
|
||||
static bool CaptureEditorSceneToPNG(
|
||||
FVector Location,
|
||||
FRotator Rotation,
|
||||
float FOV,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError
|
||||
);
|
||||
};
|
||||
430
Source/UEBlueprintMCPEditor/Public/MCPGraphLibrary.h
Normal file
430
Source/UEBlueprintMCPEditor/Public/MCPGraphLibrary.h
Normal file
@ -0,0 +1,430 @@
|
||||
// Exposes K2 graph mutation to Python.
|
||||
//
|
||||
// In Python these are reachable as:
|
||||
// unreal.MCPGraphLibrary.spawn_call_function_node(bp, graph, owner_class, fn_name, pos)
|
||||
// unreal.MCPGraphLibrary.connect_pins(graph, from_id, "Then", to_id, "Exec")
|
||||
// etc.
|
||||
//
|
||||
// Every spawn returns the node's NodeGuid as a string. Python keeps a local
|
||||
// id -> NodeGuid map and passes the guid into connect_pins / set_pin_default.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPGraphLibrary.generated.h"
|
||||
|
||||
class UBlueprint;
|
||||
class UWidgetBlueprint;
|
||||
class UWidget;
|
||||
class UEdGraph;
|
||||
class UEdGraphNode;
|
||||
class UEdGraphPin;
|
||||
class UScriptStruct;
|
||||
class UEnum;
|
||||
|
||||
UCLASS()
|
||||
class UEBLUEPRINTMCPEDITOR_API UMCPGraphLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
// ====================================================================
|
||||
// Graph lookup
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static UEdGraph* FindEventGraph(UBlueprint* Blueprint);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static UEdGraph* FindFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static TArray<FString> ListNodeIds(UEdGraph* Graph);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static TArray<FString> ListPinsOnNode(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static int32 ClearGraph(UEdGraph* Graph);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
|
||||
static bool DeleteNode(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
// ====================================================================
|
||||
// Spawners — basic
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnCallFunctionNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* FunctionOwnerClass, FName FunctionName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnEventNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* OwnerClass, FName EventName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnCustomEventNode(UBlueprint* Blueprint, UEdGraph* Graph, FName EventName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Network")
|
||||
static bool SetCustomEventNetworkFlags(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeIdOrEventName, FName Mode, bool bReliable);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnComponentBoundEvent(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnBranchNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSequenceNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, int32 OutputCount);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnVariableGetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnVariableSetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSelfNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnCastNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position);
|
||||
|
||||
// ====================================================================
|
||||
// Spawners — flow / collections
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSwitchOnIntNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSwitchOnStringNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSwitchOnEnumNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnMakeArrayNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnMakeStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnBreakStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSetFieldsInStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
|
||||
|
||||
/** Spawn a UK2Node_MacroInstance bound to one of the StandardMacros (ForLoop,
|
||||
* ForEachLoop, WhileLoop, DoOnce, Gate, FlipFlop, MultiGate, IsValid, DoN). */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnStandardMacroNode(UBlueprint* Blueprint, UEdGraph* Graph, FName MacroName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnSpawnActorFromClassNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnFormatTextNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnGetSubsystemNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* SubsystemClass, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnLoadAssetNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
// ====================================================================
|
||||
// Spawners — Tier 2 (knot/comment/enum literal/class defaults/return/arrays)
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnKnotNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnCommentNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnEnumLiteralNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnGetClassDefaultsNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnFunctionResultNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnGetArrayItemNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
|
||||
static FString SpawnPromotableOperatorNode(UBlueprint* Blueprint, UEdGraph* Graph, FName OperatorName, FVector2D Position);
|
||||
|
||||
/** Add a new case pin to a SwitchInt/SwitchString/SwitchEnum node. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static bool AddCasePinToSwitch(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
/** Set FormatText template AND reconstruct so {arg} pins appear. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static bool FormatTextSetFormat(UEdGraph* Graph, const FString& NodeId, const FString& Format);
|
||||
|
||||
/** Force a node to ReconstructNode() (e.g. after toggling pure/latent). */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static bool ReconstructNode(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
// ====================================================================
|
||||
// Delegates
|
||||
// ====================================================================
|
||||
/** Bind an existing event to a component's multicast delegate.
|
||||
* target = "ComponentName.DelegateName". */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
|
||||
static FString SpawnBindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
|
||||
static FString SpawnUnbindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
|
||||
static FString SpawnCallDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName DispatcherName, FVector2D Position);
|
||||
|
||||
/** AssignDelegate = AddDelegate + CustomEvent — a sugar combo node. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
|
||||
static FString SpawnAssignDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
|
||||
|
||||
/** Add an Event Dispatcher (multicast delegate variable) with no params. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AddEventDispatcher(UBlueprint* Blueprint, FName DispatcherName);
|
||||
|
||||
// ====================================================================
|
||||
// Edges & defaults
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
|
||||
static bool ConnectPins(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
|
||||
static bool BreakLink(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
|
||||
static bool BreakAllNodeLinks(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
|
||||
static bool SetPinDefault(UEdGraph* Graph, const FString& NodeId, FName PinName, const FString& Value);
|
||||
|
||||
// ====================================================================
|
||||
// BP-level authoring
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static UEdGraph* AddFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static UEdGraph* AddMacroGraph(UBlueprint* Blueprint, FName MacroName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AddInterface(UBlueprint* Blueprint, const FString& InterfacePath);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool SetVariableDefaultValue(UBlueprint* Blueprint, FName VariableName, const FString& Value);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool SetVariableMetadata(UBlueprint* Blueprint, FName VariableName, bool bInstanceEditable, bool bExposeOnSpawn, const FString& Category, const FString& Tooltip);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AddLocalVariable(UBlueprint* Blueprint, FName FunctionName, FName VariableName, const FString& TypeSpec, const FString& DefaultValue);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static FString AddComponentToBlueprint(UBlueprint* Blueprint, UClass* ComponentClass, FName ComponentName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AddMemberVariable(UBlueprint* Blueprint, FName VariableName, const FString& TypeSpec);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RemoveComponent(UBlueprint* Blueprint, FName ComponentName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AttachComponent(UBlueprint* Blueprint, FName ChildName, FName ParentName, FName SocketName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool SetRootComponent(UBlueprint* Blueprint, FName ComponentName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool SetComponentProperty(UBlueprint* Blueprint, FName ComponentName, FName PropertyName, const FString& Value);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool SetComponentTransform(UBlueprint* Blueprint, FName ComponentName, FVector Location, FRotator Rotation, FVector Scale);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool ConfigureBoxComponent(UBlueprint* Blueprint, FName ComponentName, FVector Extent, FName CollisionProfile);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RenameVariable(UBlueprint* Blueprint, FName OldName, FName NewName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool DeleteVariable(UBlueprint* Blueprint, FName VariableName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RenameFunction(UBlueprint* Blueprint, FName OldName, FName NewName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RemoveFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RemoveMacroGraph(UBlueprint* Blueprint, FName MacroName);
|
||||
|
||||
/** Add an input/output parameter to a user function. Direction: "input"/"output". */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool AddFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName, const FString& TypeSpec, const FString& Direction);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
|
||||
static bool RemoveFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName);
|
||||
|
||||
// ====================================================================
|
||||
// UMG Designer / WidgetTree
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static FString DescribeWidgetTreeJson(UWidgetBlueprint* Blueprint);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static FString GetWidgetPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static FString GetWidgetSlotPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static FString AddWidgetToTree(UWidgetBlueprint* Blueprint, UClass* WidgetClass, FName WidgetName, FName ParentName, int32 Index, bool bIsVariable);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool RemoveWidgetFromTree(UWidgetBlueprint* Blueprint, FName WidgetName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool RenameWidgetInTree(UWidgetBlueprint* Blueprint, FName OldName, FName NewName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool MoveWidgetInTree(UWidgetBlueprint* Blueprint, FName WidgetName, FName NewParentName, int32 Index);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetRootWidget(UWidgetBlueprint* Blueprint, FName WidgetName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetWidgetIsVariable(UWidgetBlueprint* Blueprint, FName WidgetName, bool bIsVariable);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetWidgetProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetWidgetSlotProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetCanvasSlotLayout(UWidgetBlueprint* Blueprint, FName WidgetName, FVector2D Position, FVector2D Size, FVector2D AnchorsMinimum, FVector2D AnchorsMaximum, FVector2D Alignment, bool bAutoSize, int32 ZOrder);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName, FName ContentWidgetName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool ClearNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool SetDesiredFocusWidget(UWidgetBlueprint* Blueprint, FName WidgetName);
|
||||
|
||||
/** Create a UMG WidgetAnimation that keys a single float property (e.g. RenderOpacity)
|
||||
* on WidgetName. Times (seconds) and Values are parallel arrays — each pair is one
|
||||
* linear key. The animation is added to the WidgetBlueprint's Animations array; loop
|
||||
* is controlled at PlayAnimation time (NumLoopsToPlay=0). Returns false on bad input
|
||||
* or if an animation with AnimName already exists. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
|
||||
static bool CreateWidgetFloatAnimation(UWidgetBlueprint* Blueprint, FName AnimName, FName WidgetName, FName PropertyName, const TArray<float>& Times, const TArray<float>& Values);
|
||||
|
||||
// ====================================================================
|
||||
// Compile / introspect
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
|
||||
static bool CompileAndSaveBlueprint(UBlueprint* Blueprint);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
|
||||
static TArray<FString> GetCompileErrors(UBlueprint* Blueprint);
|
||||
|
||||
/** Compile + capture FCompilerResultsLog. Returns "SEV|NodeName|Message" per entry. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
|
||||
static TArray<FString> CompileWithMessages(UBlueprint* Blueprint);
|
||||
|
||||
/** Dump entire graph as JSON string round-trippable into apply_graph NodeSpec. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
|
||||
static FString DumpGraphToJson(UEdGraph* Graph);
|
||||
|
||||
// ====================================================================
|
||||
// Discovery — Pipeline v0.3
|
||||
// ====================================================================
|
||||
/** List all graphs on a Blueprint. Returns JSON: {event:[], functions:[], macros:[], delegates:[]}. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString ListGraphsJson(UBlueprint* Blueprint);
|
||||
|
||||
/** Full BP overview: parent, interfaces, vars, components (SCS tree), functions, dispatchers. JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString DescribeBlueprintJson(UBlueprint* Blueprint);
|
||||
|
||||
/** Currently selected nodes in the open BP editor for `Blueprint`. JSON list of NodeGuid strings + per-node info. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString GetSelectedNodesJson(UBlueprint* Blueprint);
|
||||
|
||||
/** All currently-open Blueprint editors (paths). */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static TArray<FString> ListOpenBlueprints();
|
||||
|
||||
/** For a UK2Node_CallFunction by guid, return {owner_class, function, source_bp_path?, native?} JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString ResolveFunctionSourceJson(UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
/** All node guids in this BP that reference variable `Name`. JSON list grouped by graph. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString FindVariableReferencesJson(UBlueprint* Blueprint, FName VariableName);
|
||||
|
||||
/** All call sites of `FunctionName` (defined on Blueprint or external) within this BP. JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static FString FindFunctionReferencesJson(UBlueprint* Blueprint, FName FunctionName);
|
||||
|
||||
/** AssetRegistry-driven BP search by parent class path. Returns list of BP paths. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static TArray<FString> FindBlueprintsByParent(const FString& ParentClassPath);
|
||||
|
||||
/** Hard-referenced assets of this BP (asset registry). */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static TArray<FString> GetReferencedAssets(const FString& AssetPath);
|
||||
|
||||
/** Reverse: assets that reference this BP. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
|
||||
static TArray<FString> GetReferencersOfAsset(const FString& AssetPath);
|
||||
|
||||
// ====================================================================
|
||||
// Editing — single-node operations
|
||||
// ====================================================================
|
||||
/** Move nodes by guid to new positions. positions: array of {guid,x,y} encoded as parallel arrays. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static int32 MoveNodes(UEdGraph* Graph, const TArray<FString>& NodeIds, const TArray<FVector2D>& Positions);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static bool ResizeCommentNode(UEdGraph* Graph, const FString& NodeId, FVector2D Size);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
|
||||
static bool SetNodeComment(UEdGraph* Graph, const FString& NodeId, const FString& Comment, bool bCommentBubbleVisible);
|
||||
|
||||
// ====================================================================
|
||||
// Navigation — open editors, focus nodes
|
||||
// ====================================================================
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
|
||||
static bool OpenBlueprintEditor(UBlueprint* Blueprint);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
|
||||
static bool OpenGraphInEditor(UBlueprint* Blueprint, UEdGraph* Graph);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
|
||||
static bool JumpToNodeInEditor(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeId);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
|
||||
static TArray<FString> ListFunctionsOnClass(UClass* Class);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
|
||||
static TArray<FString> ListPropertiesOnClass(UClass* Class);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
|
||||
static TArray<FString> ListDelegatesOnClass(UClass* Class);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
|
||||
static FString GetPinType(UClass* OwnerClass, FName FunctionName, FName PinName);
|
||||
|
||||
private:
|
||||
static UEdGraphNode* FindNodeByGuid(UEdGraph* Graph, const FString& NodeId);
|
||||
static UEdGraphPin* FindPinByName(UEdGraphNode* Node, const FName& PinName);
|
||||
static class USCS_Node* FindSCSNode(UBlueprint* Blueprint, FName ComponentName);
|
||||
|
||||
template <typename TNode>
|
||||
static TNode* PlaceNode(UEdGraph* Graph, FVector2D Position);
|
||||
};
|
||||
28
Source/UEBlueprintMCPEditor/Public/MCPMaterialLibrary.h
Normal file
28
Source/UEBlueprintMCPEditor/Public/MCPMaterialLibrary.h
Normal file
@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPMaterialLibrary.generated.h"
|
||||
|
||||
class UMaterialExpression;
|
||||
class UMaterial;
|
||||
class UMaterialFunction;
|
||||
|
||||
UCLASS()
|
||||
class UEBLUEPRINTMCPEDITOR_API UMCPMaterialLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
|
||||
static TArray<UMaterialExpression*> GetMaterialExpressions(UObject* MaterialOrFunction);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
|
||||
static UMaterialExpression* FindMaterialExpression(UObject* MaterialOrFunction, const FString& NodeId);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
|
||||
static FString DumpMaterialGraphToJson(UObject* MaterialOrFunction);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
|
||||
static FString ConnectMaterialExpressionsRaw(UObject* MaterialOrFunction, UMaterialExpression* FromExpression, const FString& FromOutput, UMaterialExpression* ToExpression, const FString& ToInput);
|
||||
};
|
||||
69
Source/UEBlueprintMCPEditor/Public/MCPNiagaraLibrary.h
Normal file
69
Source/UEBlueprintMCPEditor/Public/MCPNiagaraLibrary.h
Normal file
@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPNiagaraLibrary.generated.h"
|
||||
|
||||
class UNiagaraSystem;
|
||||
class UNiagaraEmitter;
|
||||
class UNiagaraScript;
|
||||
class UNiagaraRendererProperties;
|
||||
|
||||
/**
|
||||
* Editor-only helpers wrapping Niagara authoring APIs for the MCP server.
|
||||
* All methods are no-ops/return defaults outside the editor.
|
||||
*/
|
||||
UCLASS()
|
||||
class UMCPNiagaraLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Create an empty UNiagaraSystem asset at the given /Game/... path. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static bool CreateNiagaraSystem(const FString& PackagePath);
|
||||
|
||||
/** List emitter handle names on the system. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static TArray<FString> GetEmitterHandleNames(UNiagaraSystem* System);
|
||||
|
||||
/** Add an emitter (asset) to the system. Returns the new handle name or empty on failure. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static FString AddEmitterToSystem(UNiagaraSystem* System, UNiagaraEmitter* SourceEmitter, const FString& EmitterName);
|
||||
|
||||
/** Remove an emitter handle (by handle name) from the system. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static bool RemoveEmitterFromSystem(UNiagaraSystem* System, const FString& EmitterName);
|
||||
|
||||
/**
|
||||
* List module nodes (function-call nodes) in a stack group.
|
||||
* GroupName: EmitterSpawn | EmitterUpdate | ParticleSpawn | ParticleUpdate | SystemSpawn | SystemUpdate
|
||||
* EmitterName ignored for SystemSpawn/SystemUpdate.
|
||||
* Returns module names in evaluation order.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static TArray<FString> ListModules(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName);
|
||||
|
||||
/**
|
||||
* Insert a module script into the stack group. Index = -1 appends.
|
||||
* Returns the spawned module's function name (used as ModuleName for subsequent ops), or empty on failure.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static FString AddModuleToStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, UNiagaraScript* ModuleScript, int32 Index);
|
||||
|
||||
/** Remove a module node from the stack group by name. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static bool RemoveModuleFromStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, const FString& ModuleName);
|
||||
|
||||
/** Add a renderer (UNiagaraRendererProperties subclass) to an emitter. Returns the renderer class name or empty. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static FString AddRendererToEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass);
|
||||
|
||||
/** Remove all renderers of a given class from an emitter. Returns count removed. */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static int32 RemoveRenderersFromEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass);
|
||||
|
||||
/** Recompile the Niagara system (and its emitters). */
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
|
||||
static bool RequestCompile(UNiagaraSystem* System);
|
||||
};
|
||||
69
Source/UEBlueprintMCPEditor/Public/MCPVoxelGraphLibrary.h
Normal file
69
Source/UEBlueprintMCPEditor/Public/MCPVoxelGraphLibrary.h
Normal file
@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPVoxelGraphLibrary.generated.h"
|
||||
|
||||
class UEdGraph;
|
||||
|
||||
UCLASS()
|
||||
class UEBLUEPRINTMCPEDITOR_API UMCPVoxelGraphLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static bool IsVoxelGraphAsset(UObject* Asset);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString ListTerminalGraphsJson(UObject* Asset);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static UEdGraph* FindTerminalEdGraph(UObject* Asset, const FString& Terminal);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString DumpTerminalGraphJson(UObject* Asset, const FString& Terminal);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString GetSelectedNodesJson(UObject* Asset, const FString& Terminal);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString SpawnCommentNode(UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment, FLinearColor Color);
|
||||
|
||||
// Returns a JSON catalogue of every spawnable Voxel node (struct nodes + function-library nodes).
|
||||
// Filter is an optional case-insensitive substring matched against id / display name / category.
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString ListNodeTypesJson(const FString& Filter, int32 Limit);
|
||||
|
||||
// Spawns a functional Voxel node (UVoxelGraphNode_Struct) identified by NodeId
|
||||
// (struct name, struct path, display name, or "Library.Function" for function nodes).
|
||||
// Returns the new node GUID as string, or empty on failure.
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString SpawnStructNode(UEdGraph* Graph, const FString& NodeId, FVector2D Position);
|
||||
|
||||
// Registers a graph property and returns its new GUID as JSON {ok, guid, name, type}.
|
||||
// Kind: "parameter" (graph parameter), "input"/"output" (function in/out), "local" (local variable).
|
||||
// TypeStr: float|double|int|bool|vector2d|vector|color|name (defaults to float).
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString AddGraphPropertyJson(UObject* Asset, const FString& Terminal, const FString& Kind, const FString& Name, const FString& TypeStr, const FString& Category);
|
||||
|
||||
// Lists graph properties of the given Kind as JSON {ok, properties:[{guid,name,type,category}]}.
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString ListGraphPropertiesJson(UObject* Asset, const FString& Terminal, const FString& Kind);
|
||||
|
||||
// Spawns a usage node for an existing property (Ref = guid or name). bDeclaration only matters
|
||||
// for Kind "local" (declaration vs usage). Returns the new node GUID, or empty on failure.
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString SpawnPropertyNode(UEdGraph* Graph, const FString& Kind, const FString& Ref, FVector2D Position, bool bDeclaration);
|
||||
|
||||
// Spawns a "Call Function" node for a function terminal graph (FunctionRef = terminal guid or name).
|
||||
// Returns the new node GUID, or empty on failure.
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static FString SpawnCallFunctionNode(UEdGraph* Graph, const FString& FunctionRef, FVector2D Position);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static bool NotifyGraphChanged(UEdGraph* Graph, bool bCompile);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
|
||||
static bool OpenVoxelGraphAsset(UObject* Asset);
|
||||
};
|
||||
42
Source/UEBlueprintMCPEditor/Public/MCPWidgetRenderLibrary.h
Normal file
42
Source/UEBlueprintMCPEditor/Public/MCPWidgetRenderLibrary.h
Normal file
@ -0,0 +1,42 @@
|
||||
// Vision-loop helper for the UEBlueprintMCP plugin.
|
||||
// Renders a UMG WidgetBlueprint to a PNG file so external agents can SEE
|
||||
// what they just authored. Called from Python via unreal.MCPWidgetRenderLibrary.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "MCPWidgetRenderLibrary.generated.h"
|
||||
|
||||
class UUserWidget;
|
||||
|
||||
UCLASS()
|
||||
class UEBLUEPRINTMCPEDITOR_API UMCPWidgetRenderLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Render a UserWidget class to a PNG file on disk.
|
||||
* Returns true on success and fills OutFullPath with the absolute file path.
|
||||
*
|
||||
* @param WidgetClass A UUserWidget subclass (e.g. GeneratedClass of a WidgetBlueprint).
|
||||
* @param OutputAbsPath Absolute directory path where the PNG should be written.
|
||||
* The folder is created if missing.
|
||||
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended automatically.
|
||||
* @param Width Texture width in pixels (clamped to [16, 8192]).
|
||||
* @param Height Texture height in pixels (clamped to [16, 8192]).
|
||||
* @param OutFullPath On success, the absolute path of the written PNG.
|
||||
* @param OutError On failure, a short description of what went wrong.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "MCP|UI",
|
||||
meta = (DisplayName = "Render Widget To PNG"))
|
||||
static bool RenderWidgetToPNG(
|
||||
TSubclassOf<UUserWidget> WidgetClass,
|
||||
const FString& OutputAbsPath,
|
||||
const FString& FileNameNoExt,
|
||||
int32 Width,
|
||||
int32 Height,
|
||||
FString& OutFullPath,
|
||||
FString& OutError
|
||||
);
|
||||
};
|
||||
31
Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h
Normal file
31
Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h
Normal file
@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
class FBlueprintEditor;
|
||||
class IInputProcessor;
|
||||
class UBlueprint;
|
||||
class UEdGraph;
|
||||
|
||||
class FUEBlueprintMCPEditorModule : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
virtual void StartupModule() override;
|
||||
virtual void ShutdownModule() override;
|
||||
|
||||
private:
|
||||
friend class FMCPZoneInputProcessor;
|
||||
|
||||
void RegisterMenus();
|
||||
void NotifyBridgeStatus();
|
||||
void ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor);
|
||||
bool IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const;
|
||||
bool TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition);
|
||||
void CreateMCPZone(UEdGraph* Graph, UBlueprint* Blueprint, const FVector2f& GraphStart, const FVector2f& GraphEnd) const;
|
||||
void RemoveMCPZones(UBlueprint* Blueprint) const;
|
||||
|
||||
bool bMCPZoneModeActive = false;
|
||||
TWeakPtr<FBlueprintEditor> ActiveMCPZoneEditor;
|
||||
TSharedPtr<IInputProcessor> MCPZoneInputProcessor;
|
||||
};
|
||||
129
Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs
Normal file
129
Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs
Normal file
@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class UEBlueprintMCPEditor : ModuleRules
|
||||
{
|
||||
public UEBlueprintMCPEditor(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"UMG",
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"UnrealEd",
|
||||
"UMGEditor",
|
||||
"BlueprintGraph",
|
||||
"KismetCompiler",
|
||||
"Kismet",
|
||||
"GraphEditor",
|
||||
"InputCore",
|
||||
"Slate",
|
||||
"SlateCore",
|
||||
"EditorSubsystem",
|
||||
"ToolMenus",
|
||||
"AssetTools",
|
||||
"Json",
|
||||
"JsonUtilities",
|
||||
"AssetRegistry",
|
||||
"HTTP",
|
||||
"Niagara",
|
||||
"NiagaraCore",
|
||||
"NiagaraEditor",
|
||||
"ImageWrapper",
|
||||
"RenderCore",
|
||||
"RHI",
|
||||
"MovieScene",
|
||||
"MovieSceneTracks",
|
||||
});
|
||||
|
||||
// ---- Optional Voxel integration -----------------------------------
|
||||
// voxel_graph_op needs Voxel's editor modules. Voxel is a heavy third-party
|
||||
// plugin most projects don't have, so it is wired in ONLY when present. The
|
||||
// C++ side guards everything behind WITH_MCP_VOXEL and compiles to stubs at 0.
|
||||
// env UE_MCP_WITH_VOXEL = "1" -> force on (errors if the plugin is missing)
|
||||
// env UE_MCP_WITH_VOXEL = "0" -> force off
|
||||
// unset -> auto-detect
|
||||
string voxelEnv = Environment.GetEnvironmentVariable("UE_MCP_WITH_VOXEL");
|
||||
string voxelPrivate = (voxelEnv == "0") ? null : FindVoxelGraphEditorPrivate(Target);
|
||||
bool voxelEnabled = voxelPrivate != null && voxelEnv != "0";
|
||||
|
||||
if (voxelEnabled)
|
||||
{
|
||||
// Reach a few VoxelGraphEditor private headers (node classes). We only use
|
||||
// inline/virtual members, so no private link symbols cross the boundary.
|
||||
PrivateIncludePaths.Add(voxelPrivate);
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"VoxelCore",
|
||||
"VoxelCoreEditor",
|
||||
"VoxelGraph",
|
||||
"VoxelGraphEditor",
|
||||
});
|
||||
PublicDefinitions.Add("WITH_MCP_VOXEL=1");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (voxelEnv == "1")
|
||||
{
|
||||
throw new BuildException(
|
||||
"UE_MCP_WITH_VOXEL=1 but the Voxel plugin (Source/VoxelGraphEditor/Private) " +
|
||||
"could not be located. Install the Voxel plugin into this project or the engine, " +
|
||||
"or unset UE_MCP_WITH_VOXEL.");
|
||||
}
|
||||
PublicDefinitions.Add("WITH_MCP_VOXEL=0");
|
||||
}
|
||||
}
|
||||
|
||||
// Locate <Voxel>/Source/VoxelGraphEditor/Private by scanning the project's and
|
||||
// engine's plugin folders for Voxel.uplugin. Returns null if Voxel isn't installed.
|
||||
private string FindVoxelGraphEditorPrivate(ReadOnlyTargetRules Target)
|
||||
{
|
||||
var roots = new List<string>();
|
||||
if (Target.ProjectFile != null)
|
||||
{
|
||||
roots.Add(Path.Combine(Target.ProjectFile.Directory.FullName, "Plugins"));
|
||||
}
|
||||
// The Plugins folder this plugin itself lives in (covers <Project>/Plugins/Voxel).
|
||||
roots.Add(Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "..")));
|
||||
try { roots.Add(Path.Combine(EngineDirectory, "Plugins")); } catch { }
|
||||
|
||||
foreach (string root in roots)
|
||||
{
|
||||
string hit = FindVoxelUnder(root, 0);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bounded, fault-tolerant recursive search (GetFiles+AllDirectories aborts wholesale
|
||||
// on a single inaccessible subdir, so we walk manually and swallow per-dir errors).
|
||||
private string FindVoxelUnder(string dir, int depth)
|
||||
{
|
||||
if (depth > 5 || string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return null;
|
||||
try
|
||||
{
|
||||
string upl = Path.Combine(dir, "Voxel.uplugin");
|
||||
if (File.Exists(upl))
|
||||
{
|
||||
string priv = Path.Combine(dir, "Source", "VoxelGraphEditor", "Private");
|
||||
if (Directory.Exists(priv)) return priv;
|
||||
}
|
||||
foreach (string sub in Directory.GetDirectories(dir))
|
||||
{
|
||||
string hit = FindVoxelUnder(sub, depth + 1);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
36
UEBlueprintMCP.uplugin
Normal file
36
UEBlueprintMCP.uplugin
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"FileVersion": 3,
|
||||
"Version": 1,
|
||||
"VersionName": "0.1.0",
|
||||
"FriendlyName": "Unreal Engine MCP System",
|
||||
"Description": "Editor-only C++/Python helpers that expose the UE editor (Blueprint graphs, UMG, materials, Niagara, PCG, assets, build, Insights, ...) as MCP tools. Project- and machine-agnostic: all paths are auto-detected.",
|
||||
"Category": "Editor",
|
||||
"CreatedBy": "ExbyteLabs",
|
||||
"CreatedByURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
|
||||
"DocsURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
|
||||
"MarketplaceURL": "",
|
||||
"SupportURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
|
||||
"EnabledByDefault": true,
|
||||
"CanContainContent": false,
|
||||
"IsBetaVersion": true,
|
||||
"IsExperimentalVersion": false,
|
||||
"Installed": false,
|
||||
"Modules": [
|
||||
{
|
||||
"Name": "UEBlueprintMCPEditor",
|
||||
"Type": "Editor",
|
||||
"LoadingPhase": "Default"
|
||||
}
|
||||
],
|
||||
"Plugins": [
|
||||
{
|
||||
"Name": "Voxel",
|
||||
"Enabled": true,
|
||||
"Optional": true
|
||||
},
|
||||
{
|
||||
"Name": "Niagara",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
273
UI_WORKFLOW.md
Normal file
273
UI_WORKFLOW.md
Normal file
@ -0,0 +1,273 @@
|
||||
# UI_WORKFLOW — UMG/Slate UI authoring guide for AI agents
|
||||
|
||||
> **READ THIS BEFORE EDITING ANY `WBP_*` / UMG / SLATE / UI.**
|
||||
> If you start a UI task without following this file, you will produce a generic
|
||||
> placeholder ("prototype look"). This document is the contract that turns
|
||||
> "ugly UMG demo" into "shippable UI".
|
||||
|
||||
## 0. Quick decision flow
|
||||
|
||||
```
|
||||
user asks for UI work
|
||||
│
|
||||
├─ Is there a DA_UITheme asset in /Game/UI/Theme/ ? ── no ─→ STEP A. Build theme first.
|
||||
│ (one-time setup, ~5 min)
|
||||
├─ Are there base components in /Game/UI/Components/ ? ── no ─→ STEP B. Build component library.
|
||||
│
|
||||
├─ Did the user give a reference image / mood / brand? ── no ─→ STEP C. Ask for one before coding.
|
||||
│
|
||||
└─ All three present? ─→ STEP D. Compose screen → screenshot → iterate.
|
||||
```
|
||||
|
||||
You **must not** invent palettes, spacing, or component shapes on the fly. If
|
||||
a token is missing, you ADD it to the theme. If a component is missing, you
|
||||
CREATE it in the library. The screen WBP only composes.
|
||||
|
||||
---
|
||||
|
||||
## STEP A — The Theme (one DataAsset to rule them all)
|
||||
|
||||
### A.1 Class
|
||||
Create C++ DataAsset `UProjectUITheme : UPrimaryDataAsset` (use `cpp_scaffold_op`):
|
||||
|
||||
```cpp
|
||||
USTRUCT(BlueprintType) struct FProjectUIPalette {
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditAnywhere) FLinearColor Primary = FLinearColor::White;
|
||||
UPROPERTY(EditAnywhere) FLinearColor Accent = FLinearColor(1, 0.5, 0.1, 1);
|
||||
UPROPERTY(EditAnywhere) FLinearColor Danger = FLinearColor::Red;
|
||||
UPROPERTY(EditAnywhere) FLinearColor Surface = FLinearColor(0.05, 0.05, 0.05, 1);
|
||||
UPROPERTY(EditAnywhere) FLinearColor SurfaceAlt = FLinearColor(0.1, 0.1, 0.1, 1);
|
||||
UPROPERTY(EditAnywhere) FLinearColor Ink = FLinearColor(0.9, 0.9, 0.85, 1);
|
||||
UPROPERTY(EditAnywhere) FLinearColor Muted = FLinearColor(0.5, 0.5, 0.45, 1);
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType) struct FProjectUIType {
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditAnywhere) FSlateFontInfo H1; // 48pt display
|
||||
UPROPERTY(EditAnywhere) FSlateFontInfo H2; // 32pt headings
|
||||
UPROPERTY(EditAnywhere) FSlateFontInfo Body; // 16pt text
|
||||
UPROPERTY(EditAnywhere) FSlateFontInfo Caption;// 12pt small
|
||||
UPROPERTY(EditAnywhere) FSlateFontInfo Mono; // monospace / terminal text
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
class PROJECTUI_API UProjectUITheme : public UPrimaryDataAsset {
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditAnywhere, Category="Theme") FProjectUIPalette Palette;
|
||||
UPROPERTY(EditAnywhere, Category="Theme") FProjectUIType Type;
|
||||
// Spacing rhythm — use index, not raw px.
|
||||
UPROPERTY(EditAnywhere, Category="Theme") TArray<float> Spacing = {4, 8, 12, 16, 24, 32, 48, 64};
|
||||
UPROPERTY(EditAnywhere, Category="Theme") float CornerRadius = 4.f;
|
||||
UPROPERTY(EditAnywhere, Category="Theme") float BorderWeight = 1.f;
|
||||
// Named brushes — referenced by their FName key from WBPs.
|
||||
UPROPERTY(EditAnywhere, Category="Theme") TMap<FName, FSlateBrush> Brushes;
|
||||
};
|
||||
```
|
||||
|
||||
### A.2 Instance
|
||||
Create instance at `/Game/UI/Theme/DA_UITheme` (one per visual mode — fork as
|
||||
`DA_UITheme_<Mode>` if a project needs several). Example tuning:
|
||||
|
||||
| Token | Value | Why |
|
||||
|---|---|---|
|
||||
| Palette.Surface | `(0.04, 0.05, 0.05, 1)` | near-black base |
|
||||
| Palette.SurfaceAlt | `(0.09, 0.1, 0.09, 1)` | row striping |
|
||||
| Palette.Ink | `(0.87, 0.91, 0.83, 1)` | off-white, never pure |
|
||||
| Palette.Accent | `(0.95, 0.62, 0.21, 1)` | amber highlight |
|
||||
| Palette.Danger | `(0.78, 0.10, 0.10, 1)` | muted red, not hex red |
|
||||
| Palette.Muted | `(0.45, 0.50, 0.45, 1)` | green-grey |
|
||||
| Type.H1 | font `Roboto Mono Bold`, 48, letter spacing +1 | display |
|
||||
| Type.Body | font `Inter Regular`, 16 | readable body |
|
||||
| CornerRadius | 0 | sharp / brutalist |
|
||||
| BorderWeight | 1.5 | thicker → more readable |
|
||||
|
||||
### A.3 Access
|
||||
Every WBP holds `UPROPERTY(EditDefaultsOnly) UProjectUITheme* Theme;` set in Class
|
||||
Defaults to `DA_UITheme`. No hardcoded colors anywhere. If you catch yourself
|
||||
typing `FLinearColor(0.7, 0.1, 0.1, 1)` — stop, add a token to the theme instead.
|
||||
|
||||
---
|
||||
|
||||
## STEP B — Component Library
|
||||
|
||||
Build these once, reuse forever. All under `/Game/UI/Components/`.
|
||||
|
||||
### Required base set
|
||||
| Component | Purpose | Variants |
|
||||
|---|---|---|
|
||||
| `WBP_Button` | All clickable | Primary, Ghost, Danger, Icon-only |
|
||||
| `WBP_Card` | Container w/ header/body/footer | Flat, Raised, Outlined |
|
||||
| `WBP_HUDPanel` | Framed panel (corner brackets, overlay channel) | Top, Bottom, Floating |
|
||||
| `WBP_StatBar` | Numeric stat bar (health/energy/progress) | Horizontal, Vertical, Circular |
|
||||
| `WBP_Label` | Text wrapper that auto-picks Type token | H1, H2, Body, Caption, Mono |
|
||||
| `WBP_Divider` | 1px line | Solid, Dashed, Glitchy |
|
||||
| `WBP_Icon` | Wraps `Image` with theme tint | size=spacing token |
|
||||
| `WBP_ListRow` | One row of a list, themed | Selected/Hovered states wired |
|
||||
| `WBP_Modal` | Centered overlay with backdrop | Confirm, Form, Info |
|
||||
| `WBP_Toast` | Bottom-slide notification | Info, Warn, Error |
|
||||
|
||||
### Authoring rules
|
||||
- **No hardcoded sizes** — use `Theme->Spacing[idx]` (e.g. padding = `Spacing[3]` = 16px).
|
||||
- **Every interactive widget has 4 states wired**: Normal, Hovered, Pressed, Disabled. Hooked from theme brushes, not duplicated.
|
||||
- **Each component exposes `SetVariant(EVariant)` and `SetState(EState)` BP-callable funcs** — so the screen WBP composes by setting flags, not by hand-tweaking child widgets.
|
||||
- **Animation:** every state transition has a 80-150ms ease. Define one `WidgetAnimation` per state in `WBP_Button`, copy pattern to others. No animation = feels dead.
|
||||
|
||||
### Optional stylized overlays
|
||||
- `WBP_HUDPanel` can expose a Niagara/Material overlay channel — bind project-specific material instances (e.g. `MI_UI_Scanline`, `MI_UI_Noise`) as defaults if your project has them.
|
||||
- `WBP_Label` Mono variant can use a chromatic-aberration material instance.
|
||||
- Background of every HUD piece: prefer a themed `MI_UI_Surface` material over a flat solid color when a stylized look is wanted.
|
||||
|
||||
---
|
||||
|
||||
## STEP C — Reference parsing protocol
|
||||
|
||||
When user supplies a reference image, **do not** start coding. First emit:
|
||||
|
||||
```
|
||||
REFERENCE TOKENS
|
||||
================
|
||||
Palette (6 colors HEX, ordered by dominance):
|
||||
1. Primary surface: #__
|
||||
2. Surface alt: #__
|
||||
3. Ink (text): #__
|
||||
4. Accent: #__
|
||||
5. Danger/warning: #__
|
||||
6. Muted/secondary: #__
|
||||
|
||||
Typography:
|
||||
H1 size: __pt weight: __ tracking: __
|
||||
Body size:__pt weight: __
|
||||
Family hint: __ (serif/sans/mono/condensed)
|
||||
|
||||
Spacing rhythm (3 visible gaps in px): __ __ __
|
||||
→ suggested Spacing array: [__, __, __, __, __, __, __, __]
|
||||
|
||||
Corner radius observed: __px (0 = sharp / brutalist)
|
||||
Border weight observed: __px
|
||||
|
||||
Visual hierarchy (one sentence): __
|
||||
|
||||
Mood (3 adjectives): __, __, __
|
||||
|
||||
Departures from current DA_UITheme:
|
||||
- Theme.Palette.__ should become #__ (was #__)
|
||||
- Theme.Spacing[__] should become __ (was __)
|
||||
```
|
||||
|
||||
Then ask the user: "apply to existing theme or fork as `DA_UITheme_<NewName>`?"
|
||||
|
||||
Once approved, use `audio_op set_property` / direct asset edits to mutate the
|
||||
theme DataAsset. Never edit individual WBPs to match a ref — change the theme,
|
||||
all WBPs follow.
|
||||
|
||||
---
|
||||
|
||||
## STEP D — The Vision Loop (most important)
|
||||
|
||||
This is what makes AI UI not suck. After ANY structural change, you MUST:
|
||||
|
||||
```
|
||||
1. widget_op apply_tree / set_property / ... (your edit)
|
||||
2. widget_op compile_save {bp_path}
|
||||
3. widget_op screenshot {bp_path, size: [1920, 1080]}
|
||||
4. Read the returned png_path. If you are a vision-capable model:
|
||||
open the file, look at the result, list 3 specific problems before next edit.
|
||||
5. Loop until no problems remain.
|
||||
```
|
||||
|
||||
### Hard rule
|
||||
- **Maximum 2 blind edits** (without screenshot) per session. After that, MUST screenshot. If a user reports "it looks bad", your first action is `widget_op screenshot`, not asking for clarification.
|
||||
- **Before declaring "done"**, last action MUST be a screenshot, and you MUST describe what you see (not what you intended).
|
||||
- The screenshot landing path is `<Project>/Saved/MCP/widgets/<bp_name>.png`. Always attach this to your turn output.
|
||||
|
||||
### What to look for in the screenshot
|
||||
- **Alignment grid** — are baselines and gutters consistent? Use a mental 8px grid.
|
||||
- **Hierarchy** — can you ID primary action in <1s by squinting?
|
||||
- **Density** — is it crammed (Unity-tutorial vibe) or breathing?
|
||||
- **Color count** — is anything outside the theme palette? If yes, that's a bug.
|
||||
- **Edge cases** — empty list, longest possible label (16 chars), worst-case localization (German is +30%).
|
||||
|
||||
---
|
||||
|
||||
## STEP E — Screen composition
|
||||
|
||||
When user asks for a new screen (`WBP_MainMenu`, `WBP_HUD_InGame`, ...):
|
||||
|
||||
1. Sketch hierarchy in text first:
|
||||
```
|
||||
WBP_MainMenu (RootCanvas)
|
||||
WBP_HUDPanel "title-bar" (anchored top, span)
|
||||
WBP_Label H1 "GAME TITLE"
|
||||
WBP_Card "menu-list" (centered, 480x600)
|
||||
WBP_Button Primary "Continue"
|
||||
WBP_Button Ghost "New Game"
|
||||
WBP_Button Ghost "Settings"
|
||||
WBP_Button Danger "Quit"
|
||||
WBP_Label Caption "v0.4.2 • Build 2026-05-28" (bottom-left)
|
||||
```
|
||||
2. Confirm with user (one screen = one confirmation step, not per widget).
|
||||
3. Build with `widget_op apply_tree` (single transaction, single undo).
|
||||
4. Compile + screenshot.
|
||||
5. Iterate per Step D loop.
|
||||
|
||||
---
|
||||
|
||||
## Naming & paths (enforced)
|
||||
|
||||
| Asset | Path | Naming |
|
||||
|---|---|---|
|
||||
| Theme DataAsset | `/Game/UI/Theme/` | `DA_UITheme_*` |
|
||||
| Base components | `/Game/UI/Components/` | `WBP_<Name>` |
|
||||
| Screens | `/Game/UI/Screens/` | `WBP_<Screen>` (no prefix like "Menu_" — flat) |
|
||||
| Materials | `/Game/UI/Materials/` | `M_UI_*`, `MI_UI_*` |
|
||||
| Icons | `/Game/UI/Icons/` | `T_UI_Icon_<verb>_<noun>` |
|
||||
| Frame brushes | `/Game/UI/Frames/` | `T_UI_Frame_*` |
|
||||
|
||||
If asked to create UI assets outside these paths, ask why. Probably a mistake.
|
||||
|
||||
---
|
||||
|
||||
## Things NEVER to do (anti-patterns AI agents fall into)
|
||||
|
||||
1. ❌ Hardcoded color `FLinearColor(0.2, 0.2, 0.2)` anywhere. → Use theme.
|
||||
2. ❌ Hardcoded padding `Padding=8`. → Use `Theme->Spacing[1]`.
|
||||
3. ❌ One giant CanvasPanel screen with absolute coords for everything. → Use HorizontalBox/VerticalBox/Grid composition.
|
||||
4. ❌ "Just add a `Border` widget" for a card. → Use `WBP_Card` component.
|
||||
5. ❌ Per-screen unique button styling. → Add a variant to `WBP_Button`.
|
||||
6. ❌ Default UE font (Roboto). → Always pick from `Theme->Type`.
|
||||
7. ❌ Default `Button` widget visible to the user. → Always wrap in `WBP_Button` because state animations live there.
|
||||
8. ❌ Skip Step D. → If you didn't screenshot, you didn't finish.
|
||||
9. ❌ Generate icons inline as text "≡" or "✕". → Use `WBP_Icon` with a real Texture2D.
|
||||
|
||||
---
|
||||
|
||||
## Required first action for any UI session
|
||||
|
||||
```
|
||||
discover_op find_bp_by_parent { parent_class_path: "/Script/ProjectUI.ProjectUITheme" }
|
||||
```
|
||||
- 0 hits → STEP A (build theme).
|
||||
- 1+ hits → fetch the `DA_UITheme` palette via `validation_op get_references` or read directly, cache token names in your working memory.
|
||||
|
||||
```
|
||||
discover_op find_bp_by_parent { parent_class_path: "/Script/UMG.UserWidget" }
|
||||
```
|
||||
List existing components. If `/Game/UI/Components/WBP_Button` missing → STEP B.
|
||||
|
||||
Then and only then proceed to the user's actual request.
|
||||
|
||||
---
|
||||
|
||||
## Why this works
|
||||
|
||||
Without this guide, AI generates UMG by sampling "what UMG typically looks like
|
||||
on GitHub" — boring, generic, grey. With this guide:
|
||||
|
||||
- **Decisions are constrained** (theme tokens, component variants) → no more "AI inventing aesthetics from scratch every time".
|
||||
- **Composition replaces design** → LLMs are great at composing; bad at painting.
|
||||
- **Vision loop closes the feedback gap** → AI sees its output, judges it, fixes it.
|
||||
- **References parse to data**, not vibes → reproducible, debuggable, diffable.
|
||||
|
||||
Result: shippable UI in 1-2 iterations instead of 15.
|
||||
4
agent-gateway/.gitignore
vendored
Normal file
4
agent-gateway/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
auth.env
|
||||
integrations.json
|
||||
.generated-audio/
|
||||
91
agent-gateway/README.md
Normal file
91
agent-gateway/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# In-engine agent gateway
|
||||
|
||||
A local HTTP/SSE server that runs a Claude agent (via the **Claude Agent SDK**)
|
||||
wired to this project's `ue-blueprint` MCP server, and serves a chat
|
||||
UI for the Unreal **WebBrowser** widget. Talk to the agent from *inside* the
|
||||
editor while it edits Blueprints, spawns nodes, builds C++, screenshots UMG, etc.
|
||||
|
||||
```
|
||||
WBP_AgentChat (WebBrowser) ──HTTP/SSE──▶ server.mjs (this)
|
||||
public/ chat UI ├─ @anthropic-ai/claude-agent-sdk (the agent)
|
||||
stream text + tool cards ├─ spawns ../src/server.js (ue-blueprint MCP)
|
||||
clickable /Game asset chips └─ /api/focus → UE Remote Control (sync Content Browser)
|
||||
```
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. **Install deps** (already done once):
|
||||
```powershell
|
||||
cd Plugins/UEBlueprintMCP/agent-gateway
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Give the agent credentials.** The gateway spawns its own agent process, so
|
||||
it needs auth that does *not* depend on your interactive Claude Code session.
|
||||
Pick one:
|
||||
|
||||
- **Subscription (recommended)** — generate a long-lived token:
|
||||
```powershell
|
||||
claude setup-token
|
||||
```
|
||||
Copy the printed token into `agent-gateway/auth.env` (gitignored):
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
```
|
||||
|
||||
- **API key (pay-as-you-go)** — in `auth.env`:
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-ant-api03-...
|
||||
```
|
||||
|
||||
`GET /api/health` returns `"hasAuth": false` until one is present, and the
|
||||
chat header shows a "no token" state.
|
||||
|
||||
3. **Enable UE plugins**: *Web Browser* (for the widget) and *Remote Control API*
|
||||
(for live tool access — `WebControl.StartServer`, default port 30010).
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
npm start # → http://127.0.0.1:8765
|
||||
```
|
||||
or double-click **`start-gateway.bat`**.
|
||||
|
||||
Open `http://127.0.0.1:8765` in any browser to test the UI without UE.
|
||||
|
||||
## Inside Unreal
|
||||
|
||||
Open **`/Game/UI/Agent/WBP_AgentChat`**, add it to the viewport (or dock it in an
|
||||
Editor Utility Widget). It hosts a full-bleed WebBrowser pointed at the gateway
|
||||
URL. See `../UI_WORKFLOW.md` note: this screen is a deliberate exception to the
|
||||
UMG-token rule — all visuals live in `public/styles.css` (the `:root` block),
|
||||
not in `DA_UITheme`, because the surface is HTML inside a browser.
|
||||
|
||||
## Config (env vars)
|
||||
|
||||
| var | default | meaning |
|
||||
|-------------------|------------------------|----------------------------------|
|
||||
| `MCP_AGENT_PORT` | `8765` | gateway port |
|
||||
| `MCP_AGENT_HOST` | `127.0.0.1` | bind host |
|
||||
| `MCP_AGENT_MODEL` | `claude-opus-4-8` | agent model |
|
||||
| `UE_PROJECT_DIR` | repo root (auto) | project cwd handed to the agent |
|
||||
| `UE_REMOTE_CONTROL_URL` | `http://127.0.0.1:30010` | UE Remote Control (asset focus) |
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /` — chat UI (static from `public/`)
|
||||
- `GET /api/health` — `{ ok, model, projectDir, mcp, hasAuth }`
|
||||
- `POST /api/chat` — `{ message, sessionId? }` → **SSE** stream of:
|
||||
`init` · `text` · `thinking` · `tool_start` · `tool_end` · `error` · `done`.
|
||||
`done.sessionId` is replayed by the client to continue the conversation.
|
||||
- `POST /api/focus` — `{ path: "/Game/..." }` → focuses the asset in UE's
|
||||
Content Browser (used by clickable asset chips).
|
||||
|
||||
## Notes
|
||||
|
||||
- The agent runs with `permissionMode: bypassPermissions` (no interactive
|
||||
terminal exists behind a web UI) and inherits the project `CLAUDE.md` via
|
||||
`settingSources: ["project"]`, so it follows the same conventions Claude Code
|
||||
does — including the MCP-first and UI vision-loop rules.
|
||||
- Conversation state is per `sessionId` (SDK `resume`); the browser keeps it in
|
||||
memory for the page session.
|
||||
8
agent-gateway/auth.env.example
Normal file
8
agent-gateway/auth.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# Copy this file to auth.env (gitignored) and fill in ONE of the following.
|
||||
# The gateway loads this file at startup; one KEY=VALUE per line.
|
||||
|
||||
# Subscription auth (recommended). Get a token with: claude setup-token
|
||||
CLAUDE_CODE_OAUTH_TOKEN=
|
||||
|
||||
# ...or use a pay-as-you-go API key instead:
|
||||
# ANTHROPIC_API_KEY=
|
||||
1465
agent-gateway/package-lock.json
generated
Normal file
1465
agent-gateway/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
agent-gateway/package.json
Normal file
14
agent-gateway/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ue-mcp-agent-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Local HTTP/SSE gateway that runs a Claude agent (Agent SDK) wired to the ue-blueprint MCP, and serves the in-engine chat UI for the UE WebBrowser widget.",
|
||||
"main": "server.mjs",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.158"
|
||||
}
|
||||
}
|
||||
699
agent-gateway/public/app.js
Normal file
699
agent-gateway/public/app.js
Normal file
@ -0,0 +1,699 @@
|
||||
// In-engine agent — "Forge" client (EventSource transport for UE CEF).
|
||||
(() => {
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const I = {
|
||||
brand: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
|
||||
history: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 4v4h4"/><path d="M12 8v4l3 2"/></svg>',
|
||||
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
search: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
|
||||
server: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>',
|
||||
mention: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.5 7.1"/></svg>',
|
||||
tools: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4z"/></svg>',
|
||||
clip: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5l-8.4 8.4a5 5 0 0 1-7.1-7.1l8.4-8.4a3.3 3.3 0 0 1 4.7 4.7l-8.4 8.4a1.7 1.7 0 0 1-2.4-2.4l7.7-7.7"/></svg>',
|
||||
send: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.6l17.4-8a1 1 0 0 0 0-1.8l-17.4-8A1 1 0 0 0 2 3.7L4.2 11l9.8 1-9.8 1L2 20.3a1 1 0 0 0 1.4 1.1z"/></svg>',
|
||||
check: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 6"/></svg>',
|
||||
x: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
|
||||
chev: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
|
||||
asset: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
|
||||
chat: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-8 8H4l3-3a8 8 0 1 1 14-5z"/></svg>',
|
||||
spark: '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8z"/></svg>',
|
||||
trash: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13"/></svg>',
|
||||
gear: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.04.04-2.78 2.78-.04-.04A1.8 1.8 0 0 0 15 19.4a1.8 1.8 0 0 0-1 .6l-.06.06H10.1l-.06-.06a1.8 1.8 0 0 0-1-.6 1.8 1.8 0 0 0-1.98.36l-.04.04-2.78-2.78.04-.04A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-.6-1l-.06-.06V10.1l.06-.06a1.8 1.8 0 0 0 .6-1 1.8 1.8 0 0 0-.36-1.98l-.04-.04 2.78-2.78.04.04A1.8 1.8 0 0 0 9 4.6a1.8 1.8 0 0 0 1-.6l.06-.06h3.84l.06.06a1.8 1.8 0 0 0 1 .6 1.8 1.8 0 0 0 1.98-.36l.04-.04 2.78 2.78-.04.04A1.8 1.8 0 0 0 19.4 9c.1.36.3.7.6 1l.06.06v3.84l-.06.06c-.3.3-.5.64-.6 1z"/></svg>',
|
||||
volume: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/></svg>',
|
||||
download: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>',
|
||||
play: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.8v14.4a1 1 0 0 0 1.55.83l10.1-7.2a1 1 0 0 0 0-1.66L8.55 3.97A1 1 0 0 0 7 4.8z"/></svg>',
|
||||
pause: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>',
|
||||
textfix: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h10M4 10h7M4 15h6"/><path d="M14 18l2 2 4-5"/></svg>',
|
||||
translate: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h7M8.5 3v2M4 9h9M11 5c-1 4-3.2 6.5-6 8"/><path d="M8 10c1 1.4 2.2 2.5 4 3.5M14 20l4-9 4 9M15.5 17h5"/></svg>',
|
||||
};
|
||||
|
||||
// ---- state -------------------------------------------------------------
|
||||
let sessionId = null, busy = false, turn = null;
|
||||
let model = localStorage.getItem("mcp_model") || "claude-opus-4-8";
|
||||
let MODELS = [{ id: model, name: model, tag: "" }];
|
||||
let pending = []; // pending attachments [{dataUrl, media, name}]
|
||||
let chats = []; // [{id,title,model,sessionId,at,msgs:[...]}]
|
||||
let activeId = null;
|
||||
|
||||
const inputEl = $("input"), sendEl = $("send"), threadEl = $("thread"),
|
||||
statusDot = $("statusDot"), statusText = $("statusText"), toastEl = $("toast"),
|
||||
threadWrap = $("threadWrap"), mentionPop = $("mentionPop"), modelPop = $("modelPop");
|
||||
|
||||
function mountIcons() {
|
||||
$("brandMark").innerHTML = I.brand; $("historyBtn").innerHTML = I.history;
|
||||
$("newChatIcon").innerHTML = I.plus; $("searchIcon").innerHTML = I.search;
|
||||
$("mcpIcon").innerHTML = I.server; $("mentionIcon").innerHTML = I.mention;
|
||||
$("toolsIcon").innerHTML = I.tools; $("attachIcon").innerHTML = I.clip;
|
||||
$("sendIcon").innerHTML = I.send; $("modelGlyph").innerHTML = I.spark; $("modelChev").innerHTML = I.chev;
|
||||
$("soundIcon").innerHTML = I.volume; $("userChev").innerHTML = I.chev;
|
||||
$("settingsIcon").innerHTML = I.gear; $("integrationsIcon").innerHTML = I.tools;
|
||||
$("textFixIcon").innerHTML = I.textfix; $("translateIcon").innerHTML = I.translate;
|
||||
}
|
||||
|
||||
// ---- health ------------------------------------------------------------
|
||||
function refreshHealth() {
|
||||
return fetch("/api/health").then((r) => r.json()).then((h) => {
|
||||
if (Array.isArray(h.models) && h.models.length) MODELS = h.models;
|
||||
if (!MODELS.find((m) => m.id === model)) model = h.model || MODELS[0].id;
|
||||
const mm = MODELS.find((m) => m.id === model);
|
||||
$("modelName").textContent = mm ? mm.name : model;
|
||||
if (h.ueToolCount) ueToolCount = h.ueToolCount;
|
||||
setMcp(h.tools || []);
|
||||
if (h.hasAuth === false) setStatus("warn", "нет токена"); else setStatus("on", "online");
|
||||
}).catch(() => setStatus("off", "offline"));
|
||||
}
|
||||
function setStatus(kind, text) { statusDot.className = "dot " + (kind === "on" ? "on" : kind === "warn" ? "warn" : "off"); statusText.textContent = text; }
|
||||
let ueToolCount = 0;
|
||||
function setMcp(tools) {
|
||||
const ue = (tools || []).filter((t) => t.startsWith("mcp__ue-blueprint__")).length;
|
||||
if (ue) ueToolCount = ue;
|
||||
$("toolsCount").textContent = ueToolCount || (tools || []).length || "";
|
||||
$("mcpCount").textContent = "1/1";
|
||||
$("mcpList").innerHTML = `<div class="mcp-srv"><span class="dot on"></span><span class="srv-name">ue-blueprint</span><span class="srv-self">SELF-HOST</span><span class="srv-tools">${ueToolCount} tools</span></div>`;
|
||||
}
|
||||
// The startup tool-prime can race the MCP subprocess connecting; poll health
|
||||
// a few times until the ue-blueprint tools show up.
|
||||
function pollTools(n) { if (ueToolCount || n <= 0) return; setTimeout(() => refreshHealth().then(() => pollTools(n - 1)), 3000); }
|
||||
function toast(t) { toastEl.textContent = t; toastEl.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); }
|
||||
|
||||
// ---- text rendering ----------------------------------------------------
|
||||
function esc(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }
|
||||
const ASSET_RE = /(\/Game\/[A-Za-z0-9_\/]+(?:\.[A-Za-z0-9_]+)?)/g;
|
||||
const MENTION_RE = /@([A-Za-z0-9_\/\.]+)/g;
|
||||
function chipsInSegment(seg) {
|
||||
seg = seg.replace(MENTION_RE, (m, name) => {
|
||||
const isAsset = name.startsWith("/Game/");
|
||||
const leaf = name.split("/").pop().split(".")[0];
|
||||
return `<span class="mention" data-asset="${isAsset ? name : ""}" data-copy="${esc(name)}">${I.mention}${esc(leaf)}</span>`;
|
||||
});
|
||||
seg = seg.replace(ASSET_RE, (m) => {
|
||||
const path = m.replace(/\.$/, ""); const leaf = path.split("/").pop();
|
||||
return `<span class="objlink" data-asset="${path}" title="${esc(path)}"><span class="ol-ico">${I.asset}</span>${esc(leaf)}</span>`;
|
||||
});
|
||||
return seg;
|
||||
}
|
||||
function renderMarkdown(text) {
|
||||
const parts = text.split(/```/); let html = "";
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i % 2 === 1) { html += `<pre><code>${esc(parts[i].replace(/^[a-zA-Z0-9]*\n/, ""))}</code></pre>`; }
|
||||
else {
|
||||
let seg = esc(parts[i]);
|
||||
seg = seg.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
|
||||
seg = seg.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
seg = seg.split(/(<code>.*?<\/code>)/g).map((s) => (s.startsWith("<code>") ? s : chipsInSegment(s))).join("");
|
||||
html += seg.replace(/\n/g, "<br>");
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function nowStr() { const d = new Date(); return d.getHours() + ":" + String(d.getMinutes()).padStart(2, "0"); }
|
||||
|
||||
// throttled scroll
|
||||
let scrollQueued = false;
|
||||
function scrollDown() { if (scrollQueued) return; scrollQueued = true; requestAnimationFrame(() => { scrollQueued = false; threadWrap.scrollTop = threadWrap.scrollHeight; }); }
|
||||
|
||||
// ---- message builders --------------------------------------------------
|
||||
function imagesHtml(images) {
|
||||
if (!images || !images.length) return "";
|
||||
return `<div class="msg-images">${images.map((d) => `<img src="${d}" />`).join("")}</div>`;
|
||||
}
|
||||
function addUserMessage(text, images) {
|
||||
const el = document.createElement("div"); el.className = "msg user-msg";
|
||||
el.innerHTML = `<div class="msg-avatar user">EX</div><div class="msg-body"><div class="msg-name">Вы <span class="ts">${nowStr()}</span></div>` +
|
||||
imagesHtml(images) + `<div class="msg-text">${renderMarkdown(text)}</div></div>`;
|
||||
threadEl.appendChild(el); scrollDown();
|
||||
}
|
||||
function addAgentShell() {
|
||||
const el = document.createElement("div"); el.className = "msg";
|
||||
el.innerHTML = `<div class="msg-avatar ai">${I.brand}</div><div class="msg-body"><div class="msg-name">Forge <span class="ts">${($("modelName").textContent)}</span></div></div>`;
|
||||
threadEl.appendChild(el);
|
||||
return { el, body: el.querySelector(".msg-body") };
|
||||
}
|
||||
|
||||
function audioSetHtml(message) {
|
||||
const sounds = message.sounds || [];
|
||||
return `<div class="audio-prompt">ElevenLabs variations for: <strong>${esc(message.prompt || "")}</strong></div><div class="audio-set">` +
|
||||
sounds.map((s, i) => `<div class="audio-card" data-audio-card><div class="audio-card-head"><span class="audio-badge">${I.volume}</span><span class="audio-name">${esc(s.name || `variation_${i + 1}`)}</span><span class="audio-meta">${Math.max(1, Math.round((s.bytes || 0) / 1024))} KB</span><button class="import-btn ${s.imported ? "done" : ""}" data-import-audio="${esc(s.id)}">${s.imported ? "Imported" : `${I.download} Import`}</button></div><div class="forge-player"><button class="player-play" data-player-play title="Play">${I.play}</button><span class="player-time" data-player-current>0:00</span><input class="player-progress" data-player-progress type="range" min="0" max="1000" value="0" step="1" aria-label="Audio progress" /><span class="player-time" data-player-duration>0:00</span><span class="player-volume-icon">${I.volume}</span><input class="player-volume" data-player-volume type="range" min="0" max="100" value="85" step="1" aria-label="Volume" /><audio data-player-audio preload="metadata" src="${esc(s.audioUrl)}"></audio></div></div>`).join("") +
|
||||
`</div>`;
|
||||
}
|
||||
function renderStoredAudio(message) {
|
||||
const sh = addAgentShell();
|
||||
const el = document.createElement("div"); el.className = "generated-audio"; el.innerHTML = audioSetHtml(message);
|
||||
sh.body.appendChild(el);
|
||||
}
|
||||
function addAudioMessage(prompt, sounds) {
|
||||
const message = { role: "audio", prompt, sounds };
|
||||
renderStoredAudio(message);
|
||||
pushMsg(message); saveChats(); scrollDown();
|
||||
}
|
||||
function fmtTime(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const mins = Math.floor(seconds / 60), secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
function syncPlayer(card) {
|
||||
const audio = card.querySelector("[data-player-audio]");
|
||||
const progress = card.querySelector("[data-player-progress]");
|
||||
card.querySelector("[data-player-current]").textContent = fmtTime(audio.currentTime);
|
||||
card.querySelector("[data-player-duration]").textContent = fmtTime(audio.duration);
|
||||
progress.value = audio.duration ? String(Math.round((audio.currentTime / audio.duration) * 1000)) : "0";
|
||||
const play = card.querySelector("[data-player-play]");
|
||||
play.innerHTML = audio.paused ? I.play : I.pause;
|
||||
play.title = audio.paused ? "Play" : "Pause";
|
||||
}
|
||||
function stopOtherPlayers(except) {
|
||||
threadEl.querySelectorAll("[data-player-audio]").forEach((audio) => {
|
||||
if (audio !== except && !audio.paused) { audio.pause(); syncPlayer(audio.closest("[data-audio-card]")); }
|
||||
});
|
||||
}
|
||||
|
||||
const VERB = {
|
||||
ping_ue: "Пинг UE", read_graph: "Чтение графа", apply_graph: "Правка графа", apply_batch: "Правка графа",
|
||||
compile: "Компиляция", introspect: "Интроспекция", discover_op: "Поиск в проекте", asset_op: "Ассет",
|
||||
widget_op: "Виджет", level_op: "Уровень", material_op: "Материал", niagara_op: "Niagara", console_op: "Консоль",
|
||||
live_coding_op: "Live Coding", build_op: "Сборка", validation_op: "Валидация", read_python_log: "Лог Python",
|
||||
get_active_blueprint: "Активный BP", selection_op: "Выделение", voxel_graph_op: "Voxel", animation_op: "Анимация",
|
||||
audio_op: "Аудио", ai_op: "AI", input_op: "Input", mesh_op: "Меш", render_op: "Рендер", network_op: "Сеть",
|
||||
preset_op: "Пресет", cull_op: "Culling", Read: "Чтение", Edit: "Правка", Write: "Запись", Bash: "Bash",
|
||||
Grep: "Поиск", Glob: "Поиск файлов", TodoWrite: "План", Task: "Подзадача", WebFetch: "Веб", WebSearch: "Веб-поиск",
|
||||
};
|
||||
const toolBase = (n) => n.replace(/^mcp__ue-blueprint__/, "").replace(/^mcp__[^_]+__/, "");
|
||||
function toolLabel(n) { if (n.startsWith("mcp__ue-blueprint__")) return "ue." + toolBase(n); if (n.startsWith("mcp__")) return n.replace(/^mcp__([^_]+)__/, "$1."); return n; }
|
||||
function targetOf(input) {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
let t = input.bp_path || input.path || input.widget || input.class || input.bp || input.file_path || input.class_path;
|
||||
if (!t && Array.isArray(input.ops) && input.ops[0]) t = input.ops[0].bp_path;
|
||||
return typeof t === "string" ? t : null;
|
||||
}
|
||||
function chipFor(tgt, base) {
|
||||
if (!tgt) return "";
|
||||
if (tgt.startsWith("/Game/")) return `<span class="objlink" data-asset="${esc(tgt)}" title="${esc(tgt)}"><span class="ol-ico">${I.asset}</span>${esc(tgt.split("/").pop())}</span>`;
|
||||
if (/^[A-Za-z0-9_]+$/.test(tgt) && tgt !== base) return `<span class="objlink"><span class="ol-ico">${I.asset}</span>${esc(tgt)}</span>`;
|
||||
return "";
|
||||
}
|
||||
function plural(n) { return n % 10 === 1 && n % 100 !== 11 ? "шаг" : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? "шага" : "шагов"); }
|
||||
|
||||
function ensureConsole() {
|
||||
if (turn.console) return;
|
||||
const c = document.createElement("div"); c.className = "console";
|
||||
c.innerHTML = `<div class="console-head"><div class="console-spin"></div><div class="console-title">Работаю… <span class="console-meta"></span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner"></div></div>`;
|
||||
turn.body.appendChild(c); turn.console = c; turn.stepsEl = c.querySelector(".console-inner");
|
||||
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
|
||||
scrollDown();
|
||||
}
|
||||
function addStep(id, name, input) {
|
||||
ensureConsole(); turn.nStep++;
|
||||
const base = toolBase(name), verb = VERB[base] || VERB[name] || "Инструмент", tgt = targetOf(input);
|
||||
const step = document.createElement("div"); step.className = "step run";
|
||||
step.innerHTML = `<div class="step-node">${turn.nStep}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(verb)}</span>${chipFor(tgt, base)}<span class="step-tool">${esc(toolLabel(name))}</span></div><span class="step-toggle" style="display:none">details</span><span class="step-dur"></span></div><div class="collapsible"><div><div class="tool-detail" style="display:none"></div></div></div></div>`;
|
||||
turn.stepsEl.appendChild(step);
|
||||
turn.steps.set(id, { el: step, t0: performance.now(), verb, tgt, tool: toolLabel(name) });
|
||||
if (turn.console) turn.console.querySelector(".console-meta").textContent = "· " + turn.nStep + " " + plural(turn.nStep);
|
||||
scrollDown();
|
||||
}
|
||||
function finishStep(id, isError, content) {
|
||||
const s = turn && turn.steps.get(id); if (!s) return;
|
||||
const dur = ((performance.now() - s.t0) / 1000).toFixed(1) + "s";
|
||||
s.el.classList.remove("run"); s.el.classList.add(isError ? "err" : "done");
|
||||
s.el.querySelector(".step-node").innerHTML = isError ? I.x : I.check;
|
||||
s.el.querySelector(".step-dur").textContent = dur;
|
||||
s.dur = dur; s.ok = !isError;
|
||||
const text = (content || "").toString().trim();
|
||||
if (text) {
|
||||
const toggle = s.el.querySelector(".step-toggle"), detail = s.el.querySelector(".tool-detail"), wrap = s.el.querySelector(".collapsible");
|
||||
detail.style.display = ""; detail.innerHTML = `<div class="td-head">результат</div><div class="td-body">${esc(text.length > 6000 ? text.slice(0, 6000) + "\n…" : text)}</div>`;
|
||||
toggle.style.display = "";
|
||||
toggle.addEventListener("click", () => { wrap.classList.toggle("open"); toggle.textContent = wrap.classList.contains("open") ? "hide" : "details"; });
|
||||
if (isError) { wrap.classList.add("open"); toggle.textContent = "hide"; }
|
||||
}
|
||||
scrollDown();
|
||||
}
|
||||
function ensureText() {
|
||||
if (turn.textEl) return;
|
||||
turn.textEl = document.createElement("div"); turn.textEl.className = "msg-text";
|
||||
turn.liveEl = document.createElement("span"); turn.caretEl = document.createElement("span"); turn.caretEl.className = "streaming-caret";
|
||||
turn.textEl.appendChild(turn.liveEl); turn.textEl.appendChild(turn.caretEl);
|
||||
turn.body.appendChild(turn.textEl);
|
||||
}
|
||||
function appendText(delta) { ensureText(); turn.raw += delta; turn.liveEl.appendChild(document.createTextNode(delta)); scrollDown(); }
|
||||
function appendThinking(delta) { if (!turn.thinkEl) { turn.thinkEl = document.createElement("div"); turn.thinkEl.className = "think"; turn.body.insertBefore(turn.thinkEl, turn.console || null); } turn.thinkEl.textContent += delta; scrollDown(); }
|
||||
|
||||
function finalizeTurn(durMs) {
|
||||
if (!turn) return;
|
||||
if (turn.console) {
|
||||
turn.console.classList.add("collapsed");
|
||||
const head = turn.console.querySelector(".console-head");
|
||||
const spin = head.querySelector(".console-spin"); if (spin) spin.outerHTML = `<span class="console-check">${I.check}</span>`;
|
||||
const secs = durMs ? (durMs / 1000).toFixed(0) : Math.round((performance.now() - turn.t0) / 1000);
|
||||
head.querySelector(".console-title").childNodes[0].nodeValue = `Готово за ${secs}s `;
|
||||
}
|
||||
if (turn.textEl) {
|
||||
if (turn.caretEl) turn.caretEl.remove();
|
||||
if (turn.raw.trim()) turn.textEl.innerHTML = renderMarkdown(turn.raw); else turn.textEl.remove();
|
||||
}
|
||||
if (!turn.console && !turn.textEl) turn.el.remove();
|
||||
// persist assistant message
|
||||
const steps = [];
|
||||
for (const [, s] of turn.steps) steps.push({ verb: s.verb, tgt: s.tgt, tool: s.tool, dur: s.dur, ok: s.ok });
|
||||
pushMsg({ role: "assistant", text: turn.raw, steps });
|
||||
turn = null;
|
||||
saveChats();
|
||||
}
|
||||
|
||||
// ---- static render of a stored assistant message (on chat reload) ------
|
||||
function renderStoredAssistant(m) {
|
||||
const sh = addAgentShell();
|
||||
if (m.steps && m.steps.length) {
|
||||
const c = document.createElement("div"); c.className = "console collapsed";
|
||||
const rows = m.steps.map((s, i) => `<div class="step ${s.ok === false ? "err" : "done"}"><div class="step-node">${s.ok === false ? I.x : I.check}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(s.verb || "")}</span>${chipFor(s.tgt, "")}<span class="step-tool">${esc(s.tool || "")}</span></div><span class="step-dur">${esc(s.dur || "")}</span></div></div></div>`).join("");
|
||||
c.innerHTML = `<div class="console-head"><span class="console-check">${I.check}</span><div class="console-title">Готово <span class="console-meta">· ${m.steps.length} ${plural(m.steps.length)}</span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner">${rows}</div></div>`;
|
||||
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
|
||||
sh.body.appendChild(c);
|
||||
}
|
||||
if (m.text && m.text.trim()) { const t = document.createElement("div"); t.className = "msg-text"; t.innerHTML = renderMarkdown(m.text); sh.body.appendChild(t); }
|
||||
}
|
||||
|
||||
// ---- chat persistence --------------------------------------------------
|
||||
function loadChats() { try { chats = JSON.parse(localStorage.getItem("mcp_chats_v1") || "[]"); } catch { chats = []; } activeId = localStorage.getItem("mcp_active") || null; }
|
||||
function saveChats() { try { localStorage.setItem("mcp_chats_v1", JSON.stringify(chats.slice(0, 50))); if (activeId) localStorage.setItem("mcp_active", activeId); } catch {} }
|
||||
function activeChat() { return chats.find((c) => c.id === activeId); }
|
||||
function pushMsg(m) { const c = activeChat(); if (c) { c.msgs.push(m); c.at = Date.now(); } }
|
||||
|
||||
function renderSidebar() {
|
||||
const groups = { "Сегодня": [], "Ранее": [] };
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
chats.slice().sort((a, b) => b.at - a.at).forEach((c) => { (c.at >= today.getTime() ? groups["Сегодня"] : groups["Ранее"]).push(c); });
|
||||
let html = "";
|
||||
for (const g of ["Сегодня", "Ранее"]) {
|
||||
if (!groups[g].length) continue;
|
||||
html += `<div class="sb-group-label">${g}</div>`;
|
||||
for (const c of groups[g]) html += `<div class="chat-item ${c.id === activeId ? "active" : ""}" data-chat="${c.id}"><span class="ci-icon">${I.chat}</span><span class="chat-title">${esc(c.title)}</span><span class="ci-act" data-del="${c.id}">${I.trash}</span></div>`;
|
||||
}
|
||||
$("sbScroll").innerHTML = html || `<div class="sb-group-label">Сессии</div>`;
|
||||
}
|
||||
|
||||
function renderThread() {
|
||||
threadEl.innerHTML = "";
|
||||
const c = activeChat();
|
||||
if (!c || !c.msgs.length) { showEmptyState(); return; }
|
||||
for (const m of c.msgs) { if (m.role === "user") addUserMessage(m.text, m.images); else if (m.role === "audio") renderStoredAudio(m); else renderStoredAssistant(m); }
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
const c = { id: "c" + Date.now().toString(36), title: "Новый чат", model, sessionId: null, at: Date.now(), msgs: [] };
|
||||
chats.unshift(c); activeId = c.id; sessionId = null; turn = null;
|
||||
$("convTitle").textContent = "Новый разговор"; $("convSub").textContent = "ue-blueprint MCP";
|
||||
renderSidebar(); renderThread(); saveChats(); inputEl.focus();
|
||||
}
|
||||
function switchChat(id) {
|
||||
const c = chats.find((x) => x.id === id); if (!c) return;
|
||||
activeId = id; sessionId = c.sessionId || null; model = c.model || model; turn = null;
|
||||
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
|
||||
$("convTitle").textContent = c.title; updateConvSub(c);
|
||||
renderSidebar(); renderThread(); saveChats();
|
||||
}
|
||||
function deleteChat(id) {
|
||||
chats = chats.filter((c) => c.id !== id);
|
||||
if (activeId === id) { activeId = chats[0] ? chats[0].id : null; if (activeId) switchChat(activeId); else newChat(); }
|
||||
renderSidebar(); saveChats();
|
||||
}
|
||||
function updateConvSub(c) {
|
||||
const n = (c && c.msgs ? c.msgs.reduce((a, m) => a + (m.steps ? m.steps.length : 0), 0) : 0);
|
||||
$("convSub").textContent = (n ? n + " tool calls · " : "") + "ue-blueprint MCP";
|
||||
}
|
||||
|
||||
// ---- empty state -------------------------------------------------------
|
||||
function showEmptyState() {
|
||||
const es = document.createElement("div"); es.className = "empty-state";
|
||||
es.innerHTML = `<div><div class="es-icon">${I.brand}</div><h2>Forge — копилот внутри движка</h2><p>Я работаю с проектом через ue-blueprint MCP и могу править Blueprints, спавнить ноды, билдить C++, делать скриншоты UMG.</p><div class="suggest-grid">${sugg("Пингани UE", "проверь связь и активный Blueprint")}${sugg("Список MCP-инструментов", "что ты умеешь")}${sugg("Опиши активный Blueprint", "разбери граф событий")}${sugg("Открой /Game", "покажи структуру проекта")}</div></div>`;
|
||||
threadEl.appendChild(es);
|
||||
es.querySelectorAll(".suggest").forEach((s) => s.addEventListener("click", () => { inputEl.value = s.dataset.q; autoGrow(); inputEl.focus(); }));
|
||||
}
|
||||
function sugg(t, d) { return `<button class="suggest" data-q="${esc(t)}"><div class="sg-title">${esc(t)}</div><div class="sg-desc">${esc(d)}</div></button>`; }
|
||||
|
||||
// ---- event handling ----------------------------------------------------
|
||||
function handleEvent(name, data) {
|
||||
switch (name) {
|
||||
case "init": sessionId = data.sessionId || sessionId; { const c = activeChat(); if (c) c.sessionId = sessionId; } if (data.tools) setMcp(data.tools); break;
|
||||
case "tool_start": addStep(data.id, data.name, data.input); break;
|
||||
case "tool_end": finishStep(data.id, data.isError, data.content); break;
|
||||
case "text": appendText(data.delta || ""); break;
|
||||
case "thinking": appendThinking(data.delta || ""); break;
|
||||
case "error": { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `<div class="err-text">⚠ ${esc(data.message || "ошибка")}</div>`); setStatus("warn", "ошибка"); break; }
|
||||
case "done": sessionId = data.sessionId || sessionId; break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- send --------------------------------------------------------------
|
||||
async function send() {
|
||||
const text = inputEl.value.trim();
|
||||
if ((!text && !pending.length) || busy) return;
|
||||
if (!pending.length && text.toLowerCase().startsWith("/sound ")) {
|
||||
inputEl.value = ""; autoGrow();
|
||||
await generateSounds(text.slice(7));
|
||||
return;
|
||||
}
|
||||
closeMention();
|
||||
busy = true; sendEl.disabled = true;
|
||||
setStatus("on", "думает…");
|
||||
|
||||
const imgs = pending.slice(); pending = []; renderAttachRow();
|
||||
const dataUrls = imgs.map((p) => p.dataUrl);
|
||||
inputEl.value = ""; autoGrow();
|
||||
|
||||
// ensure there is an active chat
|
||||
if (!activeChat()) newChat();
|
||||
const chat = activeChat();
|
||||
if (chat.msgs.length === 0) { chat.title = (text || "Изображение").slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
|
||||
|
||||
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
|
||||
addUserMessage(text, dataUrls);
|
||||
pushMsg({ role: "user", text, images: dataUrls });
|
||||
saveChats();
|
||||
|
||||
turn = Object.assign(addAgentShell(), { console: null, stepsEl: null, steps: new Map(), nStep: 0, thinkEl: null, textEl: null, liveEl: null, caretEl: null, raw: "", t0: performance.now() });
|
||||
|
||||
// upload attachments → ids
|
||||
let attachIds = [];
|
||||
try {
|
||||
for (const p of imgs) {
|
||||
const r = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dataUrl: p.dataUrl }) }).then((x) => x.json());
|
||||
if (r && r.ok) attachIds.push(r.id);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const qs = "message=" + encodeURIComponent(text) + "&model=" + encodeURIComponent(model) +
|
||||
(sessionId ? "&sessionId=" + encodeURIComponent(sessionId) : "") +
|
||||
(attachIds.length ? "&attach=" + attachIds.join(",") : "");
|
||||
const es = new EventSource("/api/chat-stream?" + qs);
|
||||
let finished = false, durMs = 0;
|
||||
const finish = () => { if (finished) return; finished = true; try { es.close(); } catch {} finalizeTurn(durMs); const c = activeChat(); if (c) updateConvSub(c); busy = false; sendEl.disabled = false; setStatus("on", "online"); refreshHealth(); inputEl.focus(); };
|
||||
["init", "text", "thinking", "tool_start", "tool_end"].forEach((nm) => es.addEventListener(nm, (e) => { let d; try { d = JSON.parse(e.data); } catch { return; } handleEvent(nm, d); }));
|
||||
es.addEventListener("done", (e) => { let d = {}; try { d = JSON.parse(e.data); } catch {} durMs = d.durationMs || 0; handleEvent("done", d); finish(); });
|
||||
es.addEventListener("error", (e) => { let d = null; try { d = JSON.parse(e.data); } catch {} if (d) handleEvent("error", d); else if (!finished && turn) { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `<div class="err-text">⚠ потеряна связь с гейтвеем</div>`); setStatus("off", "offline"); } finish(); });
|
||||
}
|
||||
|
||||
// ---- integration settings / ElevenLabs -------------------------------
|
||||
function openModal(id) { $(id).style.display = "grid"; }
|
||||
function closeModal(id) { $(id).style.display = "none"; }
|
||||
function setIntegrationState(id, connected, text) {
|
||||
const el = $(id); el.textContent = text; el.classList.toggle("connected", !!connected);
|
||||
}
|
||||
async function loadSettings() {
|
||||
const settings = await fetch("/api/settings").then((r) => r.json());
|
||||
const eleven = settings.elevenlabs || {}, codex = settings.codex || {};
|
||||
$("elevenApiKey").value = "";
|
||||
$("elevenApiKey").placeholder = eleven.apiKeyMasked || "xi-api-key";
|
||||
$("elevenKeyHint").textContent = eleven.configured ? `Connected as ${eleven.apiKeyMasked}. Leave blank to keep this key.` : "Enter a key to connect ElevenLabs.";
|
||||
$("elevenImportPath").value = eleven.importPath || "/Game/Audio/Generated";
|
||||
$("elevenVariations").value = String(eleven.variationCount || 3);
|
||||
$("elevenSoundModel").value = eleven.modelId || "eleven_text_to_sound_v2";
|
||||
$("elevenDefaultDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
|
||||
$("elevenDefaultInfluence").value = String(eleven.promptInfluence ?? 0.3);
|
||||
$("soundVariations").value = String(eleven.variationCount || 3);
|
||||
$("soundDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
|
||||
$("soundInfluence").value = String(Math.round((eleven.promptInfluence ?? 0.3) * 100));
|
||||
$("soundInfluenceValue").textContent = $("soundInfluence").value + "%";
|
||||
setSoundLoop(!!eleven.loop);
|
||||
$("textFixPrompt").value = settings.textTools?.improvePrompt || "";
|
||||
$("translateLanguages").value = (settings.textTools?.translateLanguages || []).join(", ");
|
||||
setIntegrationState("elevenState", eleven.configured, eleven.configured ? "Connected" : "Not configured");
|
||||
setIntegrationState("codexState", codex.connected, codex.connected ? "OAuth connected" : "Not connected");
|
||||
$("codexLogin").textContent = codex.connected ? "Re-authorize OAuth" : "Authorize with OAuth";
|
||||
return settings;
|
||||
}
|
||||
async function openSettings() {
|
||||
$("userPop").style.display = "none"; $("userRow").classList.remove("open");
|
||||
openModal("settingsModal");
|
||||
try { await loadSettings(); } catch { toast("Could not load settings"); }
|
||||
}
|
||||
async function saveSettings() {
|
||||
const key = $("elevenApiKey").value.trim();
|
||||
const elevenlabs = {
|
||||
importPath: $("elevenImportPath").value.trim(),
|
||||
variationCount: +$("elevenVariations").value,
|
||||
modelId: $("elevenSoundModel").value,
|
||||
durationSeconds: $("elevenDefaultDuration").value || null,
|
||||
promptInfluence: +$("elevenDefaultInfluence").value,
|
||||
};
|
||||
const textTools = { improvePrompt: $("textFixPrompt").value.trim(), translateLanguages: $("translateLanguages").value.split(",").map((x) => x.trim()).filter(Boolean) };
|
||||
if (key) elevenlabs.apiKey = key;
|
||||
const btn = $("saveSettings"); btn.disabled = true;
|
||||
try {
|
||||
const result = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ elevenlabs, textTools }) }).then((r) => r.json());
|
||||
if (!result.ok) throw new Error(result.error || "Could not save settings");
|
||||
closeModal("settingsModal"); toast("Integration settings saved");
|
||||
} catch (e) { toast(e.message || "Could not save settings"); }
|
||||
finally { btn.disabled = false; }
|
||||
}
|
||||
async function generateSounds(promptOverride) {
|
||||
const prompt = (typeof promptOverride === "string" ? promptOverride : $("soundPrompt").value).trim();
|
||||
if (!prompt) { toast("Describe the sound first"); return; }
|
||||
const btn = $("generateSound"); btn.disabled = true; btn.textContent = "Generating...";
|
||||
closeModal("soundModal");
|
||||
if (!activeChat()) newChat();
|
||||
const chat = activeChat();
|
||||
if (!chat.msgs.length) { chat.title = `Sound: ${prompt}`.slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
|
||||
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
|
||||
addUserMessage(`/sound ${prompt}`);
|
||||
pushMsg({ role: "user", text: `/sound ${prompt}` }); saveChats();
|
||||
const shell = addAgentShell(), working = document.createElement("div");
|
||||
working.className = "sound-working"; working.innerHTML = `<span class="console-spin"></span><span>Generating ElevenLabs variations...</span>`;
|
||||
shell.body.appendChild(working); scrollDown();
|
||||
try {
|
||||
const result = await fetch("/api/elevenlabs/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
|
||||
prompt,
|
||||
loop: $("soundLoop").classList.contains("on"),
|
||||
durationSeconds: $("soundDuration").value || null,
|
||||
promptInfluence: +$("soundInfluence").value / 100,
|
||||
variationCount: +$("soundVariations").value,
|
||||
modelId: "eleven_text_to_sound_v2",
|
||||
}) }).then((r) => r.json());
|
||||
if (!result.ok) throw new Error(result.error || "Sound generation failed");
|
||||
shell.el.remove();
|
||||
addAudioMessage(result.prompt, result.sounds);
|
||||
toast(`${result.sounds.length} sound variations ready`);
|
||||
} catch (e) {
|
||||
working.innerHTML = `<span class="err-text">${esc(e.message || "Sound generation failed")}</span>`;
|
||||
toast("Sound generation failed");
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = "Generate variations";
|
||||
}
|
||||
}
|
||||
async function runDraftTool(endpoint, payload, button, workingText) {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) { toast("Write a draft first"); inputEl.focus(); return; }
|
||||
const original = button.innerHTML; button.disabled = true; button.textContent = workingText;
|
||||
try {
|
||||
const result = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, ...payload }) }).then((r) => r.json());
|
||||
if (!result.ok) throw new Error(result.error || "Text tool failed");
|
||||
inputEl.value = result.text; autoGrow(); inputEl.focus(); toast("Draft updated");
|
||||
} catch (e) { toast(e.message || "Text tool failed"); }
|
||||
finally { button.disabled = false; button.innerHTML = original; }
|
||||
}
|
||||
function setSoundLoop(on) {
|
||||
$("soundLoop").classList.toggle("on", on);
|
||||
$("soundLoop").setAttribute("aria-pressed", on ? "true" : "false");
|
||||
$("soundLoop").querySelector("strong").textContent = on ? "On" : "Off";
|
||||
}
|
||||
|
||||
// ---- asset chip clicks -------------------------------------------------
|
||||
threadEl.addEventListener("click", (e) => {
|
||||
const playBtn = e.target.closest("[data-player-play]");
|
||||
if (playBtn) {
|
||||
const card = playBtn.closest("[data-audio-card]"), audio = card.querySelector("[data-player-audio]");
|
||||
if (audio.paused) { stopOtherPlayers(audio); audio.play().catch(() => toast("Could not play this sound")); } else audio.pause();
|
||||
syncPlayer(card);
|
||||
return;
|
||||
}
|
||||
const importBtn = e.target.closest("[data-import-audio]");
|
||||
if (importBtn) {
|
||||
if (importBtn.classList.contains("done")) return;
|
||||
importBtn.disabled = true; importBtn.textContent = "Importing...";
|
||||
fetch("/api/elevenlabs/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: importBtn.getAttribute("data-import-audio") }) })
|
||||
.then((r) => r.json()).then((result) => {
|
||||
if (!result.ok) throw new Error(result.error || "Import failed");
|
||||
importBtn.classList.add("done"); importBtn.textContent = "Imported";
|
||||
const sound = chats.flatMap((c) => c.msgs || []).filter((m) => m.role === "audio").flatMap((m) => m.sounds || []).find((s) => s.id === importBtn.getAttribute("data-import-audio"));
|
||||
if (sound) { sound.imported = true; sound.assetPath = result.paths && result.paths[0]; saveChats(); }
|
||||
toast(result.paths && result.paths[0] ? `Imported: ${result.paths[0]}` : "Sound imported");
|
||||
}).catch((err) => { importBtn.disabled = false; importBtn.innerHTML = `${I.download} Import`; toast(err.message || "Import failed"); });
|
||||
return;
|
||||
}
|
||||
const chip = e.target.closest("[data-asset]"); if (!chip) return;
|
||||
const path = chip.getAttribute("data-asset"), copy = chip.getAttribute("data-copy") || path;
|
||||
if (copy) { try { navigator.clipboard.writeText(copy); } catch {} }
|
||||
if (path && path.startsWith("/Game/")) {
|
||||
fetch("/api/focus", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }) })
|
||||
.then((r) => r.json()).then((res) => toast(res.ok ? "→ выделено в Content Browser" : "путь скопирован")).catch(() => toast("путь скопирован"));
|
||||
} else if (copy) toast("скопировано: " + copy);
|
||||
});
|
||||
threadEl.addEventListener("input", (e) => {
|
||||
const card = e.target.closest("[data-audio-card]"); if (!card) return;
|
||||
const audio = card.querySelector("[data-player-audio]");
|
||||
if (e.target.matches("[data-player-progress]") && audio.duration) audio.currentTime = (+e.target.value / 1000) * audio.duration;
|
||||
if (e.target.matches("[data-player-volume]")) audio.volume = +e.target.value / 100;
|
||||
syncPlayer(card);
|
||||
});
|
||||
["loadedmetadata", "timeupdate", "play", "pause", "ended"].forEach((name) => threadEl.addEventListener(name, (e) => {
|
||||
if (!e.target.matches?.("[data-player-audio]")) return;
|
||||
syncPlayer(e.target.closest("[data-audio-card]"));
|
||||
}, true));
|
||||
|
||||
// ---- sidebar clicks ----------------------------------------------------
|
||||
$("sbScroll").addEventListener("click", (e) => {
|
||||
const del = e.target.closest("[data-del]"); if (del) { e.stopPropagation(); deleteChat(del.getAttribute("data-del")); return; }
|
||||
const item = e.target.closest("[data-chat]"); if (item) switchChat(item.getAttribute("data-chat"));
|
||||
});
|
||||
|
||||
// ---- model popover -----------------------------------------------------
|
||||
function toggleModelPop() {
|
||||
if (modelPop.style.display === "block") { modelPop.style.display = "none"; return; }
|
||||
modelPop.innerHTML = `<div class="pop-label">Модель</div>` + MODELS.map((m) =>
|
||||
`<div class="pop-item ${m.disabled ? "disabled" : ""}" data-model="${m.id}" data-disabled="${m.disabled ? "1" : ""}"><span class="m-glyph" style="width:18px;height:18px">${I.spark}</span><div class="pi-main"><div class="pi-name">${esc(m.name)}</div><div class="pi-desc">${esc(m.tag || m.id)}</div></div>${m.id === model ? `<span class="pi-check">${I.check}</span>` : ""}</div>`).join("");
|
||||
modelPop.style.display = "block";
|
||||
}
|
||||
modelPop.addEventListener("click", (e) => {
|
||||
const it = e.target.closest("[data-model]"); if (!it) return;
|
||||
if (it.getAttribute("data-disabled")) { toast("Codex OAuth is connected, but Forge needs a Codex runtime transport before it can use this model"); return; }
|
||||
const id = it.getAttribute("data-model"); modelPop.style.display = "none";
|
||||
if (id === model) return;
|
||||
model = id; localStorage.setItem("mcp_model", model);
|
||||
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
|
||||
const c = activeChat();
|
||||
if (c && c.msgs.length) { toast("Модель: " + (mm ? mm.name : model) + " — новый чат"); newChat(); }
|
||||
else { if (c) c.model = model; toast("Модель: " + (mm ? mm.name : model)); }
|
||||
});
|
||||
document.addEventListener("click", (e) => { if (!e.target.closest("#modelBtn") && !e.target.closest("#modelPop")) modelPop.style.display = "none"; });
|
||||
|
||||
// ---- @mention autocomplete --------------------------------------------
|
||||
let menItems = [], menHl = 0, menTimer = null, menActive = false;
|
||||
function currentToken() {
|
||||
const pos = inputEl.selectionStart; const before = inputEl.value.slice(0, pos);
|
||||
const m = before.match(/@([^\s@]*)$/); return m ? { q: m[1], start: pos - m[0].length, end: pos } : null;
|
||||
}
|
||||
function openMention(q) {
|
||||
menActive = true;
|
||||
clearTimeout(menTimer);
|
||||
menTimer = setTimeout(() => {
|
||||
fetch("/api/assets?q=" + encodeURIComponent(q)).then((r) => r.json()).then((j) => {
|
||||
if (!menActive) return;
|
||||
menItems = j.assets || []; menHl = 0; renderMention();
|
||||
}).catch(() => {});
|
||||
}, 160);
|
||||
}
|
||||
function renderMention() {
|
||||
if (!menItems.length) { mentionPop.innerHTML = `<div class="mp-empty">ничего не найдено</div>`; mentionPop.style.display = "block"; return; }
|
||||
mentionPop.innerHTML = menItems.map((a, i) =>
|
||||
`<div class="mp-row ${i === menHl ? "hl" : ""}" data-i="${i}"><span class="mp-ico">${I.asset}</span><div class="mp-main"><div class="mp-name">${esc(a.name)} ${a.open ? '<span class="mp-open">OPEN</span>' : ""}</div><div class="mp-path">${esc(a.path)}</div></div><span class="mp-cls">${esc(a.class || "")}</span></div>`).join("");
|
||||
mentionPop.style.display = "block";
|
||||
}
|
||||
function closeMention() { menActive = false; mentionPop.style.display = "none"; menItems = []; }
|
||||
function acceptMention(i) {
|
||||
const a = menItems[i]; if (!a) return;
|
||||
const tok = currentToken(); if (!tok) { closeMention(); return; }
|
||||
const v = inputEl.value, ins = "@" + a.path + " ";
|
||||
inputEl.value = v.slice(0, tok.start) + ins + v.slice(tok.end);
|
||||
const caret = tok.start + ins.length; inputEl.setSelectionRange(caret, caret);
|
||||
closeMention(); autoGrow(); inputEl.focus();
|
||||
}
|
||||
mentionPop.addEventListener("click", (e) => { const r = e.target.closest("[data-i]"); if (r) acceptMention(+r.getAttribute("data-i")); });
|
||||
|
||||
// ---- attachments -------------------------------------------------------
|
||||
function renderAttachRow() {
|
||||
const row = $("attachRow");
|
||||
if (!pending.length) { row.style.display = "none"; row.innerHTML = ""; return; }
|
||||
row.style.display = "flex";
|
||||
row.innerHTML = pending.map((p, i) => `<div class="attach-thumb"><img src="${p.dataUrl}" /><span class="at-x" data-rm="${i}">${I.x}</span></div>`).join("");
|
||||
}
|
||||
$("attachRow").addEventListener("click", (e) => { const rm = e.target.closest("[data-rm]"); if (rm) { pending.splice(+rm.getAttribute("data-rm"), 1); renderAttachRow(); } });
|
||||
function addFile(file) {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => { pending.push({ dataUrl: reader.result, media: file.type, name: file.name || "image" }); renderAttachRow(); };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
$("attachBtn").addEventListener("click", () => $("fileInput").click());
|
||||
$("fileInput").addEventListener("change", (e) => { for (const f of e.target.files) addFile(f); e.target.value = ""; });
|
||||
inputEl.addEventListener("paste", (e) => { const items = (e.clipboardData || {}).items || []; for (const it of items) { if (it.kind === "file" && it.type.startsWith("image/")) { addFile(it.getAsFile()); e.preventDefault(); } } });
|
||||
// drag & drop onto composer
|
||||
const composer = $("composer");
|
||||
["dragover", "drop"].forEach((ev) => composer.addEventListener(ev, (e) => { e.preventDefault(); if (ev === "drop") for (const f of e.dataTransfer.files) addFile(f); }));
|
||||
|
||||
// ---- composer input ----------------------------------------------------
|
||||
function autoGrow() { inputEl.style.height = "auto"; inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px"; }
|
||||
inputEl.addEventListener("input", () => {
|
||||
autoGrow();
|
||||
const tok = currentToken();
|
||||
if (tok && tok.q.length >= 0 && !tok.q.startsWith("/")) openMention(tok.q);
|
||||
else if (tok && tok.q.startsWith("/")) openMention(tok.q);
|
||||
else closeMention();
|
||||
});
|
||||
inputEl.addEventListener("focus", () => composer.classList.add("focus"));
|
||||
inputEl.addEventListener("blur", () => { composer.classList.remove("focus"); setTimeout(closeMention, 150); });
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (mentionPop.style.display === "block" && menItems.length) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); menHl = (menHl + 1) % menItems.length; renderMention(); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); menHl = (menHl - 1 + menItems.length) % menItems.length; renderMention(); return; }
|
||||
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); acceptMention(menHl); return; }
|
||||
if (e.key === "Escape") { e.preventDefault(); closeMention(); return; }
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
$("mentionBtn").addEventListener("click", () => { const pos = inputEl.value.length; inputEl.value += (inputEl.value.endsWith(" ") || !inputEl.value ? "" : " ") + "@"; inputEl.focus(); autoGrow(); openMention(""); });
|
||||
sendEl.addEventListener("click", send);
|
||||
$("modelBtn").addEventListener("click", toggleModelPop);
|
||||
$("newChat").addEventListener("click", newChat);
|
||||
$("userRow").addEventListener("click", () => {
|
||||
const open = $("userPop").style.display === "block";
|
||||
$("userPop").style.display = open ? "none" : "block";
|
||||
$("userRow").classList.toggle("open", !open);
|
||||
});
|
||||
$("settingsBtn").addEventListener("click", openSettings);
|
||||
$("saveSettings").addEventListener("click", saveSettings);
|
||||
$("soundBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
if (!settings.elevenlabs.configured) { openSettings(); toast("Add your ElevenLabs API key first"); return; }
|
||||
openModal("soundModal"); $("soundPrompt").focus();
|
||||
} catch { openSettings(); }
|
||||
});
|
||||
$("generateSound").addEventListener("click", generateSounds);
|
||||
$("soundPrompt").addEventListener("input", () => { $("soundPromptCount").textContent = $("soundPrompt").value.length; });
|
||||
$("soundLoop").addEventListener("click", () => setSoundLoop(!$("soundLoop").classList.contains("on")));
|
||||
$("soundInfluence").addEventListener("input", () => { $("soundInfluenceValue").textContent = $("soundInfluence").value + "%"; });
|
||||
$("textFixBtn").addEventListener("click", () => runDraftTool("/api/text-tools/improve", {}, $("textFixBtn"), "Fixing..."));
|
||||
$("translateBtn").addEventListener("click", async () => {
|
||||
if (!inputEl.value.trim()) { toast("Write a draft first"); inputEl.focus(); return; }
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const languages = settings.textTools?.translateLanguages || [];
|
||||
$("translateLanguage").innerHTML = languages.map((language) => `<option>${esc(language)}</option>`).join("");
|
||||
openModal("translateModal");
|
||||
} catch { toast("Could not load translation languages"); }
|
||||
});
|
||||
$("translateDraft").addEventListener("click", async () => {
|
||||
closeModal("translateModal");
|
||||
await runDraftTool("/api/text-tools/translate", { language: $("translateLanguage").value }, $("translateBtn"), "Translating...");
|
||||
});
|
||||
$("codexLogin").addEventListener("click", async () => {
|
||||
const result = await fetch("/api/integrations/codex/login", { method: "POST" }).then((r) => r.json()).catch(() => ({ ok: false }));
|
||||
toast(result.ok ? "Codex OAuth login opened" : "Could not start Codex OAuth login");
|
||||
});
|
||||
document.querySelectorAll("[data-close-modal]").forEach((btn) => btn.addEventListener("click", () => closeModal(btn.getAttribute("data-close-modal"))));
|
||||
document.querySelectorAll(".modal-backdrop").forEach((backdrop) => backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeModal(backdrop.id); }));
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.target.closest("#userRow") && !e.target.closest("#userPop")) { $("userPop").style.display = "none"; $("userRow").classList.remove("open"); }
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") { e.preventDefault(); newChat(); } });
|
||||
|
||||
// ---- boot --------------------------------------------------------------
|
||||
mountIcons();
|
||||
loadChats();
|
||||
refreshHealth().then(() => pollTools(5));
|
||||
if (chats.length && activeId && activeChat()) { switchChat(activeId); } else if (chats.length) { switchChat(chats[0].id); } else { newChat(); }
|
||||
inputEl.focus();
|
||||
})();
|
||||
194
agent-gateway/public/index.html
Normal file
194
agent-gateway/public/index.html
Normal file
@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="eclipse">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Unreal Copilot</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="sidebar">
|
||||
<div class="sb-head">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" id="brandMark"></div>
|
||||
<div>
|
||||
<div class="brand-name">Unreal Copilot</div>
|
||||
<div class="brand-sub">ue-blueprint MCP</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn" id="historyBtn" title="История"></button>
|
||||
</div>
|
||||
|
||||
<button class="new-chat" id="newChat">
|
||||
<span id="newChatIcon"></span>
|
||||
<span>Новый чат</span>
|
||||
<span class="kbd">Ctrl N</span>
|
||||
</button>
|
||||
|
||||
<div class="sb-search">
|
||||
<span id="searchIcon"></span>
|
||||
<input id="search" placeholder="Поиск по чатам…" />
|
||||
</div>
|
||||
|
||||
<div class="sb-scroll" id="sbScroll"></div>
|
||||
|
||||
<div class="sb-foot">
|
||||
<div class="mcp-card">
|
||||
<div class="mcp-head" id="mcpHead">
|
||||
<span id="mcpIcon"></span>
|
||||
<span class="mcp-title">MCP Servers</span>
|
||||
<span class="mcp-count" id="mcpCount">…</span>
|
||||
</div>
|
||||
<div class="mcp-list" id="mcpList"></div>
|
||||
</div>
|
||||
<div class="user-row" id="userRow">
|
||||
<div class="avatar" id="userAvatar">EX</div>
|
||||
<div class="user-copy">
|
||||
<div class="user-name">Exbyte Studios</div>
|
||||
<div class="user-plan">Studio · Pro</div>
|
||||
</div>
|
||||
<span class="user-chev" id="userChev"></span>
|
||||
</div>
|
||||
<div class="user-pop pop" id="userPop" style="display:none">
|
||||
<div class="pop-label">Workspace</div>
|
||||
<div class="pop-item" id="settingsBtn"><span class="pop-ico" id="settingsIcon"></span><div class="pi-main"><div class="pi-name">Settings</div><div class="pi-desc">Integrations and import paths</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============ MAIN ============ -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="tb-titles">
|
||||
<div class="tb-title" id="convTitle">Новый разговор</div>
|
||||
<div class="tb-sub" id="convSub">ue-blueprint MCP</div>
|
||||
</div>
|
||||
<div class="tb-spacer"></div>
|
||||
<div class="status-pill" id="statusPill"><span class="dot off" id="statusDot"></span><span id="statusText">connecting…</span></div>
|
||||
<div style="position:relative">
|
||||
<button class="model-btn" id="modelBtn">
|
||||
<span class="m-glyph" id="modelGlyph"></span>
|
||||
<span id="modelName">agent</span>
|
||||
<span id="modelChev" style="color:var(--text-dim)"></span>
|
||||
</button>
|
||||
<div class="pop" id="modelPop" style="display:none; right:0; top:40px;"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="thread-wrap" id="threadWrap">
|
||||
<div class="thread" id="thread"></div>
|
||||
</div>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="composer-inner">
|
||||
<div class="attach-row" id="attachRow" style="display:none"></div>
|
||||
<div class="composer" id="composer">
|
||||
<textarea class="composer-field" id="input" rows="1" placeholder="Попроси Forge отредактировать Blueprint или @упомяни ассет…"></textarea>
|
||||
<div class="composer-bar">
|
||||
<button class="cb-btn" id="mentionBtn"><span id="mentionIcon"></span> Mention</button>
|
||||
<button class="cb-btn" id="attachBtn"><span id="attachIcon"></span> Attach</button>
|
||||
<button class="cb-btn" id="toolsBtn"><span id="toolsIcon"></span> Tools <span id="toolsCount" style="color:var(--text-dim)"></span></button>
|
||||
<button class="cb-btn" id="soundBtn"><span id="soundIcon"></span> Sound</button>
|
||||
<button class="cb-btn" id="textFixBtn"><span id="textFixIcon"></span> Text Fix</button>
|
||||
<button class="cb-btn" id="translateBtn"><span id="translateIcon"></span> Translate</button>
|
||||
<span class="cb-spacer"></span>
|
||||
<button class="send-btn" id="send" title="Отправить"><span id="sendIcon"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mention-pop pop" id="mentionPop" style="display:none"></div>
|
||||
<div class="composer-hint">Forge правит Blueprints и C++ через <strong style="color:var(--text-muted);font-weight:600">ue-blueprint</strong> MCP · <span class="kbd">⏎</span> отправить · <span class="kbd">⇧⏎</span> перенос</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" />
|
||||
<div class="modal-backdrop" id="settingsModal" style="display:none">
|
||||
<div class="modal settings-modal">
|
||||
<div class="modal-head">
|
||||
<div><div class="modal-title">Settings</div><div class="modal-sub">Forge workspace configuration</div></div>
|
||||
<button class="modal-close" data-close-modal="settingsModal">x</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-nav">
|
||||
<button class="settings-nav-item active"><span id="integrationsIcon"></span> Integrations</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-kicker">Integrations</div>
|
||||
<h2>Connected services</h2>
|
||||
<p class="settings-intro">Secrets are stored locally beside the Forge gateway and never returned to the browser.</p>
|
||||
<section class="integration-card">
|
||||
<div class="integration-head">
|
||||
<div class="integration-logo eleven">XI</div>
|
||||
<div class="integration-meta"><h3>ElevenLabs</h3><p>Generate sound-effect variations and import selected WAV assets into Unreal.</p></div>
|
||||
<span class="integration-state" id="elevenState">Not configured</span>
|
||||
</div>
|
||||
<label class="field"><span>API key</span><input id="elevenApiKey" type="password" autocomplete="off" placeholder="xi-api-key" /><small id="elevenKeyHint">Enter a key to connect ElevenLabs.</small></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Unreal import folder</span><input id="elevenImportPath" value="/Game/Audio/Generated" /></label>
|
||||
<label class="field"><span>Variations per prompt</span><select id="elevenVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
|
||||
</div>
|
||||
<label class="field"><span>Sound Effects model</span><select id="elevenSoundModel"><option value="eleven_text_to_sound_v2">Sound Effects v2 · eleven_text_to_sound_v2</option></select><small>Current ElevenLabs flagship sound-effects model. Looping is supported by v2.</small></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Default duration</span><select id="elevenDefaultDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
|
||||
<label class="field"><span>Default prompt influence</span><select id="elevenDefaultInfluence"><option value="0.2">20% · more variety</option><option value="0.3">30% · balanced</option><option value="0.5">50% · closer prompt</option><option value="0.7">70% · strict prompt</option><option value="0.9">90% · very strict</option></select></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="integration-card">
|
||||
<div class="integration-head">
|
||||
<div class="integration-logo codex"></></div>
|
||||
<div class="integration-meta"><h3>Codex</h3><p>Authorize Codex locally through OAuth. Forge only reads the connection state.</p></div>
|
||||
<span class="integration-state" id="codexState">Checking...</span>
|
||||
</div>
|
||||
<div class="integration-actions"><button class="secondary-btn" id="codexLogin">Authorize with OAuth</button><span class="integration-note">OAuth credentials stay in <code>~/.codex/auth.json</code>.</span></div>
|
||||
</section>
|
||||
<section class="integration-card">
|
||||
<div class="integration-head">
|
||||
<div class="integration-logo textfix">Aa</div>
|
||||
<div class="integration-meta"><h3>Text Fix Improver</h3><p>Improve composer drafts with your own editing instruction.</p></div>
|
||||
<span class="integration-state connected">Enabled</span>
|
||||
</div>
|
||||
<label class="field"><span>Improvement prompt</span><textarea id="textFixPrompt" rows="4" maxlength="2000"></textarea><small>Forge returns only the improved text and replaces the current draft.</small></label>
|
||||
<label class="field"><span>Translate languages</span><input id="translateLanguages" placeholder="English, Russian, German" /><small>Comma-separated languages shown in the Translate dialog.</small></label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="secondary-btn" data-close-modal="settingsModal">Cancel</button><button class="primary-btn" id="saveSettings">Save changes</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="soundModal" style="display:none">
|
||||
<div class="modal sound-modal">
|
||||
<div class="modal-head">
|
||||
<div><div class="modal-title">Generate sound effects</div><div class="modal-sub">ElevenLabs creates multiple variations for review in this chat.</div></div>
|
||||
<button class="modal-close" data-close-modal="soundModal">x</button>
|
||||
</div>
|
||||
<div class="sound-modal-body">
|
||||
<label class="field"><span>Sound prompt</span><textarea id="soundPrompt" rows="5" maxlength="450" placeholder="Heavy metal door slamming shut in a damp industrial corridor, horror game one-shot"></textarea><small><span id="soundPromptCount">0</span>/450 characters</small></label>
|
||||
<div class="sound-options">
|
||||
<label class="sound-option"><span class="sound-option-label">Loop</span><button class="toggle-btn" id="soundLoop" type="button" aria-pressed="false"><span class="toggle-track"><span class="toggle-knob"></span></span><strong>Off</strong></button></label>
|
||||
<label class="sound-option"><span class="sound-option-label">Duration</span><select id="soundDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
|
||||
<label class="sound-option influence"><span class="sound-option-label">Prompt influence</span><div class="range-row"><input id="soundInfluence" type="range" min="0" max="100" value="30" step="5" /><strong id="soundInfluenceValue">30%</strong></div></label>
|
||||
<label class="sound-option compact"><span class="sound-option-label">Variations</span><select id="soundVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="secondary-btn" data-close-modal="soundModal">Cancel</button><button class="primary-btn" id="generateSound">Generate variations</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="translateModal" style="display:none">
|
||||
<div class="modal translate-modal">
|
||||
<div class="modal-head">
|
||||
<div><div class="modal-title">Translate draft</div><div class="modal-sub">Choose the language for the current composer text.</div></div>
|
||||
<button class="modal-close" data-close-modal="translateModal">x</button>
|
||||
</div>
|
||||
<div class="sound-modal-body">
|
||||
<label class="field"><span>Target language</span><select id="translateLanguage"></select></label>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="secondary-btn" data-close-modal="translateModal">Cancel</button><button class="primary-btn" id="translateDraft">Translate</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
369
agent-gateway/public/styles.css
Normal file
369
agent-gateway/public/styles.css
Normal file
@ -0,0 +1,369 @@
|
||||
/* ============================================================
|
||||
In-engine agent — "Forge / Unreal Copilot" eclipse theme.
|
||||
Adapted for UE's CEF WebBrowser: no backdrop-filter / color-mix /
|
||||
oklch (CPU-composited, possibly older Chromium) — explicit colors only.
|
||||
All visual tokens live in :root.
|
||||
No web-font @import — UE's CEF may have no/slow internet and a render-blocking
|
||||
font fetch stalls the whole UI. System fonts only.
|
||||
============================================================ */
|
||||
:root {
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", "Cascadia Mono", Consolas, monospace;
|
||||
|
||||
--radius-sm: 7px;
|
||||
--radius: 11px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
--accent: #7C6BD6;
|
||||
--accent-2: #9D7BFF;
|
||||
--accent-soft: rgba(124,107,214,0.16);
|
||||
--accent-line: rgba(124,107,214,0.40);
|
||||
|
||||
--bg: #0a0a0c;
|
||||
--bg-grad: radial-gradient(120% 90% at 80% -10%, #15131c 0%, #0a0a0c 55%);
|
||||
--bg-elev: #0d0d10;
|
||||
--surface: #141418;
|
||||
--surface-2: #1a1a20;
|
||||
--surface-3: #212129;
|
||||
--surface-trans: rgba(20,20,24,0.70);
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border-strong: rgba(255,255,255,0.12);
|
||||
--text: #ECECF0;
|
||||
--text-muted: #9a9aa6;
|
||||
--text-dim: #62626e;
|
||||
--ok: #4ec98f;
|
||||
--warn: #e6b450;
|
||||
--err: #ff7a7a;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 28px -12px rgba(0,0,0,0.55);
|
||||
--shadow-pop: 0 18px 50px -16px rgba(0,0,0,0.7);
|
||||
|
||||
--sidebar-w: 276px;
|
||||
--ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body { overflow: hidden; }
|
||||
::selection { background: var(--accent-soft); color: var(--text); }
|
||||
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
input, textarea { font-family: inherit; }
|
||||
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; border: 3px solid transparent; background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.app {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
background: var(--bg-grad), var(--bg);
|
||||
}
|
||||
|
||||
/* ---------------- SIDEBAR ---------------- */
|
||||
.sidebar { background: var(--bg-elev); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; height: 100vh; }
|
||||
.sb-head { padding: 16px 16px 12px; display: flex; align-items: center; gap: 10px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||
.brand-mark { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center;
|
||||
background: linear-gradient(150deg, var(--accent) 0%, var(--accent-2) 100%); color: #fff; }
|
||||
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
|
||||
.brand-sub { font-size: 11px; color: var(--text-dim); margin-top: -1px; letter-spacing: 0.02em; }
|
||||
.icon-btn { width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; color: var(--text-muted); transition: background .15s, color .15s; }
|
||||
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
.new-chat { margin: 4px 12px 8px; padding: 9px 12px; display: flex; align-items: center; gap: 9px;
|
||||
border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 500; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, background .15s, transform .08s; }
|
||||
.new-chat:hover { border-color: var(--accent-line); background: var(--surface-2); }
|
||||
.new-chat:active { transform: translateY(1px); }
|
||||
.new-chat .kbd { margin-left: auto; }
|
||||
.kbd { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; background: var(--bg); }
|
||||
|
||||
.sb-search { margin: 2px 12px 8px; display: flex; align-items: center; gap: 8px; padding: 7px 10px;
|
||||
border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--border); color: var(--text-dim); }
|
||||
.sb-search input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 13px; }
|
||||
.sb-search input::placeholder { color: var(--text-dim); }
|
||||
|
||||
.sb-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
|
||||
.sb-group-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); padding: 14px 8px 5px; }
|
||||
.chat-item { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer;
|
||||
color: var(--text-muted); font-size: 13px; position: relative; transition: background .13s, color .13s; }
|
||||
.chat-item:hover { background: var(--surface); color: var(--text); }
|
||||
.chat-item.active { background: var(--surface-2); color: var(--text); }
|
||||
.chat-item.active::before { content: ''; position: absolute; left: -8px; top: 50%; transform: translateY(-50%); width: 3px; height: 16px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
||||
.chat-item .ci-icon { color: var(--text-dim); flex-shrink: 0; display: grid; place-items: center; }
|
||||
.chat-item.active .ci-icon { color: var(--accent-2); }
|
||||
.chat-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.sb-foot { border-top: 1px solid var(--border); padding: 8px; }
|
||||
.mcp-card { border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); overflow: hidden; }
|
||||
.mcp-head { display: flex; align-items: center; gap: 9px; padding: 10px 11px; cursor: pointer; transition: background .13s; }
|
||||
.mcp-head:hover { background: var(--surface-2); }
|
||||
.mcp-title { font-size: 12.5px; font-weight: 600; flex: 1; }
|
||||
.mcp-count { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
|
||||
.mcp-list { border-top: 1px solid var(--border); padding: 4px; }
|
||||
.mcp-srv { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12.5px; }
|
||||
.mcp-srv:hover { background: var(--surface-2); }
|
||||
.mcp-srv .srv-name { flex: 1; }
|
||||
.mcp-srv .srv-tools { font-size: 10.5px; color: var(--text-dim); font-family: var(--font-mono); }
|
||||
.srv-self { font-size: 9.5px; font-weight: 600; letter-spacing: .04em; color: var(--accent-2); border: 1px solid var(--accent-line); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px rgba(78,201,143,0.22); }
|
||||
.dot.off { background: var(--text-dim); }
|
||||
.dot.warn{ background: var(--warn); }
|
||||
|
||||
.user-row { display: flex; align-items: center; gap: 10px; padding: 9px 6px 4px; border-radius: var(--radius-sm); cursor: pointer; transition: background .13s; }
|
||||
.user-row:hover { background: var(--surface-2); }
|
||||
.user-copy { flex: 1; min-width: 0; }
|
||||
.user-chev { display: grid; place-items: center; color: var(--text-dim); transition: transform .16s; }
|
||||
.user-row.open .user-chev { transform: rotate(180deg); }
|
||||
.sb-foot { position: relative; }
|
||||
.user-pop { left: 8px; right: 8px; bottom: 58px; min-width: 0; }
|
||||
.avatar { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg,#5b6cff,#c45bff); display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 600; }
|
||||
.user-name { font-size: 12.5px; font-weight: 500; }
|
||||
.user-plan { font-size: 10.5px; color: var(--text-dim); }
|
||||
|
||||
/* ---------------- MAIN / TOPBAR ---------------- */
|
||||
.main { display: flex; flex-direction: column; min-width: 0; height: 100vh; }
|
||||
.topbar { height: 54px; flex-shrink: 0; display: flex; align-items: center; gap: 12px; padding: 0 16px 0 18px; border-bottom: 1px solid var(--border); background: var(--bg-elev); }
|
||||
.tb-titles { min-width: 0; }
|
||||
.tb-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 52vw; }
|
||||
.tb-sub { font-size: 11.5px; color: var(--text-dim); }
|
||||
.tb-spacer { flex: 1; }
|
||||
.model-btn { display: flex; align-items: center; gap: 8px; padding: 6px 9px 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface); font-size: 12.5px; font-weight: 500; color: var(--text); transition: border-color .15s; }
|
||||
.model-btn:hover { border-color: var(--accent-line); }
|
||||
.model-btn .m-glyph { width: 16px; height: 16px; border-radius: 5px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); }
|
||||
.status-pill { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
|
||||
|
||||
/* ---------------- THREAD ---------------- */
|
||||
.thread-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.thread { max-width: 820px; margin: 0 auto; padding: 28px 28px 40px; display: flex; flex-direction: column; gap: 26px; }
|
||||
|
||||
.msg { display: flex; gap: 14px; }
|
||||
.msg-avatar { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center; }
|
||||
.msg-avatar.user { background: linear-gradient(135deg,#5b6cff,#c45bff); color: #fff; font-size: 12px; font-weight: 600; }
|
||||
.msg-avatar.ai { background: linear-gradient(150deg, var(--accent), var(--accent-2)); color: #fff; }
|
||||
.msg-body { flex: 1; min-width: 0; padding-top: 3px; }
|
||||
.msg-name { font-size: 12.5px; font-weight: 600; margin-bottom: 7px; display: flex; align-items: center; gap: 8px; }
|
||||
.msg-name .ts { font-weight: 400; color: var(--text-dim); font-size: 11px; }
|
||||
.msg-text { font-size: 14.5px; line-height: 1.62; color: var(--text); }
|
||||
.msg-text p { margin: 0 0 12px; }
|
||||
.msg-text p:last-child { margin-bottom: 0; }
|
||||
.msg-text strong { font-weight: 600; }
|
||||
.msg-text code { font-family: var(--font-mono); font-size: 12.5px; background: var(--surface-2); padding: 2px 6px; border-radius: 5px; border: 1px solid var(--border); }
|
||||
.msg-text pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 11px 13px; overflow-x: auto; margin: 10px 0; }
|
||||
.msg-text pre code { background: none; border: none; padding: 0; }
|
||||
.msg.user-msg .msg-text { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 15px; }
|
||||
|
||||
/* inline chips */
|
||||
.mention { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline; font-family: var(--font-mono); font-size: 12.5px; font-weight: 500;
|
||||
color: var(--accent-2); background: var(--accent-soft); border: 1px solid var(--accent-line); border-radius: var(--radius-sm); padding: 1px 7px 1px 5px; cursor: pointer; transition: filter .13s; }
|
||||
.mention:hover { filter: brightness(1.18); }
|
||||
.objlink { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-family: var(--font-mono); font-size: 0.92em; font-weight: 500;
|
||||
color: var(--text); border-bottom: 1px dashed var(--border-strong); padding-bottom: 1px; transition: color .13s, border-color .13s; }
|
||||
.objlink:hover { color: var(--accent-2); border-color: var(--accent-line); }
|
||||
.objlink .ol-ico { color: var(--accent-2); display: inline-grid; place-items: center; }
|
||||
|
||||
/* ---------------- CONSOLE (agent process) ---------------- */
|
||||
.console { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-trans); overflow: hidden; margin-bottom: 16px; }
|
||||
.console-head { display: flex; align-items: center; gap: 10px; padding: 10px 13px; cursor: pointer; user-select: none; transition: background .13s; }
|
||||
.console-head:hover { background: var(--surface-2); }
|
||||
.console-spin { width: 15px; height: 15px; flex-shrink: 0; border: 2px solid var(--border-strong); border-top-color: var(--accent-2); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.console-check { width: 15px; height: 15px; flex-shrink: 0; color: var(--ok); display: grid; place-items: center; }
|
||||
.console-title { font-size: 12.5px; font-weight: 600; flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.console-meta { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
|
||||
.console-chev { color: var(--text-dim); transition: transform .2s var(--ease); display: grid; place-items: center; }
|
||||
.console.collapsed .console-chev { transform: rotate(-90deg); }
|
||||
.console-body { border-top: 1px solid var(--border); display: grid; grid-template-rows: 1fr; transition: grid-template-rows .28s var(--ease); }
|
||||
.console.collapsed .console-body { grid-template-rows: 0fr; border-top-color: transparent; }
|
||||
.console-inner { overflow: hidden; padding: 6px 0; }
|
||||
|
||||
.step { position: relative; padding: 3px 14px 3px 38px; }
|
||||
.step::before { content: ''; position: absolute; left: 21px; top: 0; bottom: 0; width: 1.5px; background: var(--border); }
|
||||
.step:first-child::before { top: 13px; }
|
||||
.step:last-child::before { bottom: calc(100% - 14px); }
|
||||
.step-node { position: absolute; left: 14px; top: 8px; width: 15px; height: 15px; border-radius: 50%; background: var(--bg-elev); border: 1.5px solid var(--border-strong); display: grid; place-items: center; z-index: 1; color: var(--text-muted); }
|
||||
.step.done .step-node { border-color: var(--accent-line); color: var(--accent-2); background: var(--accent-soft); }
|
||||
.step.run .step-node { border-color: var(--accent); color: var(--accent-2); }
|
||||
.step.err .step-node { border-color: var(--err); color: var(--err); }
|
||||
.step-main { padding: 4px 0; }
|
||||
.step-row { display: flex; align-items: center; gap: 9px; min-height: 24px; }
|
||||
.step-verb { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.step-verb .v { color: var(--text-muted); }
|
||||
.step-tool { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
|
||||
.step-dur { margin-left: auto; font-size: 10.5px; font-family: var(--font-mono); color: var(--text-dim); }
|
||||
.step-toggle { font-size: 11px; color: var(--text-dim); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
.step-toggle:hover { color: var(--accent-2); }
|
||||
|
||||
.think { font-size: 12.5px; line-height: 1.6; color: var(--text-muted); font-style: italic; padding: 8px 0 4px; max-width: 62ch; white-space: pre-wrap; }
|
||||
|
||||
.tool-detail { margin: 8px 0 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; }
|
||||
.td-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-dim); font-size: 10.5px; letter-spacing: .03em; }
|
||||
.td-body { padding: 8px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; color: var(--text-muted); }
|
||||
|
||||
.collapsible { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .26s var(--ease); }
|
||||
.collapsible.open { grid-template-rows: 1fr; }
|
||||
.collapsible > div { overflow: hidden; }
|
||||
|
||||
.streaming-caret { display: inline-block; width: 7px; height: 15px; background: var(--accent-2); margin-left: 2px; vertical-align: text-bottom; animation: blink 1s steps(2) infinite; border-radius: 1px; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ---------------- COMPOSER ---------------- */
|
||||
.composer-wrap { flex-shrink: 0; padding: 0 28px 18px; }
|
||||
.composer-inner { max-width: 820px; margin: 0 auto; position: relative; }
|
||||
.composer { border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); transition: border-color .18s, box-shadow .18s; }
|
||||
.composer.focus { border-color: var(--accent-line); box-shadow: var(--shadow), 0 0 0 3px var(--accent-soft); }
|
||||
.composer-field { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font-size: 14.5px; line-height: 1.55; padding: 14px 16px 6px; max-height: 200px; min-height: 26px; }
|
||||
.composer-field::placeholder { color: var(--text-dim); }
|
||||
.composer-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px 9px; }
|
||||
.cb-btn { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: var(--radius-sm); font-size: 12.5px; color: var(--text-muted); transition: background .13s, color .13s; }
|
||||
.cb-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.cb-spacer { flex: 1; }
|
||||
.send-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); display: grid; place-items: center; background: var(--accent); color: #fff; transition: filter .13s, transform .08s, opacity .13s; }
|
||||
.send-btn:hover { filter: brightness(1.1); }
|
||||
.send-btn:active { transform: scale(.94); }
|
||||
.send-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.composer-hint { text-align: center; font-size: 11px; color: var(--text-dim); margin-top: 9px; }
|
||||
.composer-hint .kbd { font-size: 10px; }
|
||||
|
||||
/* empty state */
|
||||
.empty-state { flex: 1; display: grid; place-items: center; text-align: center; padding: 40px; }
|
||||
.empty-state .es-icon { width: 56px; height: 56px; border-radius: 16px; margin: 0 auto 18px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border); color: var(--accent-2); }
|
||||
.empty-state h2 { font-size: 19px; font-weight: 600; margin: 0 0 8px; letter-spacing: -0.01em; }
|
||||
.empty-state p { font-size: 13.5px; color: var(--text-muted); max-width: 380px; margin: 0 auto; line-height: 1.55; }
|
||||
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 24px; max-width: 460px; }
|
||||
.suggest { text-align: left; padding: 12px 14px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, transform .08s; }
|
||||
.suggest:hover { border-color: var(--accent-line); transform: translateY(-1px); }
|
||||
.suggest .sg-title { font-size: 12.5px; font-weight: 500; margin-bottom: 3px; }
|
||||
.suggest .sg-desc { font-size: 11.5px; color: var(--text-dim); line-height: 1.4; }
|
||||
|
||||
/* toast */
|
||||
.toast { position: fixed; bottom: 92px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--surface-2); border: 1px solid var(--border-strong); color: var(--text); padding: 8px 15px; border-radius: 9px; font-size: 12.5px; z-index: 80; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; box-shadow: var(--shadow-pop); }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.err-text { color: var(--err); }
|
||||
|
||||
/* ---------------- POPOVERS (model menu, mention) ---------------- */
|
||||
.pop { position: absolute; z-index: 70; min-width: 240px; background: var(--bg-elev); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-pop); padding: 6px; }
|
||||
.pop-label { font-size: 10.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-dim); padding: 8px 9px 4px; }
|
||||
.pop-item { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
|
||||
.pop-item:hover, .pop-item.hl { background: var(--surface-2); }
|
||||
.pop-item.disabled { cursor: not-allowed; opacity: .58; }
|
||||
.pop-item.disabled:hover { background: transparent; }
|
||||
.pop-item .pi-main { flex: 1; min-width: 0; }
|
||||
.pop-item .pi-name { font-weight: 500; display: flex; align-items: center; gap: 7px; }
|
||||
.pop-item .pi-desc { font-size: 11px; color: var(--text-dim); margin-top: 1px; }
|
||||
.pop-item .pi-check { color: var(--accent-2); display: grid; place-items: center; }
|
||||
|
||||
/* mention autocomplete */
|
||||
.mention-pop { left: 12px; bottom: calc(100% + 8px); width: 360px; max-height: 320px; overflow-y: auto; }
|
||||
.mp-row { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.mp-row:hover, .mp-row.hl { background: var(--surface-2); }
|
||||
.mp-ico { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); flex-shrink: 0; }
|
||||
.mp-main { flex: 1; min-width: 0; }
|
||||
.mp-name { font-size: 13px; font-weight: 500; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mp-path { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mp-cls { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
|
||||
.mp-open { font-size: 9px; font-weight: 600; color: var(--ok); border: 1px solid rgba(78,201,143,.4); border-radius: 4px; padding: 1px 5px; }
|
||||
.mp-empty { padding: 14px; text-align: center; color: var(--text-dim); font-size: 12.5px; }
|
||||
|
||||
/* attachments */
|
||||
.attach-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.attach-thumb { position: relative; width: 56px; height: 56px; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border-strong); background: var(--surface-2); }
|
||||
.attach-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.attach-thumb .at-x { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; border-radius: 5px; background: rgba(0,0,0,.6); color: #fff; display: grid; place-items: center; }
|
||||
.attach-thumb .at-x:hover { background: var(--err); }
|
||||
.msg-images { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.msg-images img { max-width: 220px; max-height: 180px; border-radius: var(--radius-sm); border: 1px solid var(--border); display: block; }
|
||||
|
||||
/* ---------------- SETTINGS / INTEGRATIONS ---------------- */
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 28px; background: rgba(0,0,0,.72); }
|
||||
.modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 56px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--bg-elev); box-shadow: var(--shadow-pop); }
|
||||
.sound-modal { width: min(580px, 100%); }
|
||||
.translate-modal { width: min(440px, 100%); }
|
||||
.modal-head, .modal-foot { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||||
.modal-foot { justify-content: flex-end; border-top: 1px solid var(--border); border-bottom: none; }
|
||||
.modal-title { font-size: 16px; font-weight: 650; }
|
||||
.modal-sub { margin-top: 3px; font-size: 11.5px; color: var(--text-dim); }
|
||||
.modal-close { margin-left: auto; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 18px; }
|
||||
.modal-close:hover { background: var(--surface-2); color: var(--text); }
|
||||
.settings-body { display: grid; grid-template-columns: 170px minmax(0,1fr); min-height: 0; overflow: hidden; }
|
||||
.settings-nav { padding: 14px 10px; border-right: 1px solid var(--border); background: var(--bg); }
|
||||
.settings-nav-item { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 10px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12.5px; text-align: left; }
|
||||
.settings-nav-item.active { color: var(--text); background: var(--surface-2); }
|
||||
.settings-content { padding: 22px; overflow-y: auto; }
|
||||
.settings-kicker { color: var(--accent-2); text-transform: uppercase; letter-spacing: .08em; font-size: 10px; font-weight: 700; }
|
||||
.settings-content h2 { margin: 5px 0 5px; font-size: 19px; }
|
||||
.settings-intro { margin: 0 0 18px; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; }
|
||||
.integration-card { padding: 15px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-top: 12px; }
|
||||
.integration-head { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
|
||||
.integration-logo { width: 34px; height: 34px; flex-shrink: 0; display: grid; place-items: center; border-radius: 9px; font-size: 11px; font-weight: 700; }
|
||||
.integration-logo.eleven { color: #050505; background: #f2f1ea; }
|
||||
.integration-logo.codex { color: #fff; background: linear-gradient(145deg,#353842,#15171b); border: 1px solid var(--border-strong); }
|
||||
.integration-logo.textfix { color: #fff; background: linear-gradient(145deg,#745fd5,#9b72ee); }
|
||||
.integration-meta { flex: 1; min-width: 0; }
|
||||
.integration-meta h3 { margin: 0 0 3px; font-size: 14px; }
|
||||
.integration-meta p { margin: 0; color: var(--text-dim); font-size: 11.5px; line-height: 1.45; }
|
||||
.integration-state { flex-shrink: 0; border: 1px solid var(--border); border-radius: 99px; padding: 3px 8px; color: var(--text-dim); font-size: 10.5px; }
|
||||
.integration-state.connected { color: var(--ok); border-color: rgba(78,201,143,.35); background: rgba(78,201,143,.08); }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 11px; color: var(--text-muted); font-size: 11.5px; }
|
||||
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); outline: none; padding: 9px 10px; color: var(--text); background: var(--bg); font-size: 12.5px; resize: vertical; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.field small, .integration-note { color: var(--text-dim); font-size: 10.5px; }
|
||||
.field-grid { display: grid; grid-template-columns: minmax(0,1fr) 150px; gap: 12px; }
|
||||
.integration-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.primary-btn, .secondary-btn { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; }
|
||||
.primary-btn { color: #fff; background: var(--accent); }
|
||||
.primary-btn:hover { filter: brightness(1.1); }
|
||||
.primary-btn:disabled { opacity: .45; cursor: wait; }
|
||||
.secondary-btn { color: var(--text-muted); border: 1px solid var(--border-strong); background: var(--surface); }
|
||||
.secondary-btn:hover { color: var(--text); border-color: var(--accent-line); }
|
||||
.sound-modal-body { padding: 18px; }
|
||||
.sound-options { display: grid; grid-template-columns: 110px 120px minmax(0,1fr) 88px; gap: 9px; margin-top: 15px; }
|
||||
.sound-option { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.sound-option-label { color: var(--text-dim); font-size: 10px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.sound-option select { min-width: 0; border: none; outline: none; color: var(--text); background: var(--surface); font-size: 12px; }
|
||||
.toggle-btn { display: flex; align-items: center; gap: 7px; width: fit-content; color: var(--text-muted); font-size: 12px; }
|
||||
.toggle-track { width: 29px; height: 16px; padding: 2px; border-radius: 99px; background: var(--surface-3); transition: background .16s; }
|
||||
.toggle-knob { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--text-muted); transition: transform .16s, background .16s; }
|
||||
.toggle-btn.on .toggle-track { background: var(--accent); }
|
||||
.toggle-btn.on .toggle-knob { transform: translateX(13px); background: #fff; }
|
||||
.range-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.range-row input { min-width: 0; flex: 1; accent-color: var(--accent-2); }
|
||||
.range-row strong, .toggle-btn strong { color: var(--text); font-size: 12px; }
|
||||
@media (max-width: 760px) { .sound-options { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
/* ---------------- GENERATED AUDIO ---------------- */
|
||||
.audio-set { margin-top: 10px; display: grid; gap: 9px; }
|
||||
.audio-prompt { margin-bottom: 10px; color: var(--text-muted); font-size: 12.5px; }
|
||||
.audio-card { padding: 11px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
||||
.audio-card-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
|
||||
.audio-badge { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 7px; color: var(--accent-2); background: var(--accent-soft); }
|
||||
.audio-name { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.audio-meta { color: var(--text-dim); font-size: 10.5px; }
|
||||
.forge-player { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 8px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
|
||||
.forge-player audio { display: none; }
|
||||
.player-play { width: 27px; height: 27px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; color: #fff; background: var(--accent); transition: filter .13s, transform .08s; }
|
||||
.player-play:hover { filter: brightness(1.15); }
|
||||
.player-play:active { transform: scale(.93); }
|
||||
.player-time { flex-shrink: 0; min-width: 30px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; text-align: center; }
|
||||
.player-progress, .player-volume { height: 4px; accent-color: var(--accent-2); cursor: pointer; }
|
||||
.player-progress { min-width: 70px; flex: 1; }
|
||||
.player-volume { width: 62px; flex-shrink: 0; }
|
||||
.player-volume-icon { display: grid; place-items: center; flex-shrink: 0; color: var(--text-dim); }
|
||||
.player-progress::-webkit-slider-runnable-track, .player-volume::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--surface-3); }
|
||||
.player-progress::-webkit-slider-thumb, .player-volume::-webkit-slider-thumb { width: 11px; height: 11px; margin-top: -3.5px; border-radius: 50%; appearance: none; background: var(--accent-2); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.import-btn { flex-shrink: 0; padding: 6px 9px; border-radius: 6px; color: var(--accent-2); border: 1px solid var(--accent-line); font-size: 11px; font-weight: 650; }
|
||||
.import-btn:hover { background: var(--accent-soft); }
|
||||
.import-btn:disabled { color: var(--text-dim); border-color: var(--border); cursor: wait; }
|
||||
.import-btn.done { color: var(--ok); border-color: rgba(78,201,143,.35); }
|
||||
.sound-working { display: flex; align-items: center; gap: 9px; color: var(--text-muted); font-size: 12.5px; }
|
||||
901
agent-gateway/server.mjs
Normal file
901
agent-gateway/server.mjs
Normal file
@ -0,0 +1,901 @@
|
||||
// In-engine agent gateway.
|
||||
//
|
||||
// What this is:
|
||||
// A tiny local HTTP server that (1) serves the chat UI loaded by the
|
||||
// UE WebBrowser widget and (2) runs a Claude agent via the Claude Agent SDK,
|
||||
// wired to the project's ue-blueprint MCP server so the in-engine chat has
|
||||
// the exact same UE tools Claude Code uses (edit BPs, spawn nodes, build
|
||||
// C++, screenshot UMG, ...).
|
||||
//
|
||||
// Transport to the browser: POST /api/chat with a JSON body, response is an
|
||||
// SSE stream of agent events (text deltas, tool start/end, done). The browser
|
||||
// keeps a sessionId returned on the first turn and replays it to continue the
|
||||
// conversation (SDK `resume`).
|
||||
//
|
||||
// Auth: uses whatever the Agent SDK finds locally (the same subscription
|
||||
// login Claude Code uses). No API key is read or required here.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { executePythonInUE } from "../src/ueBridge.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||
const INTEGRATIONS_FILE = path.join(__dirname, "integrations.json");
|
||||
const GENERATED_AUDIO_DIR = path.join(__dirname, ".generated-audio");
|
||||
const AUDIO_CACHE = new Map();
|
||||
|
||||
// Optional local auth file (gitignored). Lets you keep the agent's credentials
|
||||
// out of the global environment. One KEY=VALUE per line. Supported keys:
|
||||
// CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token` — subscription auth)
|
||||
// ANTHROPIC_API_KEY (pay-as-you-go API key)
|
||||
function loadAuthEnv() {
|
||||
const file = path.join(__dirname, "auth.env");
|
||||
if (!existsSync(file)) return;
|
||||
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (!m) continue;
|
||||
const val = m[2].replace(/^["']|["']$/g, "");
|
||||
if (val && !process.env[m[1]]) process.env[m[1]] = val;
|
||||
}
|
||||
}
|
||||
loadAuthEnv();
|
||||
const HAS_AUTH = !!(process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY);
|
||||
|
||||
const PORT = Number(process.env.MCP_AGENT_PORT || 8765);
|
||||
const HOST = process.env.MCP_AGENT_HOST || "127.0.0.1";
|
||||
const PROJECT_DIR = (process.env.UE_PROJECT_DIR || path.resolve(__dirname, "..", "..", "..")).replace(/\\/g, "/");
|
||||
const MCP_SERVER_ENTRY = path.resolve(__dirname, "..", "src", "server.js").replace(/\\/g, "/");
|
||||
const MODEL = process.env.MCP_AGENT_MODEL || "claude-opus-4-8";
|
||||
|
||||
function defaultIntegrations() {
|
||||
return {
|
||||
elevenlabs: {
|
||||
apiKey: "",
|
||||
importPath: "/Game/Audio/Generated",
|
||||
variationCount: 3,
|
||||
loop: false,
|
||||
durationSeconds: null,
|
||||
promptInfluence: 0.3,
|
||||
modelId: "eleven_text_to_sound_v2",
|
||||
},
|
||||
textTools: {
|
||||
improvePrompt: "Fix spelling, grammar, punctuation, and clarity while preserving the original meaning, tone, formatting, and language. Return only the improved text.",
|
||||
translateLanguages: ["English", "Russian", "German", "French", "Spanish", "Japanese", "Chinese"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadIntegrations() {
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(INTEGRATIONS_FILE, "utf8"));
|
||||
return {
|
||||
elevenlabs: {
|
||||
...defaultIntegrations().elevenlabs,
|
||||
...(stored.elevenlabs || {}),
|
||||
},
|
||||
textTools: {
|
||||
...defaultIntegrations().textTools,
|
||||
...(stored.textTools || {}),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return defaultIntegrations();
|
||||
}
|
||||
}
|
||||
|
||||
let INTEGRATIONS = loadIntegrations();
|
||||
|
||||
async function saveIntegrations() {
|
||||
await writeFile(INTEGRATIONS_FILE, JSON.stringify(INTEGRATIONS, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function codexAuthStatus() {
|
||||
return {
|
||||
connected: existsSync(path.join(os.homedir(), ".codex", "auth.json")),
|
||||
method: "OAuth",
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettings() {
|
||||
const elevenlabs = INTEGRATIONS.elevenlabs;
|
||||
return {
|
||||
elevenlabs: {
|
||||
configured: !!elevenlabs.apiKey,
|
||||
apiKeyMasked: elevenlabs.apiKey ? `${elevenlabs.apiKey.slice(0, 5)}...${elevenlabs.apiKey.slice(-4)}` : "",
|
||||
importPath: elevenlabs.importPath,
|
||||
variationCount: elevenlabs.variationCount,
|
||||
loop: elevenlabs.loop,
|
||||
durationSeconds: elevenlabs.durationSeconds,
|
||||
promptInfluence: elevenlabs.promptInfluence,
|
||||
modelId: elevenlabs.modelId,
|
||||
},
|
||||
codex: codexAuthStatus(),
|
||||
textTools: {
|
||||
improvePrompt: INTEGRATIONS.textTools.improvePrompt,
|
||||
translateLanguages: INTEGRATIONS.textTools.translateLanguages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function json(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function safeAudioName(prompt, index) {
|
||||
const base = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 42) || "generated_sound";
|
||||
return `${base}_${String(index + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function pcm16MonoToWav(pcm, sampleRate = 48000) {
|
||||
const wav = Buffer.alloc(44 + pcm.length);
|
||||
wav.write("RIFF", 0);
|
||||
wav.writeUInt32LE(36 + pcm.length, 4);
|
||||
wav.write("WAVE", 8);
|
||||
wav.write("fmt ", 12);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20); // PCM
|
||||
wav.writeUInt16LE(1, 22); // mono
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * 2, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write("data", 36);
|
||||
wav.writeUInt32LE(pcm.length, 40);
|
||||
pcm.copy(wav, 44);
|
||||
return wav;
|
||||
}
|
||||
|
||||
async function generateSoundEffect(prompt, index, options) {
|
||||
const id = randomUUID();
|
||||
const name = safeAudioName(prompt, index);
|
||||
const url = "https://api.elevenlabs.io/v1/sound-generation?output_format=pcm_48000";
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"xi-api-key": INTEGRATIONS.elevenlabs.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: prompt,
|
||||
model_id: options.modelId,
|
||||
loop: options.loop,
|
||||
duration_seconds: options.durationSeconds,
|
||||
prompt_influence: options.promptInfluence,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).slice(0, 500);
|
||||
throw new Error(`ElevenLabs HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
const bytes = pcm16MonoToWav(Buffer.from(await response.arrayBuffer()));
|
||||
await mkdir(GENERATED_AUDIO_DIR, { recursive: true });
|
||||
const filePath = path.join(GENERATED_AUDIO_DIR, `${id}.wav`);
|
||||
await writeFile(filePath, bytes);
|
||||
const item = { id, name, prompt, filePath, createdAt: Date.now(), bytes: bytes.length };
|
||||
AUDIO_CACHE.set(id, item);
|
||||
return { id, name, prompt, bytes: bytes.length, audioUrl: `/api/elevenlabs/audio/${id}` };
|
||||
}
|
||||
|
||||
async function importGeneratedSound(id) {
|
||||
const item = AUDIO_CACHE.get(id);
|
||||
if (!item || !existsSync(item.filePath)) throw new Error("Generated sound has expired. Generate it again.");
|
||||
const destination = INTEGRATIONS.elevenlabs.importPath;
|
||||
if (!destination.startsWith("/Game/")) throw new Error("Import path must start with /Game/");
|
||||
const resultFile = path.join(PROJECT_DIR, "Saved", "MCP", `forge_audio_import_${id}.json`).replace(/\\/g, "/");
|
||||
const sourceFile = item.filePath.replace(/\\/g, "/");
|
||||
const assetName = item.name.replace(/[^A-Za-z0-9_]/g, "_");
|
||||
const py = [
|
||||
"import unreal, json, os",
|
||||
`task=unreal.AssetImportTask()`,
|
||||
`task.set_editor_property('filename', ${JSON.stringify(sourceFile)})`,
|
||||
`task.set_editor_property('destination_path', ${JSON.stringify(destination)})`,
|
||||
`task.set_editor_property('destination_name', ${JSON.stringify(assetName)})`,
|
||||
"task.set_editor_property('automated', True)",
|
||||
"task.set_editor_property('save', True)",
|
||||
"task.set_editor_property('replace_existing', False)",
|
||||
"unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])",
|
||||
"paths=[str(x) for x in task.get_editor_property('imported_object_paths')]",
|
||||
`out={'ok':bool(paths),'paths':paths,'error':None if paths else 'UE did not import the WAV file'}`,
|
||||
`os.makedirs(os.path.dirname(r'${resultFile}'), exist_ok=True)`,
|
||||
`open(r'${resultFile}','w',encoding='utf-8').write(json.dumps(out))`,
|
||||
].join("\n");
|
||||
const sent = await executePythonInUE(py, { timeoutMs: 45000 });
|
||||
if (!sent.ok) throw new Error(sent.error || "Cannot reach Unreal Editor");
|
||||
const result = JSON.parse(await readFile(resultFile, "utf8"));
|
||||
if (!result.ok) throw new Error(result.error || "Import failed");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runTextTool(instruction, text) {
|
||||
if (!HAS_AUTH) throw new Error("Forge agent auth is not configured.");
|
||||
const prompt = [
|
||||
"Transform the text exactly as requested.",
|
||||
"Do not answer the text, discuss it, add notes, or wrap the result in quotes or markdown fences.",
|
||||
"Return only the transformed text.",
|
||||
"",
|
||||
`Instruction: ${instruction}`,
|
||||
"",
|
||||
"Text:",
|
||||
text,
|
||||
].join("\n");
|
||||
let result = "";
|
||||
const options = {
|
||||
...agentOptions(MODEL),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
mcpServers: {},
|
||||
systemPrompt: "You are a precise text editing utility. Return only the transformed text.",
|
||||
};
|
||||
for await (const message of query({ prompt, options })) {
|
||||
if (message.type === "result") result = typeof message.result === "string" ? message.result.trim() : result;
|
||||
}
|
||||
if (!result) throw new Error("Text tool returned an empty result.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are the in-engine assistant for the current Unreal Engine project.
|
||||
You are embedded inside the Unreal editor via a WebBrowser widget. The developer talks to you here while the editor is open.
|
||||
|
||||
You have full access to the project's ue-blueprint MCP tools (prefixed mcp__ue-blueprint__) to inspect and modify the project live: edit Blueprint graphs, spawn/connect nodes, compile, build C++ via live coding, author UMG and screenshot it, run PCG/Niagara/material ops, run console commands, analyze Insights traces, and more. Prefer these tools over guessing.
|
||||
|
||||
Conventions:
|
||||
- Always confirm UE is reachable with mcp__ue-blueprint__ping_ue before tool-heavy work in a fresh session.
|
||||
- For any visual/UMG work, follow the project's UI workflow and use the screenshot vision loop.
|
||||
- Reference assets with their full /Game/... or /Script/... path, and Blueprint nodes by name — the chat UI turns those into clickable chips, so be precise.
|
||||
- Be concise. The chat panel is narrow. Lead with the answer, then detail.
|
||||
|
||||
The project lives at ${PROJECT_DIR}.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function sseHead(res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
}
|
||||
|
||||
function sseSend(res, event, data) {
|
||||
if (event !== "text") console.log(`[sse] -> ${event}`);
|
||||
const ok = res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
if (!ok) console.log(`[sse] backpressure on ${event}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions: one long-lived Agent SDK query per chat, fed via streaming input.
|
||||
// The conversation lives in memory (no `resume` / disk reload). This avoids the
|
||||
// "tool_use ids must be unique" 400 that resume+tools produced when the saved
|
||||
// transcript got a duplicated tool_use block.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models offered in the chat's model switcher. id must be a valid Anthropic id.
|
||||
const MODELS = [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", tag: "самый умный" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", tag: "баланс" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", tag: "быстрый" },
|
||||
];
|
||||
const MODEL_IDS = new Set(MODELS.map((m) => m.id));
|
||||
|
||||
function modelsForClient() {
|
||||
const models = [...MODELS];
|
||||
if (codexAuthStatus().connected) {
|
||||
models.push({ id: "codex-oauth", name: "Codex", tag: "OAuth connected · runtime unavailable", disabled: true });
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function agentOptions(model) {
|
||||
return {
|
||||
model: MODEL_IDS.has(model) ? model : MODEL,
|
||||
cwd: PROJECT_DIR,
|
||||
permissionMode: "bypassPermissions", // no interactive terminal here; full-access agent per project choice
|
||||
allowDangerouslySkipPermissions: true, // required alongside bypassPermissions
|
||||
includePartialMessages: true, // live token streaming (safe on SDK 0.3+)
|
||||
settingSources: ["project"], // load the repo CLAUDE.md so the agent knows the conventions
|
||||
mcpServers: {
|
||||
"ue-blueprint": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: [MCP_SERVER_ENTRY],
|
||||
env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR },
|
||||
},
|
||||
},
|
||||
systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_PROMPT },
|
||||
stderr: (data) => console.error("[sdk-stderr]", data),
|
||||
};
|
||||
}
|
||||
|
||||
// Tools available to the agent — captured from the first init we ever see, so
|
||||
// the sidebar can show a count before the user's first message.
|
||||
let CACHED_TOOLS = [];
|
||||
|
||||
// Image attachments: uploaded out-of-band (POST /api/upload), then referenced by
|
||||
// id in the EventSource GET (which can't carry a base64 body).
|
||||
const ATTACH = new Map();
|
||||
function putAttach(dataUrl) {
|
||||
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl || "");
|
||||
if (!m) return null;
|
||||
const id = randomUUID().slice(0, 8);
|
||||
ATTACH.set(id, { media_type: m[1], data: m[2], t: Date.now() });
|
||||
return id;
|
||||
}
|
||||
function takeAttach(ids) {
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
const a = ATTACH.get(id);
|
||||
if (a) { out.push({ media_type: a.media_type, data: a.data }); ATTACH.delete(id); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
setInterval(() => { const now = Date.now(); for (const [k, v] of ATTACH) if (now - v.t > 300000) ATTACH.delete(k); }, 60000).unref?.();
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
class Session {
|
||||
constructor(model) {
|
||||
this.id = randomUUID();
|
||||
this.model = MODEL_IDS.has(model) ? model : MODEL;
|
||||
this.pending = []; // queued { text, images? }
|
||||
this.wake = null; // resolver to wake the input generator
|
||||
this.res = null; // SSE response for the in-flight turn
|
||||
this.busy = false; // a turn is running in the SDK (independent of the socket)
|
||||
this.closed = false;
|
||||
this.resetTurn();
|
||||
this.consume();
|
||||
}
|
||||
|
||||
resetTurn() {
|
||||
this.sawText = false;
|
||||
this.textLen = 0;
|
||||
this.assistantText = "";
|
||||
}
|
||||
|
||||
// Streaming input: yields each queued user message, then parks until the next.
|
||||
async *input() {
|
||||
while (!this.closed) {
|
||||
if (this.pending.length === 0) {
|
||||
await new Promise((r) => { this.wake = r; });
|
||||
if (this.closed) break;
|
||||
}
|
||||
const item = this.pending.shift();
|
||||
let content;
|
||||
if (item.images && item.images.length) {
|
||||
content = [{ type: "text", text: item.text || "" }];
|
||||
for (const img of item.images) content.push({ type: "image", source: { type: "base64", media_type: img.media_type, data: img.data } });
|
||||
} else {
|
||||
content = item.text;
|
||||
}
|
||||
yield { type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: this.id };
|
||||
}
|
||||
}
|
||||
|
||||
push(item) {
|
||||
this.pending.push(item);
|
||||
if (this.wake) { const w = this.wake; this.wake = null; w(); }
|
||||
}
|
||||
|
||||
beginTurn(res, message, images) {
|
||||
this.res = res;
|
||||
this.busy = true;
|
||||
this.resetTurn();
|
||||
sseSend(res, "init", { sessionId: this.id, model: this.model, tools: CACHED_TOOLS });
|
||||
this.push({ text: message, images });
|
||||
}
|
||||
|
||||
endTurn() {
|
||||
this.busy = false;
|
||||
if (this.res) { try { this.res.end(); } catch { /* ignore */ } this.res = null; }
|
||||
}
|
||||
|
||||
async consume() {
|
||||
try {
|
||||
for await (const msg of query({ prompt: this.input(), options: agentOptions(this.model) })) {
|
||||
if (msg.type === "system" && msg.subtype === "init" && Array.isArray(msg.tools) && msg.tools.length) CACHED_TOOLS = msg.tools;
|
||||
this.route(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[session]", this.id.slice(0, 8), "error:", err?.message || err);
|
||||
if (this.res) sseSend(this.res, "error", { message: String(err?.message || err) });
|
||||
this.endTurn();
|
||||
}
|
||||
}
|
||||
|
||||
route(msg) {
|
||||
const res = this.res;
|
||||
switch (msg.type) {
|
||||
case "stream_event": {
|
||||
const ev = msg.event;
|
||||
if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta") {
|
||||
this.sawText = true;
|
||||
this.textLen += (ev.delta.text || "").length;
|
||||
if (res) sseSend(res, "text", { delta: ev.delta.text });
|
||||
} else if (ev?.type === "content_block_delta" && ev.delta?.type === "thinking_delta") {
|
||||
if (res) sseSend(res, "thinking", { delta: ev.delta.thinking });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
for (const b of (msg.message?.content || [])) {
|
||||
if (b.type === "tool_use") {
|
||||
if (res) sseSend(res, "tool_start", { id: b.id, name: b.name, input: b.input });
|
||||
} else if (b.type === "text" && typeof b.text === "string") {
|
||||
this.assistantText += b.text;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "user": {
|
||||
for (const b of (msg.message?.content || [])) {
|
||||
if (b.type === "tool_result") {
|
||||
if (res) sseSend(res, "tool_end", {
|
||||
id: b.tool_use_id,
|
||||
isError: !!b.is_error,
|
||||
content: normalizeToolResult(b.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const resultText = (typeof msg.result === "string" ? msg.result : "") || this.assistantText;
|
||||
console.log(`[session ${this.id.slice(0, 8)}] result: sawText=${this.sawText} streamed=${this.textLen} resultChars=${resultText.length}`);
|
||||
if (res) {
|
||||
if (msg.is_error || msg.subtype === "error_during_execution") {
|
||||
sseSend(res, "error", { message: resultText || msg.subtype || "agent error" });
|
||||
} else if (!this.sawText && resultText) {
|
||||
// No live deltas (tool-only turn) — emit the full final text.
|
||||
sseSend(res, "text", { delta: resultText });
|
||||
}
|
||||
sseSend(res, "done", {
|
||||
sessionId: this.id,
|
||||
subtype: msg.subtype,
|
||||
isError: !!msg.is_error,
|
||||
durationMs: msg.duration_ms,
|
||||
costUsd: msg.total_cost_usd,
|
||||
});
|
||||
}
|
||||
this.endTurn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind an SSE response to a (new or existing) session and run one turn.
|
||||
function handleTurn(res, { message, sessionId, model, images }) {
|
||||
let session = sessionId ? sessions.get(sessionId) : null;
|
||||
if (!session) {
|
||||
session = new Session(model);
|
||||
sessions.set(session.id, session);
|
||||
}
|
||||
sseHead(res);
|
||||
if (session.busy) {
|
||||
// A turn is still running in the SDK (client should serialize; this also
|
||||
// guards against a dropped socket whose turn hasn't finished yet).
|
||||
sseSend(res, "init", { sessionId: session.id, model: session.model, tools: CACHED_TOOLS });
|
||||
sseSend(res, "error", { message: "агент ещё занят предыдущим запросом — подожди" });
|
||||
sseSend(res, "done", { sessionId: session.id, subtype: "busy" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.on("close", () => { if (session.res === res) session.res = null; });
|
||||
session.beginTurn(res, message, images);
|
||||
}
|
||||
|
||||
// Exact ue-blueprint tool count. The SDK's init reports the MCP server as
|
||||
// "pending" (tools connect after init), so we ask the MCP server directly over
|
||||
// its stdio JSON-RPC: initialize → tools/list. Cheap, runs once at startup.
|
||||
let UE_TOOL_COUNT = 0;
|
||||
function countMcpTools() {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const child = spawn("node", [MCP_SERVER_ENTRY], { env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR } });
|
||||
const finish = (n) => { if (done) return; done = true; try { child.kill(); } catch { /* ignore */ } resolve(n); };
|
||||
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch { /* ignore */ } };
|
||||
let buf = "";
|
||||
child.stdout.on("data", (d) => {
|
||||
buf += d.toString();
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
|
||||
if (!line.trim()) continue;
|
||||
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
||||
if (msg.id === 1 && msg.result) {
|
||||
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
||||
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
||||
}
|
||||
if (msg.id === 2 && msg.result && Array.isArray(msg.result.tools)) finish(msg.result.tools.length);
|
||||
}
|
||||
});
|
||||
child.on("error", () => finish(0));
|
||||
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mcp-agent-gateway", version: "1" } } });
|
||||
setTimeout(() => finish(0), 8000);
|
||||
});
|
||||
}
|
||||
|
||||
// Grab the agent's tool list once at startup (init arrives before any model
|
||||
// call), so the sidebar count is populated before the first chat.
|
||||
async function primeTools() {
|
||||
if (!HAS_AUTH || CACHED_TOOLS.length) return;
|
||||
try {
|
||||
const q = query({ prompt: "ready", options: agentOptions() });
|
||||
for await (const m of q) {
|
||||
if (m.type === "system" && m.subtype === "init") { CACHED_TOOLS = m.tools || []; break; }
|
||||
}
|
||||
if (typeof q.interrupt === "function") { try { await q.interrupt(); } catch { /* ignore */ } }
|
||||
console.log(`[primeTools] cached ${CACHED_TOOLS.length} tools`);
|
||||
} catch (e) { console.error("[primeTools]", e?.message || e); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Asset list (for @mention autocomplete). Cached; refreshed on demand.
|
||||
// Dumps the AssetRegistry + open editors to a JSON file UE can write and we read.
|
||||
// ---------------------------------------------------------------------------
|
||||
let ASSET_CACHE = { at: 0, list: [] };
|
||||
const ASSET_FILE = path.join(PROJECT_DIR, "Saved", "MCP", "agent_assets.json").replace(/\\/g, "/");
|
||||
async function refreshAssets() {
|
||||
const py = [
|
||||
"import unreal, json, os",
|
||||
"out=[]; seen=set(); open_paths=set()",
|
||||
"try:",
|
||||
" sub=unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)",
|
||||
" for a in sub.get_all_edited_assets():",
|
||||
" open_paths.add(a.get_path_name().split('.')[0])",
|
||||
"except Exception: pass",
|
||||
"ar=unreal.AssetRegistryHelpers.get_asset_registry()",
|
||||
"f=unreal.ARFilter(package_paths=['/Game'], recursive_paths=True)",
|
||||
"for ad in ar.get_assets(f):",
|
||||
" p=str(ad.package_name)",
|
||||
" if p in seen: continue",
|
||||
" seen.add(p)",
|
||||
" try: cls=str(ad.asset_class_path.asset_name)",
|
||||
" except Exception: cls=str(getattr(ad,'asset_class',''))",
|
||||
" out.append({'name':str(ad.asset_name),'path':p,'class':cls,'open':p in open_paths})",
|
||||
`os.makedirs(os.path.dirname(r'${ASSET_FILE}'), exist_ok=True)`,
|
||||
`open(r'${ASSET_FILE}','w',encoding='utf-8').write(json.dumps(out))`,
|
||||
].join("\n");
|
||||
await executePythonInUE(py, { timeoutMs: 25000 });
|
||||
const raw = await readFile(ASSET_FILE, "utf8");
|
||||
ASSET_CACHE = { at: Date.now(), list: JSON.parse(raw) };
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { resolve(JSON.parse(b || "{}")); } catch { resolve({}); } });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeToolResult(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((c) => (c?.type === "text" ? c.text : c?.type ? `[${c.type}]` : String(c)))
|
||||
.join("\n");
|
||||
}
|
||||
return content == null ? "" : String(content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static file serving
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
async function serveStatic(req, res) {
|
||||
let rel = decodeURIComponent(req.url.split("?")[0]);
|
||||
if (rel === "/") rel = "/index.html";
|
||||
const filePath = path.join(PUBLIC_DIR, path.normalize(rel));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||||
res.writeHead(403).end("forbidden");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404).end("not found");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP server
|
||||
// ---------------------------------------------------------------------------
|
||||
const server = createServer(async (req, res) => {
|
||||
// CORS — WebBrowser widget origins can be quirky; allow all for localhost dev.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/chat") {
|
||||
const payload = await readBody(req);
|
||||
const message = (payload.message || "").trim();
|
||||
const images = [];
|
||||
if (Array.isArray(payload.images)) for (const d of payload.images) { const m = /^data:([^;]+);base64,(.+)$/s.exec(d || ""); if (m) images.push({ media_type: m[1], data: m[2] }); }
|
||||
if (!message && !images.length) { res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}'); return; }
|
||||
handleTurn(res, { message, sessionId: payload.sessionId || null, model: payload.model || null, images });
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload an image attachment (base64 data URL) → returns a short id the
|
||||
// EventSource GET references via &attach=, since GET can't carry the body.
|
||||
if (req.method === "POST" && req.url === "/api/upload") {
|
||||
const payload = await readBody(req);
|
||||
const id = putAttach(payload.dataUrl);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(id ? { ok: true, id } : { ok: false, error: "bad data url" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// EventSource transport (GET). UE's CEF WebBrowser does not consume a streamed
|
||||
// fetch() body, but it reads EventSource reliably. Message rides in the query.
|
||||
if (req.method === "GET" && req.url.startsWith("/api/chat-stream")) {
|
||||
const u = new URL(req.url, `http://${req.headers.host}`);
|
||||
const message = (u.searchParams.get("message") || "").trim();
|
||||
const sessionId = u.searchParams.get("sessionId") || null;
|
||||
const model = u.searchParams.get("model") || null;
|
||||
const images = takeAttach((u.searchParams.get("attach") || "").split(",").filter(Boolean));
|
||||
if (!message && !images.length) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}');
|
||||
return;
|
||||
}
|
||||
handleTurn(res, { message, sessionId, model, images });
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset list for @mention autocomplete (cached ~30s).
|
||||
if (req.method === "GET" && req.url.startsWith("/api/assets")) {
|
||||
const u = new URL(req.url, `http://${req.headers.host}`);
|
||||
const qq = (u.searchParams.get("q") || "").toLowerCase();
|
||||
try { if (Date.now() - ASSET_CACHE.at > 30000) await refreshAssets(); }
|
||||
catch (e) { console.error("[assets]", e?.message || e); }
|
||||
let list = ASSET_CACHE.list || [];
|
||||
if (qq) list = list.filter((a) => a.name.toLowerCase().includes(qq) || a.path.toLowerCase().includes(qq));
|
||||
list = list.slice().sort((a, b) => (b.open ? 1 : 0) - (a.open ? 1 : 0)).slice(0, 40);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ assets: list, total: (ASSET_CACHE.list || []).length }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/focus") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", async () => {
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(body || "{}"); } catch { /* ignore */ }
|
||||
const assetPath = (payload.path || "").trim();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
if (!assetPath.startsWith("/Game/")) {
|
||||
res.end(JSON.stringify({ ok: false, error: "not a /Game asset path" }));
|
||||
return;
|
||||
}
|
||||
const py = `import unreal\nunreal.EditorAssetLibrary.sync_browser_to_objects([${JSON.stringify(assetPath)}])`;
|
||||
const out = await executePythonInUE(py).catch((e) => ({ ok: false, error: String(e) }));
|
||||
res.end(JSON.stringify(out));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/settings") {
|
||||
json(res, 200, publicSettings());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/settings") {
|
||||
const payload = await readBody(req);
|
||||
const current = INTEGRATIONS.elevenlabs;
|
||||
const currentTextTools = INTEGRATIONS.textTools;
|
||||
const importPath = String(payload.elevenlabs?.importPath || current.importPath).trim().replace(/\/+$/, "");
|
||||
const variationCount = Number(payload.elevenlabs?.variationCount || current.variationCount);
|
||||
if (!importPath.startsWith("/Game/")) {
|
||||
json(res, 400, { ok: false, error: "ElevenLabs import path must start with /Game/" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(variationCount) || variationCount < 1 || variationCount > 4) {
|
||||
json(res, 400, { ok: false, error: "Variation count must be between 1 and 4" });
|
||||
return;
|
||||
}
|
||||
const nextElevenlabs = {
|
||||
apiKey: payload.elevenlabs?.apiKey === undefined ? current.apiKey : String(payload.elevenlabs.apiKey).trim(),
|
||||
importPath,
|
||||
variationCount,
|
||||
loop: current.loop,
|
||||
durationSeconds: payload.elevenlabs?.durationSeconds === null || payload.elevenlabs?.durationSeconds === ""
|
||||
? null
|
||||
: Number(payload.elevenlabs?.durationSeconds ?? current.durationSeconds),
|
||||
promptInfluence: Number(payload.elevenlabs?.promptInfluence ?? current.promptInfluence),
|
||||
modelId: String(payload.elevenlabs?.modelId || current.modelId),
|
||||
};
|
||||
if (nextElevenlabs.durationSeconds !== null && (!Number.isFinite(nextElevenlabs.durationSeconds) || nextElevenlabs.durationSeconds < 0.5 || nextElevenlabs.durationSeconds > 30)) {
|
||||
json(res, 400, { ok: false, error: "Default ElevenLabs duration must be Auto or between 0.5 and 30 seconds." });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(nextElevenlabs.promptInfluence) || nextElevenlabs.promptInfluence < 0 || nextElevenlabs.promptInfluence > 1) {
|
||||
json(res, 400, { ok: false, error: "Default ElevenLabs prompt influence must be between 0 and 1." });
|
||||
return;
|
||||
}
|
||||
if (nextElevenlabs.modelId !== "eleven_text_to_sound_v2") {
|
||||
json(res, 400, { ok: false, error: "Unsupported ElevenLabs Sound Effects model." });
|
||||
return;
|
||||
}
|
||||
INTEGRATIONS.elevenlabs = nextElevenlabs;
|
||||
const improvePrompt = String(payload.textTools?.improvePrompt || currentTextTools.improvePrompt).trim();
|
||||
const translateLanguages = Array.isArray(payload.textTools?.translateLanguages)
|
||||
? payload.textTools.translateLanguages.map((x) => String(x).trim()).filter(Boolean).slice(0, 20)
|
||||
: currentTextTools.translateLanguages;
|
||||
if (!improvePrompt || improvePrompt.length > 2000) {
|
||||
json(res, 400, { ok: false, error: "Text Fix prompt must contain 1 to 2000 characters." });
|
||||
return;
|
||||
}
|
||||
if (!translateLanguages.length) {
|
||||
json(res, 400, { ok: false, error: "Add at least one translation language." });
|
||||
return;
|
||||
}
|
||||
INTEGRATIONS.textTools = { improvePrompt, translateLanguages };
|
||||
await saveIntegrations();
|
||||
json(res, 200, { ok: true, settings: publicSettings() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/text-tools/improve") {
|
||||
const payload = await readBody(req);
|
||||
const text = String(payload.text || "").trim();
|
||||
if (!text || text.length > 12000) {
|
||||
json(res, 400, { ok: false, error: "Text Fix expects 1 to 12000 characters." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
json(res, 200, { ok: true, text: await runTextTool(INTEGRATIONS.textTools.improvePrompt, text) });
|
||||
} catch (e) {
|
||||
json(res, 500, { ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/text-tools/translate") {
|
||||
const payload = await readBody(req);
|
||||
const text = String(payload.text || "").trim();
|
||||
const language = String(payload.language || "").trim();
|
||||
if (!text || text.length > 12000) {
|
||||
json(res, 400, { ok: false, error: "Translate expects 1 to 12000 characters." });
|
||||
return;
|
||||
}
|
||||
if (!INTEGRATIONS.textTools.translateLanguages.includes(language)) {
|
||||
json(res, 400, { ok: false, error: "Choose a configured translation language." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const instruction = `Translate the text into ${language}. Preserve meaning, tone, paragraph structure, line breaks, asset paths, code, and Unreal identifiers.`;
|
||||
json(res, 200, { ok: true, text: await runTextTool(instruction, text), language });
|
||||
} catch (e) {
|
||||
json(res, 500, { ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/integrations/codex/login") {
|
||||
try {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "codex", "login"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: false,
|
||||
});
|
||||
child.unref();
|
||||
json(res, 200, { ok: true, message: "Codex OAuth login opened in a separate terminal." });
|
||||
} catch (e) {
|
||||
json(res, 500, { ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/elevenlabs/generate") {
|
||||
const payload = await readBody(req);
|
||||
const prompt = String(payload.prompt || "").trim();
|
||||
if (!INTEGRATIONS.elevenlabs.apiKey) {
|
||||
json(res, 400, { ok: false, error: "Add an ElevenLabs API key in Settings first." });
|
||||
return;
|
||||
}
|
||||
if (!prompt || prompt.length > 450) {
|
||||
json(res, 400, { ok: false, error: "Sound prompt must contain 1 to 450 characters." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const defaults = INTEGRATIONS.elevenlabs;
|
||||
const count = Number(payload.variationCount ?? defaults.variationCount);
|
||||
const loop = payload.loop === undefined ? defaults.loop : !!payload.loop;
|
||||
const durationSeconds = payload.durationSeconds === null || payload.durationSeconds === undefined || payload.durationSeconds === ""
|
||||
? null
|
||||
: Number(payload.durationSeconds);
|
||||
const promptInfluence = Number(payload.promptInfluence ?? defaults.promptInfluence);
|
||||
const modelId = String(payload.modelId || defaults.modelId);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 4) throw new Error("Variation count must be between 1 and 4.");
|
||||
if (durationSeconds !== null && (!Number.isFinite(durationSeconds) || durationSeconds < 0.5 || durationSeconds > 30)) throw new Error("Duration must be Auto or between 0.5 and 30 seconds.");
|
||||
if (!Number.isFinite(promptInfluence) || promptInfluence < 0 || promptInfluence > 1) throw new Error("Prompt influence must be between 0 and 1.");
|
||||
if (modelId !== "eleven_text_to_sound_v2") throw new Error("Unsupported ElevenLabs Sound Effects model.");
|
||||
const options = { loop, durationSeconds, promptInfluence, modelId };
|
||||
const sounds = await Promise.all(Array.from({ length: count }, (_, index) => generateSoundEffect(prompt, index, options)));
|
||||
json(res, 200, { ok: true, prompt, options, sounds });
|
||||
} catch (e) {
|
||||
json(res, 502, { ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url.startsWith("/api/elevenlabs/audio/")) {
|
||||
const id = decodeURIComponent(req.url.split("/").pop().split("?")[0]);
|
||||
const item = AUDIO_CACHE.get(id);
|
||||
if (!item || !existsSync(item.filePath)) {
|
||||
res.writeHead(404).end("audio not found");
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(item.filePath);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Length": bytes.length,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
});
|
||||
res.end(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/elevenlabs/import") {
|
||||
const payload = await readBody(req);
|
||||
try {
|
||||
const result = await importGeneratedSound(String(payload.id || ""));
|
||||
json(res, 200, { ok: true, ...result });
|
||||
} catch (e) {
|
||||
json(res, 500, { ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, model: MODEL, models: modelsForClient(), projectDir: PROJECT_DIR, mcp: MCP_SERVER_ENTRY, hasAuth: HAS_AUTH, tools: CACHED_TOOLS, ueToolCount: UE_TOOL_COUNT, integrations: publicSettings() }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
await serveStatic(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(405).end("method not allowed");
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`[mcp-agent-gateway] http://${HOST}:${PORT}`);
|
||||
console.log(`[mcp-agent-gateway] model=${MODEL}`);
|
||||
console.log(`[mcp-agent-gateway] project=${PROJECT_DIR}`);
|
||||
console.log(`[mcp-agent-gateway] mcp=${MCP_SERVER_ENTRY}`);
|
||||
if (!HAS_AUTH) {
|
||||
console.warn("[mcp-agent-gateway] WARNING: no CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY found.");
|
||||
console.warn("[mcp-agent-gateway] Run `claude setup-token` and put it in agent-gateway/auth.env — see README.md.");
|
||||
}
|
||||
primeTools(); // fire-and-forget: populate the total tool list
|
||||
countMcpTools().then((n) => { UE_TOOL_COUNT = n; console.log(`[mcp tools] ue-blueprint exposes ${n} tools`); });
|
||||
});
|
||||
14
agent-gateway/setup-token.bat
Normal file
14
agent-gateway/setup-token.bat
Normal file
@ -0,0 +1,14 @@
|
||||
@echo off
|
||||
REM Generate a long-lived Claude OAuth token using the Agent SDK's bundled CLI.
|
||||
REM Opens a browser to log in with your Claude subscription (Pro/Max), then
|
||||
REM prints a token (sk-ant-oat01-...). Copy it into auth.env as:
|
||||
REM CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
cd /d "%~dp0"
|
||||
node "node_modules\@anthropic-ai\claude-agent-sdk\cli.js" setup-token
|
||||
echo.
|
||||
echo ----------------------------------------------------------------
|
||||
echo Copy the token above into agent-gateway\auth.env :
|
||||
echo CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
echo Then restart the gateway (node server.mjs).
|
||||
echo ----------------------------------------------------------------
|
||||
pause
|
||||
10
agent-gateway/start-gateway.bat
Normal file
10
agent-gateway/start-gateway.bat
Normal file
@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
REM Launch the in-engine agent gateway.
|
||||
cd /d "%~dp0"
|
||||
if not exist node_modules (
|
||||
echo [setup] installing dependencies...
|
||||
call npm install
|
||||
)
|
||||
echo [mcp-agent-gateway] starting agent gateway on http://127.0.0.1:8765
|
||||
node server.mjs
|
||||
pause
|
||||
1587
package-lock.json
generated
Normal file
1587
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "ue-blueprint-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for spawning Unreal Engine 5 Blueprint nodes via a universal NodeSpec primitive.",
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"bin": {
|
||||
"ue-blueprint-mcp": "src/server.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test \"test/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"better-sqlite3": "^11.5.0"
|
||||
}
|
||||
}
|
||||
226
src/buildOrchestrator.js
Normal file
226
src/buildOrchestrator.js
Normal file
@ -0,0 +1,226 @@
|
||||
// Build orchestration: save → close editor → run Build.bat → reopen.
|
||||
//
|
||||
// Lives outside the editor so it can survive the editor process dying.
|
||||
// Driven from server.js as the `build_op` MCP tool.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { executePythonInUE, ping } from "./ueBridge.js";
|
||||
import * as PP from "./projectPaths.js";
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
// All defaults are auto-detected from where the plugin lives (see projectPaths.js);
|
||||
// every one can still be overridden per-call (opts) or via environment variable.
|
||||
function paths(opts = {}) {
|
||||
const projectDir = opts.project_dir || PP.projectDir();
|
||||
const engineDir = opts.engine_dir || PP.engineDir();
|
||||
const uprojectName = opts.uproject_name || PP.uprojectName();
|
||||
if (!engineDir) {
|
||||
throw new Error(
|
||||
"Could not locate an Unreal Engine install. Set UE_ENGINE_DIR (the folder that contains /Engine) " +
|
||||
"or pass engine_dir in the call."
|
||||
);
|
||||
}
|
||||
if (!uprojectName) {
|
||||
throw new Error("Could not locate a .uproject. Set UE_PROJECT_DIR / UE_UPROJECT_NAME or pass project_dir.");
|
||||
}
|
||||
return {
|
||||
projectDir,
|
||||
engineDir,
|
||||
uproject: path.join(projectDir, uprojectName).replace(/\\/g, "/"),
|
||||
editorExe: path.join(engineDir, "Engine/Binaries/Win64/UnrealEditor.exe").replace(/\\/g, "/"),
|
||||
buildBat: path.join(engineDir, "Engine/Build/BatchFiles/Build.bat").replace(/\\/g, "/"),
|
||||
target: opts.target || PP.buildTarget(),
|
||||
config: opts.config || process.env.UE_BUILD_CONFIG || "Development",
|
||||
platform: opts.platform || "Win64",
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Step 1: save all in editor -----
|
||||
export async function saveAll() {
|
||||
const py = [
|
||||
"import unreal",
|
||||
"ok_assets = unreal.EditorAssetLibrary.save_directory('/Game', only_if_is_dirty=True, recursive=True)",
|
||||
"try: unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level()",
|
||||
"except Exception: pass",
|
||||
"unreal.log('[MCP-BUILD] save_all done assets=' + str(ok_assets))",
|
||||
].join("\n");
|
||||
const r = await executePythonInUE(py);
|
||||
return { ok: r.ok, response: r.response, error: r.error };
|
||||
}
|
||||
|
||||
// ----- Step 2: close editor cleanly -----
|
||||
export async function quitEditor() {
|
||||
const py = [
|
||||
"import unreal",
|
||||
"try: unreal.SystemLibrary.quit_editor()",
|
||||
"except Exception as e: unreal.log('[MCP-BUILD] quit_editor failed: ' + str(e))",
|
||||
].join("\n");
|
||||
// Fire and forget — the editor will tear down RC mid-call.
|
||||
try { await executePythonInUE(py, { timeoutMs: 5000 }); } catch { /* expected */ }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ----- Helper: wait until UE editor process is gone -----
|
||||
async function waitEditorGone({ uprojectName, timeoutMs = 90000 }) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
// Check via Remote Control liveness first — fast and authoritative.
|
||||
const p = await ping({ timeoutMs: 1500 });
|
||||
if (!p.ok) {
|
||||
// Confirm with tasklist filtering UnrealEditor.exe with our uproject in cmdline.
|
||||
const alive = await editorProcessAlive(uprojectName);
|
||||
if (!alive) return { ok: true, waited_ms: Date.now() - start };
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
return { ok: false, error: `editor still alive after ${timeoutMs}ms` };
|
||||
}
|
||||
|
||||
async function editorProcessAlive(uprojectName) {
|
||||
try {
|
||||
// WMIC is being deprecated but still on Win10/11. Fallback to powershell.
|
||||
const { stdout } = await execFileP("powershell.exe", [
|
||||
"-NoProfile", "-Command",
|
||||
`Get-CimInstance Win32_Process -Filter "Name='UnrealEditor.exe'" | Select-Object -ExpandProperty CommandLine`,
|
||||
], { timeout: 5000 });
|
||||
return stdout.toLowerCase().includes(uprojectName.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Step 3: run Build.bat and parse output -----
|
||||
export function runBuild(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const P = paths(opts);
|
||||
if (!existsSync(P.buildBat)) {
|
||||
return resolve({ ok: false, error: `Build.bat not found at ${P.buildBat}` });
|
||||
}
|
||||
if (!existsSync(P.uproject)) {
|
||||
return resolve({ ok: false, error: `.uproject not found at ${P.uproject}` });
|
||||
}
|
||||
|
||||
// Use cmd.exe /c with manually-quoted command — paths with spaces (Program Files)
|
||||
// get mangled by shell:true otherwise.
|
||||
const quoted =
|
||||
`"${P.buildBat}" ${P.target} ${P.platform} ${P.config} ` +
|
||||
`-Project="${P.uproject}" -WaitMutex -FromMsBuild`;
|
||||
const started = Date.now();
|
||||
const child = spawn("cmd.exe", ["/c", quoted], {
|
||||
windowsVerbatimArguments: true,
|
||||
shell: false,
|
||||
cwd: P.engineDir,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const lineHandler = (chunk, isErr) => {
|
||||
const text = chunk.toString();
|
||||
if (isErr) stderr += text; else stdout += text;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
if (/(\s|:)error\s+[A-Z]?\d{2,5}|fatal error|LINK\s*:\s*fatal|:\s*error\s*:/i.test(line)) {
|
||||
errors.push(line);
|
||||
} else if (/(\s|:)warning\s+[A-Z]?\d{2,5}|:\s*warning\s*:/i.test(line)) {
|
||||
warnings.push(line);
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout.on("data", (b) => lineHandler(b, false));
|
||||
child.stderr.on("data", (b) => lineHandler(b, true));
|
||||
|
||||
child.on("error", (e) => {
|
||||
resolve({ ok: false, error: `spawn failed: ${e.message}`, duration_ms: Date.now() - started });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
exit_code: code,
|
||||
duration_ms: Date.now() - started,
|
||||
errors_count: errors.length,
|
||||
warnings_count: warnings.length,
|
||||
errors: errors.slice(-30),
|
||||
warnings: warnings.slice(-15),
|
||||
stdout_tail: stdout.split(/\r?\n/).slice(-40).join("\n"),
|
||||
stderr_tail: stderr.split(/\r?\n/).slice(-20).join("\n"),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Step 4: relaunch editor -----
|
||||
export function launchEditor(opts = {}) {
|
||||
const P = paths(opts);
|
||||
if (!existsSync(P.editorExe)) return { ok: false, error: `Editor not found at ${P.editorExe}` };
|
||||
if (!existsSync(P.uproject)) return { ok: false, error: `.uproject not found at ${P.uproject}` };
|
||||
const child = spawn(P.editorExe, [P.uproject], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return { ok: true, pid: child.pid, editor: P.editorExe, project: P.uproject };
|
||||
}
|
||||
|
||||
// ----- Orchestration: full save → close → build → reopen -----
|
||||
export async function fullRebuild(opts = {}) {
|
||||
const log = [];
|
||||
const P = paths(opts);
|
||||
const autoReopen = opts.auto_reopen !== false;
|
||||
const skipSave = !!opts.skip_save;
|
||||
const skipClose = !!opts.skip_close;
|
||||
|
||||
// 1. Save
|
||||
if (!skipSave) {
|
||||
const s = await saveAll();
|
||||
log.push({ step: "save_all", ok: s.ok, error: s.error });
|
||||
if (!s.ok) return { ok: false, where: "save_all", log };
|
||||
}
|
||||
|
||||
// 2. Close
|
||||
if (!skipClose) {
|
||||
const live = await ping({ timeoutMs: 1500 });
|
||||
if (live.ok) {
|
||||
await quitEditor();
|
||||
const wait = await waitEditorGone({ uprojectName: P.uproject.split("/").pop(), timeoutMs: 90000 });
|
||||
log.push({ step: "quit_editor", ok: wait.ok, waited_ms: wait.waited_ms, error: wait.error });
|
||||
if (!wait.ok) return { ok: false, where: "quit_editor", log };
|
||||
} else {
|
||||
log.push({ step: "quit_editor", ok: true, note: "editor was already down" });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build
|
||||
const b = await runBuild(opts);
|
||||
log.push({ step: "build", ok: b.ok, duration_ms: b.duration_ms,
|
||||
errors_count: b.errors_count, warnings_count: b.warnings_count, exit_code: b.exit_code });
|
||||
|
||||
if (!b.ok) {
|
||||
return {
|
||||
ok: false, where: "build", log,
|
||||
build: {
|
||||
errors: b.errors, warnings: b.warnings,
|
||||
stdout_tail: b.stdout_tail, stderr_tail: b.stderr_tail,
|
||||
duration_ms: b.duration_ms, exit_code: b.exit_code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Reopen
|
||||
if (autoReopen) {
|
||||
const l = launchEditor(opts);
|
||||
log.push({ step: "launch_editor", ok: l.ok, pid: l.pid, error: l.error });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true, log,
|
||||
build: { duration_ms: b.duration_ms, warnings_count: b.warnings_count, warnings: b.warnings },
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
751
src/insights.js
Normal file
751
src/insights.js
Normal file
@ -0,0 +1,751 @@
|
||||
// MCP tool handlers for Unreal Insights analytics.
|
||||
//
|
||||
// Public surface (called from server.js):
|
||||
// handleOpenTrace, handleListTraces, handleSummary,
|
||||
// handleFrameStats, handleTopScopes, handleScopeDetail,
|
||||
// handleGpuTop, handleMemoryOverview, handleLoadingTop,
|
||||
// handleCompareTraces, handleQuery, handleReport
|
||||
//
|
||||
// Storage layout (per project):
|
||||
// <project>/Saved/InsightsMCP/
|
||||
// traces.json — index { trace_id: {path, label, ...} }
|
||||
// <trace_id>/
|
||||
// manifest.json
|
||||
// raw/*.tsv — Insights export
|
||||
// cache.sqlite — parsed
|
||||
// insights.log
|
||||
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { exportTrace, findInsightsExe, hashTrace } from "./insightsExporter.js";
|
||||
import {
|
||||
openDb, ingestRawDir, listTables, tableColumns, dbAvailable,
|
||||
} from "./insightsDb.js";
|
||||
import {
|
||||
frameTimeline, frameHistogram, topScopesBar, threadBreakdown, buildDashboard,
|
||||
} from "./insightsChart.js";
|
||||
import { projectDir as detectProjectDir } from "./projectPaths.js";
|
||||
|
||||
const DEFAULT_PROJECT_DIR = detectProjectDir();
|
||||
|
||||
function cacheRoot(projectDir = DEFAULT_PROJECT_DIR) {
|
||||
return path.join(projectDir, "Saved/InsightsMCP");
|
||||
}
|
||||
|
||||
function traceDir(traceId, projectDir) {
|
||||
return path.join(cacheRoot(projectDir), traceId);
|
||||
}
|
||||
|
||||
function ok(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; }
|
||||
function err(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }
|
||||
|
||||
// ---------- traces index ----------
|
||||
|
||||
function readIndex(projectDir) {
|
||||
const p = path.join(cacheRoot(projectDir), "traces.json");
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
|
||||
}
|
||||
|
||||
function writeIndex(projectDir, idx) {
|
||||
const root = cacheRoot(projectDir);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "traces.json"), JSON.stringify(idx, null, 2));
|
||||
}
|
||||
|
||||
function resolveTrace(traceIdOrLabel, projectDir) {
|
||||
const idx = readIndex(projectDir);
|
||||
if (idx[traceIdOrLabel]) return { id: traceIdOrLabel, ...idx[traceIdOrLabel] };
|
||||
for (const [id, meta] of Object.entries(idx)) {
|
||||
if (meta.label === traceIdOrLabel) return { id, ...meta };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- column inference ----------
|
||||
|
||||
function pickColumn(cols, candidates) {
|
||||
const names = cols.map((c) => c.name);
|
||||
for (const cand of candidates) {
|
||||
const hit = names.find((n) => n.toLowerCase() === cand.toLowerCase());
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const cand of candidates) {
|
||||
const hit = names.find((n) => n.toLowerCase().includes(cand.toLowerCase()));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTable(db, candidates) {
|
||||
const tables = listTables(db);
|
||||
for (const cand of candidates) {
|
||||
const hit = tables.find((t) => t.toLowerCase() === cand.toLowerCase());
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const cand of candidates) {
|
||||
const hit = tables.find((t) => t.toLowerCase().includes(cand.toLowerCase()));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- tool handlers ----------
|
||||
|
||||
export async function handleOpenTrace({ trace_path, label, force, project_dir }) {
|
||||
if (!trace_path) return err("open_trace: trace_path required");
|
||||
if (!dbAvailable()) return err("better-sqlite3 not installed. Run `npm install` in Plugins/UEBlueprintMCP.");
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
|
||||
let absPath = trace_path.replace(/\\/g, "/");
|
||||
if (!fs.existsSync(absPath)) {
|
||||
const inProject = path.join(projectDir, "Saved/Profiling/UnrealInsights", path.basename(absPath));
|
||||
if (fs.existsSync(inProject)) absPath = inProject;
|
||||
else return err(`trace file not found: ${trace_path}`);
|
||||
}
|
||||
|
||||
const exe = findInsightsExe();
|
||||
if (!exe) return err("UnrealInsights.exe not found. Set env UE_INSIGHTS_EXE to override.");
|
||||
|
||||
const hash = await hashTrace(absPath);
|
||||
const dir = traceDir(hash, projectDir);
|
||||
const sqlitePath = path.join(dir, "cache.sqlite");
|
||||
const alreadyCached = fs.existsSync(sqlitePath) && !force;
|
||||
|
||||
let exportResult = null;
|
||||
let ingestSummary = null;
|
||||
if (!alreadyCached) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
exportResult = await exportTrace({ tracePath: absPath, cacheDir: dir });
|
||||
if (!exportResult.ok) {
|
||||
return err(`export failed: ${exportResult.error}\nlog: ${exportResult.logPath}\nstderr tail:\n${exportResult.stderr}`);
|
||||
}
|
||||
const db = openDb(dir);
|
||||
try {
|
||||
ingestSummary = await ingestRawDir(db, exportResult.rawDir);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary by probing tables.
|
||||
const summary = computeSummary(dir);
|
||||
|
||||
const idx = readIndex(projectDir);
|
||||
idx[hash] = {
|
||||
label: label || path.basename(absPath),
|
||||
trace_path: absPath,
|
||||
insights_exe: exe,
|
||||
opened_at: new Date().toISOString(),
|
||||
size_bytes: (await fsp.stat(absPath)).size,
|
||||
};
|
||||
writeIndex(projectDir, idx);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({
|
||||
trace_id: hash, trace_path: absPath, label: idx[hash].label,
|
||||
insights_exe: exe,
|
||||
export: exportResult,
|
||||
ingest: ingestSummary,
|
||||
summary,
|
||||
}, null, 2));
|
||||
|
||||
return ok({
|
||||
trace_id: hash,
|
||||
label: idx[hash].label,
|
||||
cache_dir: dir,
|
||||
cached: alreadyCached,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleListTraces({ project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const idx = readIndex(projectDir);
|
||||
const out = [];
|
||||
for (const [id, meta] of Object.entries(idx)) {
|
||||
out.push({ trace_id: id, ...meta, exists: fs.existsSync(traceDir(id, projectDir)) });
|
||||
}
|
||||
return ok({ project_dir: projectDir, count: out.length, traces: out });
|
||||
}
|
||||
|
||||
export async function handleSummary({ trace_id, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
return ok({ trace_id: t.id, label: t.label, ...computeSummary(traceDir(t.id, projectDir)) });
|
||||
}
|
||||
|
||||
function computeSummary(dir) {
|
||||
const db = openDb(dir);
|
||||
try {
|
||||
const tables = listTables(db);
|
||||
const byTable = {};
|
||||
for (const t of tables) {
|
||||
const cols = tableColumns(db, t);
|
||||
const count = db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
|
||||
byTable[t] = { rows: count, columns: cols.map((c) => c.name) };
|
||||
}
|
||||
// Try to infer time span from frame_markers, then any events-shaped table.
|
||||
let frames = null;
|
||||
const candidates = ["frame_markers", "frames", "events", "timing_events", "gpu_events"];
|
||||
for (const c of candidates) {
|
||||
const tbl = findTable(db, [c]);
|
||||
if (!tbl) continue;
|
||||
const cols = tableColumns(db, tbl);
|
||||
const start = pickColumn(cols, ["StartTime", "Time"]);
|
||||
if (!start) continue;
|
||||
const r = db.prepare(`SELECT MIN("${start}") AS t0, MAX("${start}") AS t1, COUNT(*) AS n FROM "${tbl}"`).get();
|
||||
if (r && r.t0 != null && r.t1 != null) {
|
||||
frames = { source: tbl, trace_start_s: r.t0, trace_end_s: r.t1, duration_s: r.t1 - r.t0, count: r.n };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { tables: byTable, time: frames };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- frame stats ----------
|
||||
|
||||
export async function handleFrameStats({ trace_id, hitch_ms = 33, top_n = 20, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
// Prefer dedicated frame_markers table (virtual "Game Frames" thread).
|
||||
// Fall back to filtering events tables by frame-ish names.
|
||||
const fm = findTable(db, ["frame_markers", "frames", "events", "timing_events"]);
|
||||
if (!fm) return err("no frame_markers/events table — TimingInsights.ExportTimingEvents didn't run");
|
||||
const cols = tableColumns(db, fm);
|
||||
const dur = pickColumn(cols, ["Duration", "InclusiveDuration", "ExclusiveDuration"]);
|
||||
const start = pickColumn(cols, ["StartTime", "Time"]);
|
||||
const name = pickColumn(cols, ["TimerName", "Name", "EventName"]);
|
||||
const thread = pickColumn(cols, ["ThreadName", "Thread"]);
|
||||
if (!dur || !start) return err(`couldn't find Duration/StartTime in ${fm}: ${cols.map(c => c.name).join(",")}`);
|
||||
|
||||
let frames;
|
||||
if (fm === "frame_markers") {
|
||||
// "Game Frames" virtual thread emits one event per game frame; in
|
||||
// practice they all sit at Depth=1 (not 0). Filter by thread only.
|
||||
const where = [];
|
||||
if (thread) where.push(`LOWER("${thread}") LIKE '%game%'`);
|
||||
const sql = `SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}"`
|
||||
+ (where.length ? ` WHERE ${where.join(" AND ")}` : "")
|
||||
+ ` ORDER BY "${start}"`;
|
||||
frames = db.prepare(sql).all();
|
||||
} else {
|
||||
const frameFilter = name && thread
|
||||
? `WHERE LOWER("${name}") LIKE '%frame%' AND LOWER("${thread}") LIKE '%game%'`
|
||||
: "";
|
||||
frames = db.prepare(
|
||||
`SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}" ${frameFilter} ORDER BY "${start}"`
|
||||
).all();
|
||||
}
|
||||
if (frames.length === 0) return err("no frames detected — check that 'frame' channel was traced");
|
||||
|
||||
const ms = frames.map((f) => f.ms);
|
||||
ms.sort((a, b) => a - b);
|
||||
const pct = (p) => ms[Math.min(ms.length - 1, Math.floor(p / 100 * ms.length))];
|
||||
const avg = ms.reduce((a, b) => a + b, 0) / ms.length;
|
||||
|
||||
const hitches = frames.filter((f) => f.ms >= hitch_ms)
|
||||
.sort((a, b) => b.ms - a.ms)
|
||||
.slice(0, top_n)
|
||||
.map((f) => ({ start_s: f.start_s, ms: Number(f.ms.toFixed(2)) }));
|
||||
|
||||
return ok({
|
||||
trace_id: t.id,
|
||||
frames: frames.length,
|
||||
avg_ms: Number(avg.toFixed(2)),
|
||||
avg_fps: Number((1000 / avg).toFixed(1)),
|
||||
p50_ms: Number(pct(50).toFixed(2)),
|
||||
p95_ms: Number(pct(95).toFixed(2)),
|
||||
p99_ms: Number(pct(99).toFixed(2)),
|
||||
max_ms: Number(pct(100).toFixed(2)),
|
||||
hitch_threshold_ms: hitch_ms,
|
||||
hitch_count: frames.filter((f) => f.ms >= hitch_ms).length,
|
||||
worst_hitches: hitches,
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- top scopes ----------
|
||||
|
||||
export async function handleTopScopes({ trace_id, thread, limit = 20, sort_by = "incl", project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
// Per-thread tables let us answer "what's heavy on GameThread" without
|
||||
// re-aggregating events. Map common aliases to actual table names.
|
||||
const threadMap = {
|
||||
"game": "timer_stats_game", "gamethread": "timer_stats_game",
|
||||
"render": "timer_stats_render", "renderthread":"timer_stats_render",
|
||||
"rhi": "timer_stats_rhi",
|
||||
"gpu": "timer_stats_gpu",
|
||||
};
|
||||
const tables = listTables(db);
|
||||
let stats = null;
|
||||
if (thread) {
|
||||
const key = thread.toLowerCase().replace(/\s+/g, "");
|
||||
const want = threadMap[key];
|
||||
if (want && tables.includes(want)) stats = want;
|
||||
}
|
||||
if (!stats) {
|
||||
stats = findTable(db, ["timer_stats_all", "timer_stats", "timerstats"]);
|
||||
}
|
||||
if (stats) {
|
||||
const cols = tableColumns(db, stats);
|
||||
// UE 5.8 ExportTimerStatistics columns: Name, Count, C.Avg, Incl, I.Min, I.Max, I.Avg, I.Med, Excl, E.Min, E.Max, E.Avg, E.Med
|
||||
const nameCol = pickColumn(cols, ["Name", "TimerName"]);
|
||||
const inclCol = pickColumn(cols, ["Incl", "TotalInclusive", "InclusiveTime"]);
|
||||
const exclCol = pickColumn(cols, ["Excl", "TotalExclusive", "ExclusiveTime"]);
|
||||
const countCol = pickColumn(cols, ["Count", "Calls"]);
|
||||
const avgIncl = pickColumn(cols, ["I.Avg", "I_Avg", "AvgInclusive"]);
|
||||
const maxIncl = pickColumn(cols, ["I.Max", "I_Max", "MaxInclusive"]);
|
||||
const sortCol = sort_by === "excl" ? exclCol : inclCol;
|
||||
if (nameCol && sortCol) {
|
||||
const parts = [`"${nameCol}" AS name`];
|
||||
if (inclCol) parts.push(`"${inclCol}" AS incl_s`);
|
||||
if (exclCol) parts.push(`"${exclCol}" AS excl_s`);
|
||||
if (avgIncl) parts.push(`"${avgIncl}" AS avg_incl_s`);
|
||||
if (maxIncl) parts.push(`"${maxIncl}" AS max_incl_s`);
|
||||
if (countCol) parts.push(`"${countCol}" AS calls`);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${parts.join(", ")} FROM "${stats}" ORDER BY "${sortCol}" DESC LIMIT ?`
|
||||
).all(limit);
|
||||
return ok({
|
||||
trace_id: t.id, source: stats, thread_scope: thread || "all", sorted_by: sort_by,
|
||||
rows: rows.map(formatScopeRow),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: aggregate from any events table.
|
||||
const events = findTable(db, ["frame_markers", "gpu_events", "events", "timing_events"]);
|
||||
if (!events) return err("no timer_stats_* or events tables available");
|
||||
const cols = tableColumns(db, events);
|
||||
const nameCol = pickColumn(cols, ["TimerName", "Name"]);
|
||||
const durCol = pickColumn(cols, ["Duration", "InclusiveDuration"]);
|
||||
const threadCol = pickColumn(cols, ["ThreadName", "Thread"]);
|
||||
if (!nameCol || !durCol) return err(`events table missing Name/Duration: ${cols.map(c => c.name).join(",")}`);
|
||||
|
||||
const where = thread && threadCol ? `WHERE LOWER("${threadCol}") LIKE ?` : "";
|
||||
const args = thread && threadCol ? [`%${thread.toLowerCase()}%`] : [];
|
||||
const rows = db.prepare(
|
||||
`SELECT "${nameCol}" AS name, COUNT(*) AS calls, SUM("${durCol}") AS incl_s, AVG("${durCol}") AS avg_incl_s, MAX("${durCol}") AS max_incl_s ` +
|
||||
`FROM "${events}" ${where} GROUP BY "${nameCol}" ORDER BY incl_s DESC LIMIT ?`
|
||||
).all(...args, limit);
|
||||
return ok({
|
||||
trace_id: t.id, source: events, sorted_by: "incl (aggregated from events)",
|
||||
rows: rows.map(formatScopeRow),
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function formatScopeRow(r) {
|
||||
const out = { ...r };
|
||||
for (const k of ["incl_s", "excl_s", "avg_s", "max_s", "avg_incl_s", "max_incl_s", "min_incl_s"]) {
|
||||
if (out[k] != null) {
|
||||
out[k.replace("_s", "_ms")] = Number((out[k] * 1000).toFixed(3));
|
||||
delete out[k];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- scope detail ----------
|
||||
|
||||
export async function handleScopeDetail({ trace_id, name, project_dir }) {
|
||||
if (!name) return err("scope_detail: name required");
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
const events = findTable(db, ["events", "timing_events"]);
|
||||
if (!events) return err("no events table");
|
||||
const cols = tableColumns(db, events);
|
||||
const nameCol = pickColumn(cols, ["Name", "TimerName"]);
|
||||
const durCol = pickColumn(cols, ["Duration", "InclusiveDuration"]);
|
||||
const startCol = pickColumn(cols, ["StartTime", "Time"]);
|
||||
const threadCol = pickColumn(cols, ["ThreadName", "Thread"]);
|
||||
if (!nameCol || !durCol) return err("events table missing Name/Duration");
|
||||
|
||||
const calls = db.prepare(
|
||||
`SELECT COUNT(*) AS n, SUM("${durCol}") AS sum_s, AVG("${durCol}") AS avg_s, MAX("${durCol}") AS max_s, MIN("${durCol}") AS min_s FROM "${events}" WHERE "${nameCol}" = ?`
|
||||
).get(name);
|
||||
if (!calls || calls.n === 0) return err(`scope not found: ${name}`);
|
||||
|
||||
const worst = db.prepare(
|
||||
`SELECT "${startCol || "rowid"}" AS start, "${durCol}"*1000 AS ms${threadCol ? `, "${threadCol}" AS thread` : ""} FROM "${events}" WHERE "${nameCol}" = ? ORDER BY "${durCol}" DESC LIMIT 10`
|
||||
).all(name);
|
||||
|
||||
return ok({
|
||||
trace_id: t.id,
|
||||
scope: name,
|
||||
calls: calls.n,
|
||||
total_ms: Number((calls.sum_s * 1000).toFixed(2)),
|
||||
avg_ms: Number((calls.avg_s * 1000).toFixed(3)),
|
||||
min_ms: Number((calls.min_s * 1000).toFixed(3)),
|
||||
max_ms: Number((calls.max_s * 1000).toFixed(3)),
|
||||
worst_instances: worst.map((w) => ({ ...w, ms: Number(w.ms.toFixed(3)) })),
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- gpu top ----------
|
||||
|
||||
export async function handleGpuTop({ trace_id, limit = 20, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
// Prefer the per-GPU aggregated stats table (small, pre-sorted).
|
||||
const stats = findTable(db, ["timer_stats_gpu"]);
|
||||
if (stats) {
|
||||
const cols = tableColumns(db, stats);
|
||||
const nameCol = pickColumn(cols, ["Name"]);
|
||||
const inclCol = pickColumn(cols, ["Incl"]);
|
||||
const countCol = pickColumn(cols, ["Count"]);
|
||||
const avgIncl = pickColumn(cols, ["I.Avg"]);
|
||||
const maxIncl = pickColumn(cols, ["I.Max"]);
|
||||
if (nameCol && inclCol) {
|
||||
const rows = db.prepare(
|
||||
`SELECT "${nameCol}" AS name, "${inclCol}" AS incl_s` +
|
||||
(countCol ? `, "${countCol}" AS calls` : "") +
|
||||
(avgIncl ? `, "${avgIncl}" AS avg_incl_s` : "") +
|
||||
(maxIncl ? `, "${maxIncl}" AS max_incl_s` : "") +
|
||||
` FROM "${stats}" ORDER BY "${inclCol}" DESC LIMIT ?`
|
||||
).all(limit);
|
||||
return ok({ trace_id: t.id, source: stats, rows: rows.map(formatScopeRow) });
|
||||
}
|
||||
}
|
||||
// Fallback: aggregate gpu_events.
|
||||
const gev = findTable(db, ["gpu_events"]);
|
||||
if (!gev) return ok({ trace_id: t.id, note: "no GPU data — was the 'gpu' channel enabled when recording?" });
|
||||
const cols = tableColumns(db, gev);
|
||||
const nameCol = pickColumn(cols, ["TimerName", "Name"]);
|
||||
const durCol = pickColumn(cols, ["Duration"]);
|
||||
if (!nameCol || !durCol) return err("gpu_events table missing Name/Duration");
|
||||
const rows = db.prepare(
|
||||
`SELECT "${nameCol}" AS name, COUNT(*) AS calls, SUM("${durCol}") AS incl_s, AVG("${durCol}") AS avg_incl_s, MAX("${durCol}") AS max_incl_s ` +
|
||||
`FROM "${gev}" GROUP BY "${nameCol}" ORDER BY incl_s DESC LIMIT ?`
|
||||
).all(limit);
|
||||
return ok({ trace_id: t.id, source: gev, rows: rows.map(formatScopeRow) });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- memory ----------
|
||||
|
||||
export async function handleMemoryOverview({ trace_id, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
const memtags = findTable(db, ["memtags", "llm_tags"]);
|
||||
if (!memtags) return ok({ trace_id: t.id, note: "no memtags table — record with -trace=memory" });
|
||||
const cols = tableColumns(db, memtags);
|
||||
const peakCol = pickColumn(cols, ["Peak", "PeakBytes", "PeakMB"]);
|
||||
const curCol = pickColumn(cols, ["Current", "CurrentBytes", "CurrentMB", "Size"]);
|
||||
const nameCol = pickColumn(cols, ["Name", "Tag", "TagName"]);
|
||||
if (!nameCol) return err("memtags table missing Name");
|
||||
const orderBy = peakCol || curCol;
|
||||
if (!orderBy) {
|
||||
const all = db.prepare(`SELECT * FROM "${memtags}" LIMIT 200`).all();
|
||||
return ok({ trace_id: t.id, note: "no numeric size column detected — returning raw rows", rows: all });
|
||||
}
|
||||
const rows = db.prepare(`SELECT * FROM "${memtags}" ORDER BY "${orderBy}" DESC LIMIT 50`).all();
|
||||
return ok({ trace_id: t.id, sorted_by: orderBy, rows });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
|
||||
export async function handleLoadingTop({ trace_id, limit = 30, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
const tbl = findTable(db, ["loadtime_events", "loadtime", "loadtime_summary"]);
|
||||
if (!tbl) return ok({ trace_id: t.id, note: "no loadtime table — record with -trace=loadtime,asset" });
|
||||
const cols = tableColumns(db, tbl);
|
||||
const nameCol = pickColumn(cols, ["Name", "Package", "Asset", "AssetName", "PackageName"]);
|
||||
const durCol = pickColumn(cols, ["Duration", "LoadTime", "InclusiveTime", "TotalDuration"]);
|
||||
if (!nameCol || !durCol) {
|
||||
const sample = db.prepare(`SELECT * FROM "${tbl}" LIMIT ${limit}`).all();
|
||||
return ok({ trace_id: t.id, note: "couldn't pick Name/Duration; raw sample", columns: cols.map(c => c.name), rows: sample });
|
||||
}
|
||||
const rows = db.prepare(
|
||||
`SELECT "${nameCol}" AS name, "${durCol}" AS s FROM "${tbl}" ORDER BY "${durCol}" DESC LIMIT ?`
|
||||
).all(limit);
|
||||
return ok({
|
||||
trace_id: t.id, source: tbl,
|
||||
rows: rows.map((r) => ({ name: r.name, ms: Number((r.s * 1000).toFixed(2)) })),
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- compare ----------
|
||||
|
||||
export async function handleCompareTraces({ trace_a, trace_b, project_dir }) {
|
||||
if (!trace_a || !trace_b) return err("compare_traces: trace_a and trace_b required");
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const A = await handleFrameStats({ trace_id: trace_a, project_dir });
|
||||
const B = await handleFrameStats({ trace_id: trace_b, project_dir });
|
||||
if (A.isError) return A;
|
||||
if (B.isError) return B;
|
||||
const a = JSON.parse(A.content[0].text);
|
||||
const b = JSON.parse(B.content[0].text);
|
||||
|
||||
const dt = (k) => ({ a: a[k], b: b[k], delta: Number((b[k] - a[k]).toFixed(3)), pct: a[k] ? Number((100 * (b[k] - a[k]) / a[k]).toFixed(1)) : null });
|
||||
// Top scopes diff
|
||||
const scopesA = JSON.parse((await handleTopScopes({ trace_id: trace_a, limit: 50, project_dir })).content[0].text).rows || [];
|
||||
const scopesB = JSON.parse((await handleTopScopes({ trace_id: trace_b, limit: 50, project_dir })).content[0].text).rows || [];
|
||||
const mapA = new Map(scopesA.map((r) => [r.name, r.incl_ms ?? r.excl_ms ?? 0]));
|
||||
const mapB = new Map(scopesB.map((r) => [r.name, r.incl_ms ?? r.excl_ms ?? 0]));
|
||||
const allNames = new Set([...mapA.keys(), ...mapB.keys()]);
|
||||
const scopeDiff = [];
|
||||
for (const n of allNames) {
|
||||
const va = mapA.get(n) ?? 0;
|
||||
const vb = mapB.get(n) ?? 0;
|
||||
if (va === 0 && vb === 0) continue;
|
||||
scopeDiff.push({ name: n, a_ms: va, b_ms: vb, delta_ms: Number((vb - va).toFixed(2)) });
|
||||
}
|
||||
scopeDiff.sort((x, y) => Math.abs(y.delta_ms) - Math.abs(x.delta_ms));
|
||||
|
||||
return ok({
|
||||
a: trace_a, b: trace_b,
|
||||
frame_stats: {
|
||||
avg_ms: dt("avg_ms"), p95_ms: dt("p95_ms"), p99_ms: dt("p99_ms"),
|
||||
max_ms: dt("max_ms"), hitch_count: dt("hitch_count"),
|
||||
},
|
||||
top_changed_scopes: scopeDiff.slice(0, 20),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- raw SQL ----------
|
||||
|
||||
export async function handleQuery({ trace_id, sql, project_dir }) {
|
||||
if (!sql) return err("query: sql required");
|
||||
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|PRAGMA)\b/i.test(sql)) {
|
||||
return err("query: read-only SELECT statements only");
|
||||
}
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const db = openDb(traceDir(t.id, projectDir));
|
||||
try {
|
||||
const rows = db.prepare(sql).all();
|
||||
return ok({ trace_id: t.id, rows: rows.slice(0, 1000), row_count: rows.length });
|
||||
} catch (e) {
|
||||
return err(`SQL error: ${e.message}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- report ----------
|
||||
|
||||
export async function handleReport({ trace_id, save = true, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
|
||||
const frames = parseOr(await handleFrameStats({ trace_id, project_dir }));
|
||||
const scopes = parseOr(await handleTopScopes({ trace_id, limit: 15, project_dir }));
|
||||
const gpu = parseOr(await handleGpuTop({ trace_id, limit: 10, project_dir }));
|
||||
const mem = parseOr(await handleMemoryOverview({ trace_id, project_dir }));
|
||||
const load = parseOr(await handleLoadingTop({ trace_id, limit: 15, project_dir }));
|
||||
|
||||
const lines = [];
|
||||
lines.push(`# Insights report — ${t.label}`);
|
||||
lines.push(`Trace: \`${t.trace_path}\``);
|
||||
lines.push("");
|
||||
if (frames && !frames.error) {
|
||||
lines.push(`## Frames`);
|
||||
lines.push(`- ${frames.frames} frames, avg ${frames.avg_ms}ms (${frames.avg_fps} fps)`);
|
||||
lines.push(`- p95: ${frames.p95_ms}ms, p99: ${frames.p99_ms}ms, max: ${frames.max_ms}ms`);
|
||||
lines.push(`- hitches >${frames.hitch_threshold_ms}ms: ${frames.hitch_count}`);
|
||||
if (frames.worst_hitches?.length) {
|
||||
lines.push(`### Worst hitches`);
|
||||
for (const h of frames.worst_hitches.slice(0, 5)) {
|
||||
lines.push(`- t=${h.start_s.toFixed(2)}s — ${h.ms}ms`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (scopes?.rows?.length) {
|
||||
lines.push(`## Top scopes (inclusive)`);
|
||||
for (const r of scopes.rows.slice(0, 10)) {
|
||||
lines.push(`- ${r.name} — ${r.incl_ms ?? r.excl_ms ?? "?"}ms (calls: ${r.calls ?? "?"})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (gpu?.rows?.length) {
|
||||
lines.push(`## GPU top`);
|
||||
for (const r of gpu.rows.slice(0, 8)) {
|
||||
lines.push(`- ${r.name} — ${r.incl_ms ?? "?"}ms`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (mem?.rows?.length) {
|
||||
lines.push(`## Memory tags`);
|
||||
for (const r of mem.rows.slice(0, 10)) {
|
||||
lines.push(`- ${JSON.stringify(r)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (load?.rows?.length) {
|
||||
lines.push(`## Slowest loads`);
|
||||
for (const r of load.rows.slice(0, 10)) {
|
||||
lines.push(`- ${r.name} — ${r.ms}ms`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const md = lines.join("\n");
|
||||
let savedPath = null;
|
||||
if (save) {
|
||||
savedPath = path.join(traceDir(t.id, projectDir), "report.md");
|
||||
fs.writeFileSync(savedPath, md);
|
||||
}
|
||||
return ok({ trace_id: t.id, saved_to: savedPath, markdown: md });
|
||||
}
|
||||
|
||||
function parseOr(res) {
|
||||
if (!res || !res.content) return null;
|
||||
try { const o = JSON.parse(res.content[0].text); return res.isError ? { error: res.content[0].text } : o; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------- charts ----------
|
||||
|
||||
export async function handleRenderCharts({ trace_id, hitch_ms = 33, project_dir }) {
|
||||
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
|
||||
const t = resolveTrace(trace_id, projectDir);
|
||||
if (!t) return err(`trace not found: ${trace_id}`);
|
||||
const dir = traceDir(t.id, projectDir);
|
||||
const chartsDir = path.join(dir, "charts");
|
||||
fs.mkdirSync(chartsDir, { recursive: true });
|
||||
|
||||
const db = openDb(dir);
|
||||
let sections, frameCount = 0, hitchCount = 0;
|
||||
try {
|
||||
// ----- frames
|
||||
const fm = findTable(db, ["frame_markers"]);
|
||||
let frames = [];
|
||||
if (fm) {
|
||||
const cols = tableColumns(db, fm);
|
||||
const start = pickColumn(cols, ["StartTime", "Time"]);
|
||||
const dur = pickColumn(cols, ["Duration"]);
|
||||
const thread = pickColumn(cols, ["ThreadName"]);
|
||||
if (start && dur) {
|
||||
const where = thread ? `WHERE LOWER("${thread}") LIKE '%game%'` : "";
|
||||
frames = db.prepare(
|
||||
`SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}" ${where} ORDER BY "${start}"`
|
||||
).all();
|
||||
}
|
||||
}
|
||||
frameCount = frames.length;
|
||||
hitchCount = frames.filter((f) => f.ms >= hitch_ms).length;
|
||||
|
||||
// ----- per-thread totals (from timer_stats_* tables)
|
||||
const tables = listTables(db);
|
||||
const threadTotals = [];
|
||||
const threadMap = [
|
||||
["GameThread", "timer_stats_game"],
|
||||
["RenderThread", "timer_stats_render"],
|
||||
["RHIThread", "timer_stats_rhi"],
|
||||
["GPU", "timer_stats_gpu"],
|
||||
];
|
||||
for (const [thrName, tbl] of threadMap) {
|
||||
if (!tables.includes(tbl)) continue;
|
||||
const cols = tableColumns(db, tbl);
|
||||
const inclCol = pickColumn(cols, ["Incl"]);
|
||||
if (!inclCol) continue;
|
||||
// Sum of top-level (those without children inflating) is unreliable; use MAX
|
||||
// of root scopes — but simplest: take MAX(Incl) across the table as a proxy
|
||||
// for "thread total time". For "Frame" timer that == frame total.
|
||||
const r = db.prepare(`SELECT MAX("${inclCol}") AS max_incl FROM "${tbl}"`).get();
|
||||
threadTotals.push({ thread: thrName, total_s: r?.max_incl || 0 });
|
||||
}
|
||||
|
||||
// ----- top scopes per thread (exclusive — what actually runs there)
|
||||
function topFromTable(tbl, label, metric) {
|
||||
if (!tables.includes(tbl)) return null;
|
||||
const cols = tableColumns(db, tbl);
|
||||
const nameCol = pickColumn(cols, ["Name"]);
|
||||
const inclCol = pickColumn(cols, ["Incl"]);
|
||||
const exclCol = pickColumn(cols, ["Excl"]);
|
||||
const countCol = pickColumn(cols, ["Count"]);
|
||||
const sortCol = metric === "excl" ? exclCol : inclCol;
|
||||
if (!nameCol || !sortCol) return null;
|
||||
const rows = db.prepare(
|
||||
`SELECT "${nameCol}" AS name, "${inclCol}"*1000 AS incl_ms, "${exclCol}"*1000 AS excl_ms, "${countCol}" AS calls FROM "${tbl}" ORDER BY "${sortCol}" DESC LIMIT 15`
|
||||
).all();
|
||||
return { label, rows, metric };
|
||||
}
|
||||
const topGameExcl = topFromTable("timer_stats_game", "GameThread — top by EXCLUSIVE (what it actually does)", "excl");
|
||||
const topRenderExcl = topFromTable("timer_stats_render", "RenderThread — top by EXCLUSIVE", "excl");
|
||||
const topRhi = topFromTable("timer_stats_rhi", "RHIThread — top by INCLUSIVE", "incl");
|
||||
const topGpu = topFromTable("timer_stats_gpu", "GPU — top by INCLUSIVE", "incl");
|
||||
|
||||
// ----- compose dashboard
|
||||
sections = [];
|
||||
sections.push({ full: true, svg: frameTimeline({ frames, hitchMs: hitch_ms, title: "Frame time (log Y) — every spike is a hitch" }),
|
||||
note: "X axis = minutes into trace. Orange line = hitch threshold. Red dots = hitches." });
|
||||
sections.push({ svg: frameHistogram({ frames, title: "Frame time distribution" }),
|
||||
note: "Green = ≤16.6ms (60fps). Yellow = ≤33ms (30fps). Red = hitch." });
|
||||
sections.push({ svg: threadBreakdown({ rows: threadTotals, title: "Thread time totals (proxy: max Incl per thread)" }),
|
||||
note: "Rough comparison — GameThread is usually limiter." });
|
||||
if (topGameExcl) sections.push({ svg: topScopesBar({ rows: topGameExcl.rows, title: topGameExcl.label, metric: "excl_ms" }) });
|
||||
if (topRenderExcl) sections.push({ svg: topScopesBar({ rows: topRenderExcl.rows, title: topRenderExcl.label, metric: "excl_ms" }) });
|
||||
if (topRhi) sections.push({ svg: topScopesBar({ rows: topRhi.rows, title: topRhi.label, metric: "incl_ms" }) });
|
||||
if (topGpu) sections.push({ svg: topScopesBar({ rows: topGpu.rows, title: topGpu.label, metric: "incl_ms" }) });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Write individual SVGs (handy if someone wants to embed one) + dashboard.
|
||||
const written = [];
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const p = path.join(chartsDir, `chart_${String(i).padStart(2, "0")}.svg`);
|
||||
fs.writeFileSync(p, sections[i].svg);
|
||||
written.push(p);
|
||||
}
|
||||
const html = buildDashboard({ label: t.label, traceId: t.id, sections });
|
||||
const htmlPath = path.join(chartsDir, "dashboard.html");
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
|
||||
return ok({
|
||||
trace_id: t.id,
|
||||
dashboard: htmlPath,
|
||||
svgs: written,
|
||||
summary: {
|
||||
frames: frameCount,
|
||||
hitches: hitchCount,
|
||||
memory_note: "No memory/LLM channels in this trace — record with -trace=...,memory,llm next time for RAM charts.",
|
||||
},
|
||||
open: `file:///${htmlPath.replace(/\\/g, "/")}`,
|
||||
});
|
||||
}
|
||||
201
src/insightsChart.js
Normal file
201
src/insightsChart.js
Normal file
@ -0,0 +1,201 @@
|
||||
// Pure-SVG chart builders. No dependencies. Each function returns an
|
||||
// `<svg>...</svg>` string sized to (width, height). Bundled together into
|
||||
// a single HTML by the caller — open in any browser, no server needed.
|
||||
//
|
||||
// Style philosophy: dense, professional, monospace-ish. Designed to be
|
||||
// scannable in a dashboard at 100% zoom. Dark theme to reduce eye strain
|
||||
// when staring at perf data for an hour.
|
||||
|
||||
const COLORS = {
|
||||
bg: "#1e1e2e",
|
||||
panel: "#26273a",
|
||||
axis: "#666",
|
||||
grid: "#33344a",
|
||||
text: "#cdd6f4",
|
||||
muted: "#8b8fa0",
|
||||
good: "#a6e3a1",
|
||||
warn: "#f9e2af",
|
||||
bad: "#f38ba8",
|
||||
bar: "#89b4fa",
|
||||
bar2: "#cba6f7",
|
||||
line: "#94e2d5",
|
||||
hitch: "#fab387",
|
||||
};
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
function fmt(n) { if (n == null) return "—"; if (n >= 1000) return n.toFixed(0); if (n >= 10) return n.toFixed(1); return n.toFixed(2); }
|
||||
|
||||
// ----- frame timeline: ms per frame across time, log y (frames cluster low + hitch spikes)
|
||||
export function frameTimeline({ frames, width = 1100, height = 280, hitchMs = 33, title = "Frame time" }) {
|
||||
if (!frames.length) return placeholder(width, height, "no frames");
|
||||
const padL = 60, padR = 20, padT = 30, padB = 40;
|
||||
const W = width - padL - padR, H = height - padT - padB;
|
||||
const tMin = frames[0].start_s;
|
||||
const tMax = frames[frames.length - 1].start_s;
|
||||
const tRange = Math.max(tMax - tMin, 1);
|
||||
// Log scale on Y (frames are 5–30ms typical, hitches 100–4000ms)
|
||||
const yMin = 1, yMax = Math.max(50, ...frames.map((f) => f.ms));
|
||||
const ly = (ms) => H - (Math.log10(Math.max(ms, yMin)) - Math.log10(yMin)) / (Math.log10(yMax) - Math.log10(yMin)) * H;
|
||||
const xt = (t) => (t - tMin) / tRange * W;
|
||||
|
||||
// Downsample to ~2000 points for SVG perf
|
||||
const stride = Math.max(1, Math.ceil(frames.length / 2000));
|
||||
const pts = [];
|
||||
for (let i = 0; i < frames.length; i += stride) {
|
||||
const f = frames[i];
|
||||
pts.push(`${xt(f.start_s).toFixed(1)},${ly(f.ms).toFixed(1)}`);
|
||||
}
|
||||
// Hitch markers: every frame > hitchMs (no downsample for these)
|
||||
const hitches = frames.filter((f) => f.ms >= hitchMs);
|
||||
const hitchDots = hitches.map((f) => `<circle cx="${xt(f.start_s).toFixed(1)}" cy="${ly(f.ms).toFixed(1)}" r="2" fill="${COLORS.bad}"/>`).join("");
|
||||
|
||||
// Y-axis ticks: log decades
|
||||
const ticks = [];
|
||||
for (const v of [5, 16.6, 33, 100, 500, 1000, 5000]) {
|
||||
if (v >= yMin && v <= yMax) {
|
||||
const y = ly(v) + padT;
|
||||
ticks.push(`<line x1="${padL}" x2="${padL + W}" y1="${y}" y2="${y}" stroke="${COLORS.grid}" stroke-width="1"/>` +
|
||||
`<text x="${padL - 6}" y="${y + 3}" fill="${COLORS.muted}" font-size="10" text-anchor="end">${v}ms</text>`);
|
||||
}
|
||||
}
|
||||
// Hitch threshold line
|
||||
const hy = ly(hitchMs) + padT;
|
||||
|
||||
// X-axis ticks: every 60s
|
||||
const xticks = [];
|
||||
for (let t = Math.ceil(tMin / 60) * 60; t <= tMax; t += 60) {
|
||||
const x = xt(t) + padL;
|
||||
xticks.push(`<line x1="${x}" x2="${x}" y1="${padT}" y2="${padT + H}" stroke="${COLORS.grid}" stroke-width="1"/>` +
|
||||
`<text x="${x}" y="${height - 12}" fill="${COLORS.muted}" font-size="10" text-anchor="middle">${(t/60).toFixed(0)}m</text>`);
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
|
||||
<text x="${padL}" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)} <tspan fill="${COLORS.muted}">— ${frames.length} frames, ${hitches.length} hitches >${hitchMs}ms</tspan></text>
|
||||
${xticks.join("")}
|
||||
${ticks.join("")}
|
||||
<line x1="${padL}" x2="${padL + W}" y1="${hy}" y2="${hy}" stroke="${COLORS.hitch}" stroke-width="1" stroke-dasharray="4,3" opacity="0.7"/>
|
||||
<text x="${padL + W - 4}" y="${hy - 4}" fill="${COLORS.hitch}" font-size="10" text-anchor="end">hitch threshold ${hitchMs}ms</text>
|
||||
<polyline points="${pts.join(" ")}" fill="none" stroke="${COLORS.line}" stroke-width="0.8" opacity="0.8" transform="translate(${padL},${padT})"/>
|
||||
<g transform="translate(${padL},${padT})">${hitchDots}</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----- frame histogram: bucketed distribution
|
||||
export function frameHistogram({ frames, width = 540, height = 280, title = "Frame time distribution" }) {
|
||||
if (!frames.length) return placeholder(width, height, "no frames");
|
||||
const padL = 40, padR = 20, padT = 30, padB = 40;
|
||||
const W = width - padL - padR, H = height - padT - padB;
|
||||
// Log-spaced buckets up to max
|
||||
const max = Math.max(...frames.map((f) => f.ms));
|
||||
const edges = [0, 8, 12, 16.6, 25, 33, 50, 100, 200, 500, 1000, 2000, 5000];
|
||||
while (edges[edges.length - 1] < max) edges.push(edges[edges.length - 1] * 2);
|
||||
const counts = new Array(edges.length - 1).fill(0);
|
||||
for (const f of frames) {
|
||||
for (let i = 1; i < edges.length; i++) {
|
||||
if (f.ms < edges[i]) { counts[i - 1]++; break; }
|
||||
}
|
||||
}
|
||||
const maxN = Math.max(...counts, 1);
|
||||
const bw = W / counts.length;
|
||||
const bars = counts.map((n, i) => {
|
||||
const h = n / maxN * H;
|
||||
const color = edges[i + 1] <= 16.6 ? COLORS.good : edges[i + 1] <= 33 ? COLORS.warn : COLORS.bad;
|
||||
const tt = `${edges[i]}–${edges[i + 1]}ms: ${n} frames`;
|
||||
return `<g><rect x="${padL + i * bw + 1}" y="${padT + H - h}" width="${bw - 2}" height="${h}" fill="${color}" opacity="0.85"><title>${tt}</title></rect>` +
|
||||
`<text x="${padL + i * bw + bw / 2}" y="${height - 12}" fill="${COLORS.muted}" font-size="9" text-anchor="middle">${edges[i + 1] >= 1000 ? (edges[i + 1] / 1000) + "s" : edges[i + 1]}</text></g>`;
|
||||
}).join("");
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
|
||||
<text x="${padL}" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
|
||||
<text x="${padL}" y="${padT + H - 4}" fill="${COLORS.muted}" font-size="9">0</text>
|
||||
<text x="${padL}" y="${padT + 8}" fill="${COLORS.muted}" font-size="9">${maxN}</text>
|
||||
${bars}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----- horizontal bar chart of top scopes
|
||||
export function topScopesBar({ rows, width = 540, height = 360, title, metric = "incl_ms" }) {
|
||||
if (!rows || !rows.length) return placeholder(width, height, "no scopes");
|
||||
const padL = 220, padR = 70, padT = 30, padB = 20;
|
||||
const W = width - padL - padR, H = height - padT - padB;
|
||||
const data = rows.slice(0, 15);
|
||||
const max = Math.max(...data.map((r) => r[metric] || 0), 1);
|
||||
const rowH = H / data.length;
|
||||
const bars = data.map((r, i) => {
|
||||
const v = r[metric] || 0;
|
||||
const bw = v / max * W;
|
||||
const y = padT + i * rowH;
|
||||
const cy = y + rowH / 2;
|
||||
const label = String(r.name).slice(0, 30);
|
||||
return `<text x="${padL - 8}" y="${cy + 3}" fill="${COLORS.text}" font-size="10" text-anchor="end" font-family="monospace">${esc(label)}</text>` +
|
||||
`<rect x="${padL}" y="${y + 3}" width="${bw}" height="${rowH - 6}" fill="${COLORS.bar}" opacity="0.9"/>` +
|
||||
`<text x="${padL + bw + 4}" y="${cy + 3}" fill="${COLORS.muted}" font-size="10" font-family="monospace">${fmt(v)}${metric.endsWith("_ms") ? "ms" : ""}${r.calls ? ` ×${r.calls}` : ""}</text>`;
|
||||
}).join("");
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
|
||||
<text x="12" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
|
||||
${bars}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----- thread breakdown bar (per-thread total inclusive)
|
||||
export function threadBreakdown({ rows, width = 540, height = 220, title = "Per-thread total time" }) {
|
||||
if (!rows || !rows.length) return placeholder(width, height, "no data");
|
||||
const padL = 130, padR = 80, padT = 30, padB = 20;
|
||||
const W = width - padL - padR, H = height - padT - padB;
|
||||
const max = Math.max(...rows.map((r) => r.total_s || 0), 1);
|
||||
const rowH = H / rows.length;
|
||||
const palette = [COLORS.bar, COLORS.bar2, COLORS.line, COLORS.warn, COLORS.bad];
|
||||
const bars = rows.map((r, i) => {
|
||||
const v = r.total_s || 0;
|
||||
const bw = v / max * W;
|
||||
const y = padT + i * rowH;
|
||||
const cy = y + rowH / 2;
|
||||
return `<text x="${padL - 8}" y="${cy + 3}" fill="${COLORS.text}" font-size="11" text-anchor="end" font-family="monospace">${esc(r.thread)}</text>` +
|
||||
`<rect x="${padL}" y="${y + 4}" width="${bw}" height="${rowH - 8}" fill="${palette[i % palette.length]}" opacity="0.85"/>` +
|
||||
`<text x="${padL + bw + 4}" y="${cy + 3}" fill="${COLORS.muted}" font-size="10" font-family="monospace">${fmt(v)}s</text>`;
|
||||
}).join("");
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
|
||||
<text x="12" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
|
||||
${bars}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function placeholder(w, h, msg) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
|
||||
<rect width="${w}" height="${h}" fill="${COLORS.panel}"/>
|
||||
<text x="${w/2}" y="${h/2}" fill="${COLORS.muted}" font-size="13" text-anchor="middle" font-family="system-ui">${esc(msg)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Build a single HTML page that embeds all SVGs side by side, dark theme.
|
||||
export function buildDashboard({ label, traceId, sections }) {
|
||||
const css = `
|
||||
body { margin: 0; background: ${COLORS.bg}; color: ${COLORS.text}; font-family: system-ui; }
|
||||
header { padding: 16px 24px; border-bottom: 1px solid ${COLORS.grid}; }
|
||||
h1 { margin: 0; font-size: 18px; }
|
||||
.sub { color: ${COLORS.muted}; font-size: 13px; margin-top: 4px; }
|
||||
main { padding: 20px 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.full { grid-column: 1 / -1; }
|
||||
.card { background: ${COLORS.panel}; border-radius: 8px; overflow: hidden; }
|
||||
.note { color: ${COLORS.muted}; font-size: 12px; padding: 8px 14px; border-top: 1px solid ${COLORS.grid}; }
|
||||
svg { display: block; width: 100%; height: auto; }
|
||||
`;
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Insights ${esc(label)}</title><style>${css}</style></head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Insights Dashboard — ${esc(label)}</h1>
|
||||
<div class="sub">trace_id <code>${esc(traceId)}</code> · generated ${new Date().toISOString()}</div>
|
||||
</header>
|
||||
<main>
|
||||
${sections.map((s) => `<div class="card${s.full ? " full" : ""}">${s.svg}${s.note ? `<div class="note">${esc(s.note)}</div>` : ""}</div>`).join("\n")}
|
||||
</main>
|
||||
</body></html>`;
|
||||
return html;
|
||||
}
|
||||
207
src/insightsDb.js
Normal file
207
src/insightsDb.js
Normal file
@ -0,0 +1,207 @@
|
||||
// sqlite cache for parsed Insights exports.
|
||||
//
|
||||
// Design choice: schema is *inferred* from each TSV's header row, one table
|
||||
// per file (basename → table). Insights renames/reorders columns between UE
|
||||
// versions; pinning a hand-written schema would break on every upgrade. The
|
||||
// analytics layer probes columns at query time and adapts.
|
||||
//
|
||||
// Numeric columns are detected by sniffing the first 50 non-empty values.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
|
||||
let Database;
|
||||
try {
|
||||
({ default: Database } = await import("better-sqlite3"));
|
||||
} catch (e) {
|
||||
// surfaced lazily at first call
|
||||
Database = null;
|
||||
}
|
||||
|
||||
export function dbAvailable() { return Database !== null; }
|
||||
|
||||
export function openDb(cacheDir) {
|
||||
if (!Database) throw new Error("better-sqlite3 not installed. Run `npm install` in Plugins/UEBlueprintMCP.");
|
||||
const dbPath = path.join(cacheDir, "cache.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
return db;
|
||||
}
|
||||
|
||||
const TABLE_SAFE = /^[a-z0-9_]+$/i;
|
||||
|
||||
function sanitizeIdent(s) {
|
||||
return s.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "") || "col";
|
||||
}
|
||||
|
||||
// Returns array of cleaned, unique column names matching original order.
|
||||
function cleanHeaders(headers) {
|
||||
const seen = new Map();
|
||||
return headers.map((h) => {
|
||||
let name = sanitizeIdent(h.trim());
|
||||
if (!name) name = "col";
|
||||
if (/^\d/.test(name)) name = "_" + name;
|
||||
const n = (seen.get(name) || 0) + 1;
|
||||
seen.set(name, n);
|
||||
return n === 1 ? name : `${name}_${n}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Streams a TSV/CSV file, returns { headers, types, rows }.
|
||||
// types: "INTEGER" | "REAL" | "TEXT" per column.
|
||||
export async function parseTabular(filePath) {
|
||||
// Insights is inconsistent: ExportTimerStatistics writes comma-separated
|
||||
// data into a .tsv file. So always sniff — don't trust the extension.
|
||||
const sep = await sniffSeparator(filePath);
|
||||
|
||||
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
let headers = null;
|
||||
const sniff = []; // first ~50 rows used for type inference
|
||||
const allRows = [];
|
||||
let lineNo = 0;
|
||||
for await (const raw of rl) {
|
||||
lineNo++;
|
||||
if (!raw) continue;
|
||||
// Insights sometimes emits a leading "Sep=," BOM-style line — skip.
|
||||
if (lineNo === 1 && /^sep=/i.test(raw)) continue;
|
||||
const cells = splitRow(raw, sep);
|
||||
if (!headers) {
|
||||
headers = cleanHeaders(cells);
|
||||
continue;
|
||||
}
|
||||
allRows.push(cells);
|
||||
if (sniff.length < 50) sniff.push(cells);
|
||||
}
|
||||
if (!headers) return { headers: [], types: [], rows: [] };
|
||||
|
||||
const types = headers.map((_, i) => inferType(sniff.map((r) => r[i])));
|
||||
return { headers, types, rows: allRows };
|
||||
}
|
||||
|
||||
async function sniffSeparator(filePath) {
|
||||
const fd = await fs.promises.open(filePath, "r");
|
||||
const buf = Buffer.alloc(4096);
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
|
||||
await fd.close();
|
||||
const head = buf.slice(0, bytesRead).toString("utf8");
|
||||
const firstLine = head.split(/\r?\n/, 1)[0] || "";
|
||||
const tabs = (firstLine.match(/\t/g) || []).length;
|
||||
const commas = (firstLine.match(/,/g) || []).length;
|
||||
return tabs >= commas ? "\t" : ",";
|
||||
}
|
||||
|
||||
// Minimal CSV-aware splitter. Handles "quoted, fields" with "" escapes.
|
||||
// For TAB separator we don't bother with quoting (Insights doesn't quote tabs).
|
||||
function splitRow(line, sep) {
|
||||
if (sep === "\t") return line.split("\t");
|
||||
const out = [];
|
||||
let cur = "";
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuote) {
|
||||
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
||||
else if (ch === '"') inQuote = false;
|
||||
else cur += ch;
|
||||
} else {
|
||||
if (ch === '"') inQuote = true;
|
||||
else if (ch === sep) { out.push(cur); cur = ""; }
|
||||
else cur += ch;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferType(samples) {
|
||||
let int = true, real = true, seenAny = false;
|
||||
for (const s of samples) {
|
||||
if (s === undefined || s === null || s === "") continue;
|
||||
seenAny = true;
|
||||
const v = s.trim();
|
||||
if (!/^-?\d+$/.test(v)) int = false;
|
||||
if (!/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(v)) real = false;
|
||||
if (!int && !real) break;
|
||||
}
|
||||
if (!seenAny) return "TEXT";
|
||||
if (int) return "INTEGER";
|
||||
if (real) return "REAL";
|
||||
return "TEXT";
|
||||
}
|
||||
|
||||
// Ingest one TSV file into a table. Drops & recreates so re-ingest is idempotent.
|
||||
export function ingestFile(db, tableName, parsed) {
|
||||
if (!TABLE_SAFE.test(tableName)) throw new Error(`unsafe table name: ${tableName}`);
|
||||
const { headers, types, rows } = parsed;
|
||||
if (headers.length === 0) return { table: tableName, rows: 0, skipped: "empty" };
|
||||
|
||||
db.exec(`DROP TABLE IF EXISTS ${tableName}`);
|
||||
const colDefs = headers.map((h, i) => `"${h}" ${types[i]}`).join(", ");
|
||||
db.exec(`CREATE TABLE ${tableName} (${colDefs})`);
|
||||
|
||||
const placeholders = headers.map(() => "?").join(", ");
|
||||
const stmt = db.prepare(`INSERT INTO ${tableName} VALUES (${placeholders})`);
|
||||
const insertMany = db.transaction((rs) => {
|
||||
for (const r of rs) {
|
||||
const vals = headers.map((_, i) => coerce(r[i], types[i]));
|
||||
stmt.run(...vals);
|
||||
}
|
||||
});
|
||||
insertMany(rows);
|
||||
|
||||
// Helpful indexes when columns exist.
|
||||
for (const idxCol of ["Thread", "ThreadName", "Frame", "FrameIndex", "Name", "TimerName"]) {
|
||||
if (headers.includes(idxCol)) {
|
||||
try { db.exec(`CREATE INDEX idx_${tableName}_${idxCol} ON ${tableName}("${idxCol}")`); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return { table: tableName, rows: rows.length, columns: headers, types };
|
||||
}
|
||||
|
||||
function coerce(v, type) {
|
||||
if (v === undefined || v === null || v === "") return null;
|
||||
if (type === "INTEGER") {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
if (type === "REAL") {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// Ingest every .tsv/.csv in rawDir as a table named after its basename.
|
||||
export async function ingestRawDir(db, rawDir) {
|
||||
const files = fs.readdirSync(rawDir).filter((f) => /\.(tsv|csv)$/i.test(f));
|
||||
const summary = [];
|
||||
for (const f of files) {
|
||||
const table = sanitizeIdent(path.basename(f, path.extname(f))).toLowerCase();
|
||||
try {
|
||||
const parsed = await parseTabular(path.join(rawDir, f));
|
||||
summary.push(ingestFile(db, table, parsed));
|
||||
} catch (e) {
|
||||
summary.push({ table, file: f, error: e.message });
|
||||
}
|
||||
}
|
||||
// Save a manifest of available tables for introspection.
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS _mcp_manifest (key TEXT PRIMARY KEY, value TEXT)`);
|
||||
db.prepare(`INSERT OR REPLACE INTO _mcp_manifest VALUES (?, ?)`).run("ingest", JSON.stringify(summary));
|
||||
db.prepare(`INSERT OR REPLACE INTO _mcp_manifest VALUES (?, ?)`).run("ingest_time", new Date().toISOString());
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function listTables(db) {
|
||||
return db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`
|
||||
).all().map((r) => r.name);
|
||||
}
|
||||
|
||||
export function tableColumns(db, table) {
|
||||
if (!TABLE_SAFE.test(table)) return [];
|
||||
return db.prepare(`PRAGMA table_info("${table}")`).all().map((r) => ({ name: r.name, type: r.type }));
|
||||
}
|
||||
154
src/insightsExporter.js
Normal file
154
src/insightsExporter.js
Normal file
@ -0,0 +1,154 @@
|
||||
// Wraps UnrealInsights.exe to export an .utrace file into CSVs.
|
||||
//
|
||||
// Strategy: run `UnrealInsights.exe -OpenTraceFile=<path> -AutoQuit
|
||||
// -ABSLog=<log> -ExecOnAnalysisCompleteCmd="cmd1;cmd2;..."` which loads
|
||||
// the trace headless, executes the given Trace*.Export* console commands,
|
||||
// then quits. Output CSVs land in cacheDir/raw/.
|
||||
//
|
||||
// Robustness:
|
||||
// - locates UnrealInsights.exe via projectPaths (env override > the project's
|
||||
// associated engine > a scan of installed engines)
|
||||
// - skips export commands silently if the channel isn't in the trace
|
||||
// (we detect this post-hoc by which CSVs got written)
|
||||
// - has a hard timeout; partial output is still usable.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import fssync from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { insightsExe } from "./projectPaths.js";
|
||||
|
||||
// Locates UnrealInsights.exe purely from the resolved engine (env override >
|
||||
// the project's associated engine > scan of installed Epic engines). No
|
||||
// machine-specific paths baked in here — see projectPaths.js.
|
||||
export function findInsightsExe() {
|
||||
return insightsExe();
|
||||
}
|
||||
|
||||
export async function hashTrace(tracePath) {
|
||||
// Hash header + size + mtime — quick, avoids reading multi-GB trace.
|
||||
const stat = await fs.stat(tracePath);
|
||||
const fd = await fs.open(tracePath, "r");
|
||||
const buf = Buffer.alloc(Math.min(64 * 1024, stat.size));
|
||||
await fd.read(buf, 0, buf.length, 0);
|
||||
await fd.close();
|
||||
const h = crypto.createHash("sha1");
|
||||
h.update(buf);
|
||||
h.update(String(stat.size));
|
||||
h.update(String(stat.mtimeMs | 0));
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// Export commands. UE 5.8 only honours ONE command via -ExecOnAnalysisCompleteCmd
|
||||
// — for multiples you must use a response file: `-ExecOnAnalysisCompleteCmd="@=path.rsp"`
|
||||
// with one command per line. Pattern lifted from
|
||||
// Engine/Source/Developer/TraceInsights/Private/Insights/Tests/FunctionalTests/ExportCommandsTests.cpp
|
||||
//
|
||||
// Strategy for big traces:
|
||||
// - ExportTimerStatistics → aggregated stats, small even for 158M scopes
|
||||
// - ExportThreads / ExportTimers → tiny metadata
|
||||
// - ExportTimingEvents → only frame-marker events (filtered by timer name)
|
||||
// to keep size sane while still letting us compute frame-time stats
|
||||
// - ExportCounters → for FPS, memory, custom counters
|
||||
//
|
||||
// Memory tags / loadtime exports only make sense when those channels were
|
||||
// recorded — they're separate commands and would be no-ops here.
|
||||
|
||||
function fwd(p) { return p.replace(/\\/g, "/"); }
|
||||
|
||||
function buildResponseFile(rawDir) {
|
||||
const f = (name) => `"${fwd(path.join(rawDir, name))}"`;
|
||||
// Order doesn't matter much; each line is executed independently.
|
||||
// Notes on filter syntax (from TimingProfilerManager.cpp, UE 5.8):
|
||||
// -threads=Name1,Name2 inclusive thread filter, matches thread Name exactly
|
||||
// -timers=Name1,Name2 inclusive timer filter, partial match — keep narrow
|
||||
// Thread names in UE: "Game Frames" / "Rendering Frames" are virtual
|
||||
// frame-marker threads (group "Frame Tracks"); GPU queues are
|
||||
// GPU0-Graphics0 / GPU0-Compute0 / GPU0-Copy0; also GPU1, GPU2 (multi-GPU).
|
||||
return [
|
||||
`TimingInsights.ExportThreads ${f("threads.tsv")}`,
|
||||
`TimingInsights.ExportTimers ${f("timers.tsv")}`,
|
||||
// Global aggregated stats — primary source for top_scopes / report.
|
||||
`TimingInsights.ExportTimerStatistics ${f("timer_stats_all.tsv")} -columns=* -sortby=totalinclusivetime -sortorder=descending`,
|
||||
// Per-thread aggregated stats — lets us answer "what's heavy on GameThread vs RenderThread".
|
||||
`TimingInsights.ExportTimerStatistics ${f("timer_stats_game.tsv")} -threads=GameThread -columns=* -sortby=totalinclusivetime -sortorder=descending`,
|
||||
`TimingInsights.ExportTimerStatistics ${f("timer_stats_render.tsv")} -threads="RenderThread 0" -columns=* -sortby=totalinclusivetime -sortorder=descending`,
|
||||
`TimingInsights.ExportTimerStatistics ${f("timer_stats_rhi.tsv")} -threads=RHIThread -columns=* -sortby=totalinclusivetime -sortorder=descending`,
|
||||
`TimingInsights.ExportTimerStatistics ${f("timer_stats_gpu.tsv")} -threads=GPU0-Graphics0,GPU0-Compute0,GPU0-Copy0,GPU1,GPU2 -columns=* -sortby=totalinclusivetime -sortorder=descending`,
|
||||
// Frame markers — only the "Game Frames" virtual thread, small.
|
||||
`TimingInsights.ExportTimingEvents ${f("frame_markers.tsv")} -threads="Game Frames" -columns=*`,
|
||||
// GPU timing events on primary graphics queue (for top GPU events list).
|
||||
`TimingInsights.ExportTimingEvents ${f("gpu_events.tsv")} -threads=GPU0-Graphics0,GPU1 -columns=*`,
|
||||
`TimingInsights.ExportCounters ${f("counters.tsv")}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function exportTrace({ tracePath, cacheDir, timeoutMs = 15 * 60 * 1000 }) {
|
||||
const exe = findInsightsExe();
|
||||
if (!exe) {
|
||||
return { ok: false, error: "UnrealInsights.exe not found. Set env UE_INSIGHTS_EXE." };
|
||||
}
|
||||
if (!fssync.existsSync(tracePath)) {
|
||||
return { ok: false, error: `trace file not found: ${tracePath}` };
|
||||
}
|
||||
|
||||
const rawDir = path.join(cacheDir, "raw");
|
||||
await fs.mkdir(rawDir, { recursive: true });
|
||||
const logPath = path.join(cacheDir, "insights.log");
|
||||
const rspPath = path.join(cacheDir, "export.rsp");
|
||||
await fs.writeFile(rspPath, buildResponseFile(rawDir), "utf8");
|
||||
|
||||
const args = [
|
||||
`-OpenTraceFile=${fwd(tracePath)}`,
|
||||
"-AutoQuit",
|
||||
"-NoUI",
|
||||
"-Unattended",
|
||||
`-ABSLog=${fwd(logPath)}`,
|
||||
`-ExecOnAnalysisCompleteCmd=@=${fwd(rspPath)}`,
|
||||
];
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await runProcess(exe, args, timeoutMs);
|
||||
const elapsedMs = Date.now() - t0;
|
||||
|
||||
const produced = (await fs.readdir(rawDir)).filter((f) => f.endsWith(".tsv") || f.endsWith(".csv"));
|
||||
return {
|
||||
ok: produced.length > 0,
|
||||
exe,
|
||||
args,
|
||||
rspPath,
|
||||
rspBody: await fs.readFile(rspPath, "utf8"),
|
||||
elapsedMs,
|
||||
produced,
|
||||
rawDir,
|
||||
logPath,
|
||||
exitCode: res.exitCode,
|
||||
timedOut: res.timedOut,
|
||||
stderr: res.stderr.slice(-2000),
|
||||
error: produced.length === 0 ? "no CSV files were exported — see insights.log" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function runProcess(exe, args, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(exe, args, { windowsHide: true });
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
||||
}, timeoutMs);
|
||||
proc.stdout?.on("data", (b) => { stdout += b.toString(); });
|
||||
proc.stderr?.on("data", (b) => { stderr += b.toString(); });
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode: code, timedOut, stdout, stderr });
|
||||
});
|
||||
proc.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode: -1, timedOut, stdout, stderr: stderr + "\n" + e.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
263
src/launcher.js
Normal file
263
src/launcher.js
Normal file
@ -0,0 +1,263 @@
|
||||
// launcher.js — JS-native handler for the UE Project Launcher. Reads & edits
|
||||
// launch profiles WITHOUT touching UE Remote Control, so it keeps working even
|
||||
// when the editor is busy or crashed.
|
||||
//
|
||||
// Covers BOTH launchers: the Legacy Project Launcher / Session Frontend AND the
|
||||
// new Project Launcher (UE 5.5+, Engine/Plugins/Developer/ProjectLauncher).
|
||||
// Verified via the new plugin's binary: it owns no file I/O and drives the same
|
||||
// ILauncherProfileManager (LauncherServices) — symbols HandleProfileManager
|
||||
// ProfileAdded/Removed, CreateCustomProfile→ILauncherProfile — so both UIs
|
||||
// persist to the SAME *.ulp2 files. The new launcher's "Basic" profiles are
|
||||
// transient (per build target/device) and never written to disk; only saved
|
||||
// Custom/Advanced profiles land here.
|
||||
//
|
||||
// Profiles are plain JSON files (`*.ulp2`, "Version": 42) that the Launcher
|
||||
// loads from <EngineDir>/Engine/Programs/UnrealFrontend/Profiles/. Editing the
|
||||
// file IS editing the profile — the Launcher re-reads this folder on open.
|
||||
//
|
||||
// CAVEAT: if the Launcher window is already open it holds the profile in memory
|
||||
// and may overwrite your file edit on its next save. Edit with the Launcher
|
||||
// closed (or reopen it afterwards) for changes to stick.
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { engineDir as detectEngineDir } from "./projectPaths.js";
|
||||
|
||||
const PROFILE_EXT = ".ulp2";
|
||||
|
||||
// Engine root WITHOUT the trailing /Engine. Auto-detected from the host project
|
||||
// (see projectPaths.js). Override per-call via {engine_dir} or env UE_ENGINE_DIR,
|
||||
// or point straight at the folder with {profiles_dir} / env UE_PROFILES_DIR.
|
||||
function profilesDir(args = {}) {
|
||||
if (args.profiles_dir) return args.profiles_dir.replace(/\\/g, "/");
|
||||
if (process.env.UE_PROFILES_DIR) return process.env.UE_PROFILES_DIR.replace(/\\/g, "/");
|
||||
const eng = detectEngineDir(args.engine_dir);
|
||||
if (!eng) {
|
||||
throw new Error(
|
||||
"Could not locate an Unreal Engine install for launcher profiles. " +
|
||||
"Set UE_ENGINE_DIR / UE_PROFILES_DIR or pass engine_dir / profiles_dir."
|
||||
);
|
||||
}
|
||||
return `${eng.replace(/\\/g, "/")}/Engine/Programs/UnrealFrontend/Profiles`;
|
||||
}
|
||||
|
||||
async function listFiles(dir) {
|
||||
let names;
|
||||
try {
|
||||
names = await fs.readdir(dir);
|
||||
} catch (e) {
|
||||
const err = new Error(`profiles folder not found: ${dir} (${e.code}). Pass {profiles_dir} or set UE_PROFILES_DIR.`);
|
||||
err.code = "NO_DIR";
|
||||
throw err;
|
||||
}
|
||||
return names.filter((n) => n.toLowerCase().endsWith(PROFILE_EXT)).map((n) => path.join(dir, n));
|
||||
}
|
||||
|
||||
async function readProfileFile(file) {
|
||||
const txt = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(txt);
|
||||
}
|
||||
|
||||
function summarize(json, file) {
|
||||
return {
|
||||
file: path.basename(file),
|
||||
id: json.Id,
|
||||
name: json.Name,
|
||||
description: json.Description || "",
|
||||
project: json.ShareableProjectPath || json.ProjectPath || "",
|
||||
build_config: json.BuildConfiguration,
|
||||
cooked_maps: json.CookedMaps || [],
|
||||
cooked_platforms: json.CookedPlatforms || [],
|
||||
package_dir: json.PackageDir || "",
|
||||
build_mode: json.BuildMode,
|
||||
cook_mode: json.CookMode,
|
||||
deployment_mode: json.DeploymentMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve a single profile from {file|id|name}. Returns {file, json}.
|
||||
async function select(args) {
|
||||
const dir = profilesDir(args);
|
||||
const files = await listFiles(dir);
|
||||
if (args.file) {
|
||||
const want = path.isAbsolute(args.file) ? args.file : path.join(dir, args.file);
|
||||
const hit = files.find((f) => path.resolve(f) === path.resolve(want)) ||
|
||||
files.find((f) => path.basename(f).toLowerCase() === String(args.file).toLowerCase());
|
||||
if (!hit) throw new Error(`profile file not found: ${args.file}`);
|
||||
return { file: hit, json: await readProfileFile(hit) };
|
||||
}
|
||||
const loaded = await Promise.all(files.map(async (f) => ({ file: f, json: await readProfileFile(f).catch(() => null) })));
|
||||
const valid = loaded.filter((x) => x.json);
|
||||
let matches = valid;
|
||||
if (args.id) {
|
||||
const id = String(args.id).toUpperCase();
|
||||
matches = valid.filter((x) => String(x.json.Id).toUpperCase() === id);
|
||||
} else if (args.name) {
|
||||
const nm = String(args.name).toLowerCase();
|
||||
matches = valid.filter((x) => String(x.json.Name).toLowerCase() === nm);
|
||||
if (matches.length === 0) matches = valid.filter((x) => String(x.json.Name).toLowerCase().includes(nm));
|
||||
} else {
|
||||
throw new Error("specify one of {id, name, file} to select a profile");
|
||||
}
|
||||
if (matches.length === 0) throw new Error(`no profile matched ${JSON.stringify({ id: args.id, name: args.name })}`);
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`ambiguous selector — ${matches.length} profiles matched: ` +
|
||||
matches.map((m) => `${m.json.Name} (${m.json.Id})`).join(", "));
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
async function backup(file) {
|
||||
const bak = `${file}.bak`;
|
||||
await fs.copyFile(file, bak).catch(() => {});
|
||||
return path.basename(bak);
|
||||
}
|
||||
|
||||
// UE writes these files with tab indentation; match it to keep diffs minimal.
|
||||
async function writeProfile(file, json) {
|
||||
await fs.writeFile(file, JSON.stringify(json, null, "\t") + "\n", "utf8");
|
||||
}
|
||||
|
||||
function newId() {
|
||||
return crypto.randomUUID().replace(/-/g, "").toUpperCase(); // 32 hex chars, UE-style
|
||||
}
|
||||
|
||||
// ---- ops -------------------------------------------------------------------
|
||||
|
||||
async function opList(args) {
|
||||
const dir = profilesDir(args);
|
||||
const files = await listFiles(dir);
|
||||
const out = [];
|
||||
for (const f of files) {
|
||||
try { out.push(summarize(await readProfileFile(f), f)); }
|
||||
catch (e) { out.push({ file: path.basename(f), error: `parse failed: ${e.message}` }); }
|
||||
}
|
||||
return { ok: true, profiles_dir: dir, count: out.length, profiles: out };
|
||||
}
|
||||
|
||||
async function opRead(args) {
|
||||
const { file, json } = await select(args);
|
||||
return { ok: true, file: path.basename(file), profile: json };
|
||||
}
|
||||
|
||||
async function opGet(args) {
|
||||
if (!args.field) throw new Error("get: 'field' required");
|
||||
const { file, json } = await select(args);
|
||||
return { ok: true, file: path.basename(file), field: args.field, value: json[args.field] };
|
||||
}
|
||||
|
||||
async function opSetField(args) {
|
||||
if (!args.field) throw new Error("set_field: 'field' required");
|
||||
if (!("value" in args)) throw new Error("set_field: 'value' required");
|
||||
const { file, json } = await select(args);
|
||||
const before = json[args.field];
|
||||
json[args.field] = args.value;
|
||||
const bak = await backup(file);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, file: path.basename(file), field: args.field, before, after: args.value, backup: bak };
|
||||
}
|
||||
|
||||
async function opSetFields(args) {
|
||||
if (!args.fields || typeof args.fields !== "object") throw new Error("set_fields: 'fields' object required");
|
||||
const { file, json } = await select(args);
|
||||
const changes = {};
|
||||
for (const [k, v] of Object.entries(args.fields)) { changes[k] = { before: json[k], after: v }; json[k] = v; }
|
||||
const bak = await backup(file);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, file: path.basename(file), changes, backup: bak };
|
||||
}
|
||||
|
||||
async function opSetCookedMaps(args) {
|
||||
if (!Array.isArray(args.maps)) throw new Error("set_cooked_maps: 'maps' array required");
|
||||
const { file, json } = await select(args);
|
||||
const before = json.CookedMaps || [];
|
||||
json.CookedMaps = args.maps;
|
||||
const bak = await backup(file);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, file: path.basename(file), before, after: json.CookedMaps, backup: bak };
|
||||
}
|
||||
|
||||
async function opAddCookedMap(args) {
|
||||
if (!args.map) throw new Error("add_cooked_map: 'map' required");
|
||||
const { file, json } = await select(args);
|
||||
const maps = json.CookedMaps || [];
|
||||
if (maps.includes(args.map)) return { ok: true, file: path.basename(file), unchanged: true, cooked_maps: maps };
|
||||
maps.push(args.map);
|
||||
json.CookedMaps = maps;
|
||||
const bak = await backup(file);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, file: path.basename(file), added: args.map, cooked_maps: maps, backup: bak };
|
||||
}
|
||||
|
||||
async function opRemoveCookedMap(args) {
|
||||
if (!args.map) throw new Error("remove_cooked_map: 'map' required");
|
||||
const { file, json } = await select(args);
|
||||
const maps = json.CookedMaps || [];
|
||||
if (!maps.includes(args.map)) return { ok: true, file: path.basename(file), unchanged: true, cooked_maps: maps };
|
||||
json.CookedMaps = maps.filter((m) => m !== args.map);
|
||||
const bak = await backup(file);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, file: path.basename(file), removed: args.map, cooked_maps: json.CookedMaps, backup: bak };
|
||||
}
|
||||
|
||||
async function opDuplicate(args) {
|
||||
const src = await select(args); // selects the template via {id|name|file}
|
||||
const newName = args.new_name || `${src.json.Name} copy`;
|
||||
const dir = profilesDir(args);
|
||||
const json = JSON.parse(JSON.stringify(src.json));
|
||||
const id = newId();
|
||||
json.Id = id;
|
||||
json.Name = newName;
|
||||
// Apply any field overrides on the freshly-created copy.
|
||||
if (args.fields && typeof args.fields === "object") {
|
||||
for (const [k, v] of Object.entries(args.fields)) json[k] = v;
|
||||
}
|
||||
const safe = newName.replace(/[\\/:*?"<>|]/g, "_").toUpperCase();
|
||||
const file = path.join(dir, `${safe}_${id}${PROFILE_EXT}`);
|
||||
await writeProfile(file, json);
|
||||
return { ok: true, created: path.basename(file), id, name: newName, from: src.json.Name };
|
||||
}
|
||||
|
||||
// Soft delete: move the file into a Profiles/_mcp_trash subfolder (reversible),
|
||||
// never a hard delete.
|
||||
async function opDelete(args) {
|
||||
const { file } = await select(args);
|
||||
const dir = profilesDir(args);
|
||||
const trash = path.join(dir, "_mcp_trash");
|
||||
await fs.mkdir(trash, { recursive: true });
|
||||
const dest = path.join(trash, path.basename(file));
|
||||
await fs.rename(file, dest);
|
||||
return { ok: true, trashed: path.basename(file), moved_to: `_mcp_trash/${path.basename(file)}`, restore_hint: "move it back to the Profiles folder to restore" };
|
||||
}
|
||||
|
||||
const OPS = {
|
||||
list: opList,
|
||||
read: opRead,
|
||||
get: opGet,
|
||||
set_field: opSetField,
|
||||
set_fields: opSetFields,
|
||||
set_cooked_maps: opSetCookedMaps,
|
||||
add_cooked_map: opAddCookedMap,
|
||||
remove_cooked_map: opRemoveCookedMap,
|
||||
duplicate: opDuplicate,
|
||||
create_profile: opDuplicate, // alias — always template off an existing profile
|
||||
delete: opDelete,
|
||||
};
|
||||
|
||||
export async function handleLauncherOp(args) {
|
||||
const op = args && args.op;
|
||||
const fn = op && OPS[op];
|
||||
if (!fn) {
|
||||
return errResult(`launcher_op: unknown op '${op}'. Available: ${Object.keys(OPS).join(", ")}`);
|
||||
}
|
||||
try {
|
||||
return okResult(await fn(args));
|
||||
} catch (e) {
|
||||
return errResult(`launcher_op ${op} failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Local result helpers (mirror server.js so this module is self-contained).
|
||||
function okResult(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; }
|
||||
function errResult(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }
|
||||
148
src/nodeSpec.js
Normal file
148
src/nodeSpec.js
Normal file
@ -0,0 +1,148 @@
|
||||
// NodeSpec / EdgeSpec — the universal primitive for spawning K2 nodes.
|
||||
//
|
||||
// A NodeSpec describes ONE Blueprint node. All ~15 supported `kind`s map to a
|
||||
// concrete UK2Node_* class on the UE side. The `target` field disambiguates
|
||||
// which function / variable / type the node refers to.
|
||||
|
||||
export const NODE_KINDS = new Set([
|
||||
// Events & entry
|
||||
"event", // UK2Node_Event — target: event name (BeginPlay, Tick, ReceiveActorBeginOverlap…)
|
||||
"custom_event", // UK2Node_CustomEvent — target: custom event name
|
||||
"component_event", // UK2Node_ComponentBoundEvent — target: "ComponentName.DelegateName"
|
||||
// Function calls
|
||||
"call_function", // UK2Node_CallFunction — target: "Class.Function"
|
||||
// Variables
|
||||
"variable_get", // UK2Node_VariableGet — target: variable name
|
||||
"variable_set", // UK2Node_VariableSet — target: variable name
|
||||
"self", // UK2Node_Self
|
||||
// Flow control
|
||||
"branch", // UK2Node_IfThenElse
|
||||
"sequence", // UK2Node_ExecutionSequence — params.then_count: int
|
||||
"switch_int", // UK2Node_SwitchInteger
|
||||
"switch_string", // UK2Node_SwitchString
|
||||
"switch_enum", // UK2Node_SwitchEnum — target: enum path "/Game/MyEnum.MyEnum"
|
||||
// Macros (StandardMacros): ForLoop, ForEachLoop, WhileLoop, DoOnce, Gate, FlipFlop, MultiGate, IsValid, DoN
|
||||
"macro", // UK2Node_MacroInstance — target: macro name
|
||||
// Cast
|
||||
"cast", // UK2Node_DynamicCast — target: class path
|
||||
// Collections / structs
|
||||
"make_array", // UK2Node_MakeArray
|
||||
"make_struct", // UK2Node_MakeStruct — target: struct path
|
||||
"break_struct", // UK2Node_BreakStruct — target: struct path
|
||||
"set_fields_in_struct", // UK2Node_SetFieldsInStruct — target: struct path
|
||||
// Misc commonly-used
|
||||
"spawn_actor", // UK2Node_SpawnActorFromClass
|
||||
"format_text", // UK2Node_FormatText
|
||||
"get_subsystem", // UK2Node_GetSubsystem — target: subsystem class path
|
||||
"load_asset", // UK2Node_LoadAsset
|
||||
// Delegates
|
||||
"bind_delegate", // UK2Node_AddDelegate — target: "ComponentName.DelegateName"
|
||||
"unbind_delegate", // UK2Node_RemoveDelegate — target: "ComponentName.DelegateName"
|
||||
"call_delegate", // UK2Node_CallDelegate — target: dispatcher name on this BP
|
||||
"assign_delegate", // UK2Node_AssignDelegate — target: "ComponentName.DelegateName"
|
||||
// Tier 2
|
||||
"knot", // UK2Node_Knot — reroute
|
||||
"comment", // UEdGraphNode_Comment — params: {text, width, height}
|
||||
"enum_literal", // UK2Node_EnumLiteral — target: enum path
|
||||
"get_class_defaults", // UK2Node_GetClassDefaults — target: class path
|
||||
"function_result", // UK2Node_FunctionResult — return node for function graphs
|
||||
"get_array_item", // UK2Node_GetArrayItem
|
||||
"operator", // UK2Node_PromotableOperator — target: "+", "-", "*", "==", etc.
|
||||
]);
|
||||
|
||||
export function validateGraph(graph) {
|
||||
const errors = [];
|
||||
if (!graph || typeof graph !== "object") {
|
||||
return ["graph must be an object"];
|
||||
}
|
||||
const { bp_path, nodes, edges } = graph;
|
||||
// bp_path is optional now: when omitted, the UE side auto-detects the
|
||||
// currently open Blueprint editor (most-recently-focused asset).
|
||||
if (bp_path !== undefined && bp_path !== null && bp_path !== "") {
|
||||
if (typeof bp_path !== "string" || !bp_path.startsWith("/Game/")) {
|
||||
errors.push(`bp_path must be a /Game/... path when provided (got ${JSON.stringify(bp_path)})`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(nodes)) {
|
||||
errors.push("nodes must be an array");
|
||||
return errors;
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const [i, n] of nodes.entries()) {
|
||||
if (!n || typeof n !== "object") {
|
||||
errors.push(`nodes[${i}] not an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof n.id !== "string" || !n.id) {
|
||||
errors.push(`nodes[${i}].id missing`);
|
||||
} else if (ids.has(n.id)) {
|
||||
errors.push(`duplicate node id "${n.id}"`);
|
||||
} else {
|
||||
ids.add(n.id);
|
||||
}
|
||||
if (!NODE_KINDS.has(n.kind)) {
|
||||
errors.push(`nodes[${i}].kind "${n.kind}" not supported`);
|
||||
}
|
||||
if (n.position && (!Array.isArray(n.position) || n.position.length !== 2)) {
|
||||
errors.push(`nodes[${i}].position must be [x,y]`);
|
||||
}
|
||||
}
|
||||
if (edges !== undefined) {
|
||||
if (!Array.isArray(edges)) {
|
||||
errors.push("edges must be an array");
|
||||
} else {
|
||||
for (const [i, e] of edges.entries()) {
|
||||
if (!e || !Array.isArray(e.from) || !Array.isArray(e.to)
|
||||
|| e.from.length !== 2 || e.to.length !== 2) {
|
||||
errors.push(`edges[${i}] must be {from:[id,pin], to:[id,pin]}`);
|
||||
continue;
|
||||
}
|
||||
// @guid:<NodeGuid> lets you reference an EXISTING node in the graph.
|
||||
const isGuidRef = (s) => typeof s === "string" && s.startsWith("@guid:");
|
||||
if (!isGuidRef(e.from[0]) && !ids.has(e.from[0])) errors.push(`edges[${i}].from references unknown node "${e.from[0]}"`);
|
||||
if (!isGuidRef(e.to[0]) && !ids.has(e.to[0])) errors.push(`edges[${i}].to references unknown node "${e.to[0]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export const APPLY_GRAPH_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["nodes"],
|
||||
properties: {
|
||||
bp_path: { type: "string", description: "Blueprint asset path, e.g. /Game/Blueprints/BP_Foo. If omitted, the currently focused open Blueprint editor is used." },
|
||||
function: { type: "string", description: "Graph to edit. 'EventGraph' (default), or a function/macro name." },
|
||||
mode: { type: "string", enum: ["append", "replace_function", "replace_event"], description: "How to merge into the graph. Default: append." },
|
||||
layout: { type: "string", enum: ["preserve", "compact", "columns"], description: "Node layout mode. Default: preserve explicit positions. Use compact/columns to repack generated nodes." },
|
||||
auto_layout: { type: "boolean", description: "Legacy shortcut for layout=compact. Default: false, so explicit positions are preserved." },
|
||||
nodes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["id", "kind"],
|
||||
properties: {
|
||||
id: { type: "string", description: "Local id used by edges within this batch." },
|
||||
kind: { type: "string", enum: [...NODE_KINDS] },
|
||||
target: { type: "string", description: "Function path / variable name / type — see kind docs." },
|
||||
position: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 },
|
||||
params: { type: "object", description: "Pin default literals, keyed by pin name." },
|
||||
flags: { type: "object", description: "pure, latent, self_context, etc." }
|
||||
}
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["from", "to"],
|
||||
properties: {
|
||||
from: { type: "array", minItems: 2, maxItems: 2, items: { type: "string" } },
|
||||
to: { type: "array", minItems: 2, maxItems: 2, items: { type: "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
dry_run: { type: "boolean", description: "If true, return the generated Python without sending to UE." },
|
||||
validate: { type: "boolean", description: "If true, resolve all targets (classes, functions, structs, enums) inside UE without spawning. Returns {missing:[], resolved:[]}." }
|
||||
}
|
||||
};
|
||||
240
src/projectPaths.js
Normal file
240
src/projectPaths.js
Normal file
@ -0,0 +1,240 @@
|
||||
// projectPaths.js — zero-config, portable resolution of the host UE project and
|
||||
// engine. NOTHING here is hardcoded to a particular project or machine: every
|
||||
// value is derived from where this plugin lives on disk, with environment-
|
||||
// variable overrides for the few things that can't be inferred.
|
||||
//
|
||||
// The plugin is expected to live at <Project>/Plugins/<AnyFolder>/ — i.e. this
|
||||
// file sits at <Project>/Plugins/<AnyFolder>/src/projectPaths.js. We walk up
|
||||
// from here to the nearest directory containing a *.uproject, and everything
|
||||
// (project name, Editor target, log file) follows from that.
|
||||
//
|
||||
// Overrides (all optional):
|
||||
// UE_PROJECT_DIR — absolute path to the .uproject parent folder
|
||||
// UE_UPROJECT_NAME — e.g. MyGame.uproject
|
||||
// UE_BUILD_TARGET — e.g. MyGameEditor
|
||||
// UE_ENGINE_DIR — installed engine root (the folder that contains /Engine)
|
||||
// UE_INSIGHTS_EXE — full path to UnrealInsights.exe
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url)); // <plugin>/src
|
||||
export const PLUGIN_DIR = path.resolve(HERE, ".."); // <plugin>
|
||||
const norm = (p) => (p ? p.replace(/\\/g, "/") : p);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _project = null;
|
||||
|
||||
function discoverProject() {
|
||||
if (_project) return _project;
|
||||
|
||||
// 1. Explicit override.
|
||||
const envDir = process.env.UE_PROJECT_DIR;
|
||||
if (envDir && fs.existsSync(envDir)) {
|
||||
_project = fromDir(envDir) || { projectDir: norm(envDir), uproject: null, projectName: null };
|
||||
return _project;
|
||||
}
|
||||
|
||||
// 2. Walk up from the plugin folder to the nearest *.uproject.
|
||||
let dir = PLUGIN_DIR;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const found = fromDir(dir);
|
||||
if (found) { _project = found; return _project; }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
// 3. Give up gracefully — projectDir is the plugin's grandparent's parent
|
||||
// (the conventional <Project>/Plugins/<Plugin> layout) so callers still
|
||||
// get a usable directory even if no .uproject was found.
|
||||
_project = { projectDir: norm(path.resolve(PLUGIN_DIR, "..", "..")), uproject: null, projectName: null };
|
||||
return _project;
|
||||
}
|
||||
|
||||
function fromDir(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const up = entries.find((f) => f.toLowerCase().endsWith(".uproject"));
|
||||
if (!up) return null;
|
||||
const override = process.env.UE_UPROJECT_NAME;
|
||||
const uprojectName = override && entries.includes(override) ? override : up;
|
||||
return {
|
||||
projectDir: norm(dir),
|
||||
uproject: norm(path.join(dir, uprojectName)),
|
||||
projectName: uprojectName.replace(/\.uproject$/i, ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectDir() { return discoverProject().projectDir; }
|
||||
export function uprojectPath() { return discoverProject().uproject; }
|
||||
export function uprojectName() {
|
||||
if (process.env.UE_UPROJECT_NAME) return process.env.UE_UPROJECT_NAME;
|
||||
const up = discoverProject().uproject;
|
||||
return up ? path.basename(up) : null;
|
||||
}
|
||||
export function projectName() { return discoverProject().projectName; }
|
||||
|
||||
// The Editor build target. Convention is "<ProjectName>Editor"; a project can
|
||||
// override via env or by naming its .Target.cs differently.
|
||||
export function buildTarget() {
|
||||
if (process.env.UE_BUILD_TARGET) return process.env.UE_BUILD_TARGET;
|
||||
const name = projectName();
|
||||
return name ? `${name}Editor` : null;
|
||||
}
|
||||
|
||||
// Default UE log file name is "<ProjectName>.log" under Saved/Logs.
|
||||
export function logName() {
|
||||
if (process.env.UE_LOG_NAME) return process.env.UE_LOG_NAME;
|
||||
const name = projectName();
|
||||
return name ? `${name}.log` : "UnrealEditor.log";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EPIC_ROOTS = [
|
||||
"C:/Program Files/Epic Games",
|
||||
"D:/Program Files/Epic Games",
|
||||
"C:/Program Files (x86)/Epic Games",
|
||||
];
|
||||
|
||||
let _engine = null;
|
||||
|
||||
// Returns the engine root (folder containing /Engine), or null if undetectable.
|
||||
export function engineDir(override) {
|
||||
if (override && fs.existsSync(override)) return norm(override);
|
||||
if (_engine !== null) return _engine || null;
|
||||
|
||||
_engine = resolveEngine() || "";
|
||||
return _engine || null;
|
||||
}
|
||||
|
||||
function resolveEngine() {
|
||||
// 1. Explicit override wins.
|
||||
if (process.env.UE_ENGINE_DIR && fs.existsSync(process.env.UE_ENGINE_DIR)) {
|
||||
return norm(process.env.UE_ENGINE_DIR);
|
||||
}
|
||||
|
||||
const assoc = engineAssociation();
|
||||
|
||||
// 2. Installed-engine manifest (works on any Windows box, no registry).
|
||||
const fromManifest = engineFromLauncherManifest(assoc);
|
||||
if (fromManifest) return fromManifest;
|
||||
|
||||
// 3. Source-build registry mapping (GUID associations), Windows only.
|
||||
if (assoc && /[0-9a-fA-F-]{20,}|\{.*\}/.test(assoc)) {
|
||||
const fromReg = engineFromRegistry(assoc);
|
||||
if (fromReg) return fromReg;
|
||||
}
|
||||
|
||||
// 4. Scan the standard Epic install roots; prefer the version that matches
|
||||
// EngineAssociation, else the highest installed version.
|
||||
const scanned = scanEpicRoots(assoc);
|
||||
if (scanned) return scanned;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function engineAssociation() {
|
||||
const up = uprojectPath();
|
||||
if (!up || !fs.existsSync(up)) return null;
|
||||
try {
|
||||
const json = JSON.parse(fs.readFileSync(up, "utf8"));
|
||||
return json.EngineAssociation || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Parses LauncherInstalled.dat — JSON listing every Epic-installed engine with
|
||||
// its AppName ("UE_5.7") and InstallLocation. Present on all launcher installs.
|
||||
function engineFromLauncherManifest(assoc) {
|
||||
const dat = "C:/ProgramData/Epic/UnrealEngineLauncher/LauncherInstalled.dat";
|
||||
if (!fs.existsSync(dat)) return null;
|
||||
let list;
|
||||
try { list = JSON.parse(fs.readFileSync(dat, "utf8")).InstallationList || []; }
|
||||
catch { return null; }
|
||||
const engines = list.filter((e) => /^UE_/.test(e.AppName || ""));
|
||||
if (!engines.length) return null;
|
||||
if (assoc) {
|
||||
const want = `UE_${assoc}`;
|
||||
const hit = engines.find((e) => e.AppName === want);
|
||||
if (hit && fs.existsSync(hit.InstallLocation)) return norm(hit.InstallLocation);
|
||||
}
|
||||
// Highest version available.
|
||||
engines.sort((a, b) => verKey(b.AppName) - verKey(a.AppName));
|
||||
for (const e of engines) if (fs.existsSync(e.InstallLocation)) return norm(e.InstallLocation);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Source builds register a GUID->path map under HKCU\Software\Epic Games\Unreal Engine\Builds.
|
||||
function engineFromRegistry(assoc) {
|
||||
if (process.platform !== "win32") return null;
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"reg",
|
||||
["query", "HKCU\\Software\\Epic Games\\Unreal Engine\\Builds", "/v", assoc.replace(/[{}]/g, "")],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
const m = /REG_SZ\s+(.+)$/m.exec(out);
|
||||
if (m) {
|
||||
const p = m[1].trim();
|
||||
if (fs.existsSync(p)) return norm(p);
|
||||
}
|
||||
} catch { /* not a registered source build */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanEpicRoots(assoc) {
|
||||
const candidates = [];
|
||||
for (const root of EPIC_ROOTS) {
|
||||
let dirs;
|
||||
try { dirs = fs.readdirSync(root); } catch { continue; }
|
||||
for (const d of dirs) {
|
||||
if (!/^UE_/.test(d)) continue;
|
||||
const full = path.join(root, d);
|
||||
if (fs.existsSync(path.join(full, "Engine"))) candidates.push({ name: d, full });
|
||||
}
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
if (assoc) {
|
||||
const hit = candidates.find((c) => c.name === `UE_${assoc}`);
|
||||
if (hit) return norm(hit.full);
|
||||
}
|
||||
candidates.sort((a, b) => verKey(b.name) - verKey(a.name));
|
||||
return norm(candidates[0].full);
|
||||
}
|
||||
|
||||
function verKey(appName) {
|
||||
const m = /UE_(\d+)\.(\d+)/.exec(appName || "");
|
||||
if (!m) return 0;
|
||||
return Number(m[1]) * 1000 + Number(m[2]);
|
||||
}
|
||||
|
||||
// Locate UnrealInsights.exe: explicit env > resolved engine > scanned engines.
|
||||
export function insightsExe() {
|
||||
if (process.env.UE_INSIGHTS_EXE && fs.existsSync(process.env.UE_INSIGHTS_EXE)) {
|
||||
return process.env.UE_INSIGHTS_EXE;
|
||||
}
|
||||
const eng = engineDir();
|
||||
if (eng) {
|
||||
const p = path.join(eng, "Engine/Binaries/Win64/UnrealInsights.exe");
|
||||
if (fs.existsSync(p)) return norm(p);
|
||||
}
|
||||
// Fall back to scanning every installed engine root.
|
||||
for (const root of EPIC_ROOTS) {
|
||||
let dirs;
|
||||
try { dirs = fs.readdirSync(root); } catch { continue; }
|
||||
dirs.sort((a, b) => verKey(b) - verKey(a));
|
||||
for (const d of dirs) {
|
||||
const p = path.join(root, d, "Engine/Binaries/Win64/UnrealInsights.exe");
|
||||
if (fs.existsSync(p)) return norm(p);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
61
src/pythonGen.js
Normal file
61
src/pythonGen.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Builds the single-shot Python command the MCP server sends to UE's
|
||||
// PythonScriptLibrary.ExecutePythonCommand. The command embeds the graph as a
|
||||
// JSON literal and dispatches to the in-engine helper module.
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const UE_SIDE_DIR = path.resolve(HERE, "..", "ue_side");
|
||||
|
||||
/**
|
||||
* Build a Python command string that, when executed inside UE, will:
|
||||
* 1. Append our `ue_side/` to sys.path so the helper module is importable.
|
||||
* 2. Call `apply_graph.entrypoint(<json>)`.
|
||||
* 3. Print a result envelope `__MCP_RESULT__<json>__MCP_END__` so the server
|
||||
* can parse stdout (UE returns ExecutePythonCommand output as a string).
|
||||
*/
|
||||
export function buildApplyCommand(graph, { ueSideDir = UE_SIDE_DIR } = {}) {
|
||||
const payload = JSON.stringify(graph);
|
||||
// Python string literal — JSON is conveniently a Python literal too, but we
|
||||
// want to be safe against backslashes/quotes; embed via repr().
|
||||
const pyPayload = pyRepr(payload);
|
||||
const pyDir = pyRepr(ueSideDir.replace(/\\/g, "/"));
|
||||
return [
|
||||
"import sys, json",
|
||||
`_d = ${pyDir}`,
|
||||
"sys.path.insert(0, _d) if _d not in sys.path else None",
|
||||
"import importlib, apply_graph",
|
||||
"importlib.reload(apply_graph)",
|
||||
`_out = apply_graph.entrypoint(${pyPayload})`,
|
||||
"print('__MCP_RESULT__' + _out + '__MCP_END__')",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseResultEnvelope(stdout) {
|
||||
const m = /__MCP_RESULT__([\s\S]*?)__MCP_END__/.exec(stdout || "");
|
||||
if (!m) return { ok: false, errors: ["no result envelope in UE stdout"], raw: stdout };
|
||||
try {
|
||||
return JSON.parse(m[1]);
|
||||
} catch (e) {
|
||||
return { ok: false, errors: [`bad JSON in envelope: ${e.message}`], raw: m[1] };
|
||||
}
|
||||
}
|
||||
|
||||
function pyRepr(s) {
|
||||
// Python-safe single-quoted string with \\, \', and control chars escaped.
|
||||
let out = "'";
|
||||
for (const ch of s) {
|
||||
const c = ch.charCodeAt(0);
|
||||
if (ch === "\\") out += "\\\\";
|
||||
else if (ch === "'") out += "\\'";
|
||||
else if (ch === "\n") out += "\\n";
|
||||
else if (ch === "\r") out += "\\r";
|
||||
else if (ch === "\t") out += "\\t";
|
||||
else if (c < 0x20 || c === 0x7f) out += "\\x" + c.toString(16).padStart(2, "0");
|
||||
else out += ch;
|
||||
}
|
||||
return out + "'";
|
||||
}
|
||||
|
||||
export const _internal = { pyRepr };
|
||||
1320
src/server.js
Normal file
1320
src/server.js
Normal file
File diff suppressed because it is too large
Load Diff
68
src/ueBridge.js
Normal file
68
src/ueBridge.js
Normal file
@ -0,0 +1,68 @@
|
||||
// Talks to UE's Remote Control HTTP server.
|
||||
//
|
||||
// Enable in UE: Edit > Plugins > "Remote Control API" (and "Python Editor
|
||||
// Script Plugin"). Then `WebControl.EnableServerOnStartup = true` or run the
|
||||
// console command `WebControl.StartServer` in the editor. Default port 30010.
|
||||
|
||||
const DEFAULT_BASE = process.env.UE_REMOTE_CONTROL_URL || "http://127.0.0.1:30010";
|
||||
|
||||
/**
|
||||
* Execute an arbitrary Python command inside the UE editor.
|
||||
* Returns { ok, stdout, stderr, status, error? }.
|
||||
*/
|
||||
export async function executePythonInUE(pythonCommand, { baseUrl = DEFAULT_BASE, timeoutMs = 30000 } = {}) {
|
||||
const url = `${baseUrl}/remote/object/call`;
|
||||
const body = {
|
||||
objectPath: "/Script/PythonScriptPlugin.Default__PythonScriptLibrary",
|
||||
functionName: "ExecutePythonCommand",
|
||||
parameters: { PythonCommand: pythonCommand },
|
||||
generateTransaction: true,
|
||||
};
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "PUT", // RC uses PUT for /remote/object/call
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: `cannot reach UE Remote Control at ${baseUrl}: ${e.message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
return { ok: false, status: resp.status, error: `RC HTTP ${resp.status}: ${text}` };
|
||||
}
|
||||
|
||||
// RC returns { ReturnValue: bool, ... } — Python output isn't echoed back.
|
||||
// For stdout capture we rely on the helper module printing into UE's
|
||||
// OutputLog; the result envelope is parsed there too. Best we can do over
|
||||
// REST is to wrap the helper to ALSO store its result in a global the
|
||||
// server can read back with a second GET. Simpler: tee the result to a
|
||||
// file in Saved/, then read it.
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(text); } catch { /* ignore */ }
|
||||
return { ok: true, status: resp.status, response: parsed ?? text };
|
||||
}
|
||||
|
||||
/** Probe UE Remote Control liveness. Returns { ok, version? }. */
|
||||
export async function ping({ baseUrl = DEFAULT_BASE, timeoutMs = 3000 } = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const r = await fetch(`${baseUrl}/remote/info`, { signal: ctrl.signal });
|
||||
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
||||
const j = await r.json().catch(() => null);
|
||||
return { ok: true, info: j };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
108
test/insights.test.js
Normal file
108
test/insights.test.js
Normal file
@ -0,0 +1,108 @@
|
||||
// Structural tests for the Insights MCP layer.
|
||||
// We synthesise minimal Insights-style TSVs in a tmp dir, run the ingester
|
||||
// + analytics handlers directly, and assert the shapes. The full e2e path
|
||||
// (UnrealInsights.exe export) is not exercised here — that needs a real
|
||||
// .utrace and an installed Insights binary.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { dbAvailable, openDb, ingestRawDir, listTables, tableColumns, parseTabular } from "../src/insightsDb.js";
|
||||
|
||||
function mkTmp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "insights-mcp-test-"));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeTsv(dir, name, header, rows) {
|
||||
const text = [header.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
}
|
||||
|
||||
test("parseTabular: TSV with mixed types", async () => {
|
||||
const dir = mkTmp();
|
||||
const raw = path.join(dir, "raw");
|
||||
fs.mkdirSync(raw);
|
||||
writeTsv(raw, "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["UWorld::Tick", "GameThread", "0.0", "0.008"],
|
||||
["BPP_Foo::Tick", "GameThread", "0.008", "0.003"],
|
||||
["FrameTime", "GameThread", "0.0", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.052"], // a hitch
|
||||
]
|
||||
);
|
||||
const parsed = await parseTabular(path.join(raw, "events.tsv"));
|
||||
assert.deepEqual(parsed.headers, ["Name", "ThreadName", "StartTime", "Duration"]);
|
||||
assert.equal(parsed.types[2], "REAL");
|
||||
assert.equal(parsed.rows.length, 4);
|
||||
});
|
||||
|
||||
test("ingestRawDir: creates tables, picks indexes", async (t) => {
|
||||
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet (run npm install)");
|
||||
const dir = mkTmp();
|
||||
const raw = path.join(dir, "raw");
|
||||
fs.mkdirSync(raw);
|
||||
writeTsv(raw, "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["UWorld::Tick", "GameThread", "0.0", "0.008"],
|
||||
["FrameTime", "GameThread", "0.0", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.052"],
|
||||
]
|
||||
);
|
||||
writeTsv(raw, "memtags.tsv",
|
||||
["Name", "Peak", "Current"],
|
||||
[["Niagara", "180000000", "120000000"], ["Audio", "40000000", "30000000"]]
|
||||
);
|
||||
|
||||
const db = openDb(dir);
|
||||
try {
|
||||
const summary = await ingestRawDir(db, raw);
|
||||
assert.ok(summary.find((s) => s.table === "events" && s.rows === 3));
|
||||
assert.ok(summary.find((s) => s.table === "memtags" && s.rows === 2));
|
||||
const tables = listTables(db);
|
||||
assert.ok(tables.includes("events"));
|
||||
assert.ok(tables.includes("memtags"));
|
||||
const cols = tableColumns(db, "events").map((c) => c.name);
|
||||
assert.deepEqual(cols, ["Name", "ThreadName", "StartTime", "Duration"]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
test("handleFrameStats: detects hitches over threshold", async (t) => {
|
||||
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet");
|
||||
const { handleOpenTrace, handleFrameStats } = await import("../src/insights.js");
|
||||
// We bypass open_trace's real-export by manually staging the cache
|
||||
// structure that open_trace would have produced.
|
||||
const projectDir = mkTmp();
|
||||
const traceId = "abcd1234deadbeef";
|
||||
const dir = path.join(projectDir, "Saved/InsightsMCP", traceId);
|
||||
fs.mkdirSync(path.join(dir, "raw"), { recursive: true });
|
||||
writeTsv(path.join(dir, "raw"), "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["FrameTime", "GameThread", "0.000", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.017"],
|
||||
["FrameTime", "GameThread", "0.033", "0.052"], // hitch
|
||||
["FrameTime", "GameThread", "0.085", "0.018"],
|
||||
["FrameTime", "GameThread", "0.103", "0.045"], // hitch
|
||||
]
|
||||
);
|
||||
const db = openDb(dir);
|
||||
try { await ingestRawDir(db, path.join(dir, "raw")); } finally { db.close(); }
|
||||
// Write a minimal trace index entry.
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, "Saved/InsightsMCP/traces.json"),
|
||||
JSON.stringify({ [traceId]: { label: "synthetic", trace_path: "synthetic.utrace" } })
|
||||
);
|
||||
|
||||
const res = await handleFrameStats({ trace_id: traceId, hitch_ms: 33, project_dir: projectDir });
|
||||
assert.equal(res.isError, undefined, res.content?.[0]?.text);
|
||||
const out = JSON.parse(res.content[0].text);
|
||||
assert.equal(out.frames, 5);
|
||||
assert.equal(out.hitch_count, 2);
|
||||
assert.ok(out.worst_hitches[0].ms >= 50);
|
||||
});
|
||||
60
test/mockUE.test.js
Normal file
60
test/mockUE.test.js
Normal file
@ -0,0 +1,60 @@
|
||||
// Spin up a fake Remote Control endpoint and verify the bridge talks to it.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { executePythonInUE, ping } from "../src/ueBridge.js";
|
||||
|
||||
function startMockUE() {
|
||||
const seen = { calls: [] };
|
||||
const srv = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", c => body += c);
|
||||
req.on("end", () => {
|
||||
if (req.url === "/remote/info" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ name: "MockUE", version: "5.7" }));
|
||||
}
|
||||
if (req.url === "/remote/object/call" && req.method === "PUT") {
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(body); } catch {}
|
||||
seen.calls.push(parsed);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ReturnValue: true }));
|
||||
}
|
||||
res.writeHead(404); res.end();
|
||||
});
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
resolve({ srv, seen, baseUrl: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("ping reaches mock UE", async () => {
|
||||
const { srv, baseUrl } = await startMockUE();
|
||||
try {
|
||||
const r = await ping({ baseUrl, timeoutMs: 2000 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.info.name, "MockUE");
|
||||
} finally { srv.close(); }
|
||||
});
|
||||
|
||||
test("executePythonInUE PUTs the right payload to /remote/object/call", async () => {
|
||||
const { srv, seen, baseUrl } = await startMockUE();
|
||||
try {
|
||||
const r = await executePythonInUE("print('hi')", { baseUrl, timeoutMs: 2000 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(seen.calls.length, 1);
|
||||
assert.equal(seen.calls[0].functionName, "ExecutePythonCommand");
|
||||
assert.equal(seen.calls[0].parameters.PythonCommand, "print('hi')");
|
||||
assert.ok(seen.calls[0].objectPath.includes("PythonScriptLibrary"));
|
||||
} finally { srv.close(); }
|
||||
});
|
||||
|
||||
test("executePythonInUE returns ok:false when UE unreachable", async () => {
|
||||
const r = await executePythonInUE("print('x')", { baseUrl: "http://127.0.0.1:1", timeoutMs: 500 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /cannot reach UE/);
|
||||
});
|
||||
70
test/nodeSpec.test.js
Normal file
70
test/nodeSpec.test.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateGraph, NODE_KINDS } from "../src/nodeSpec.js";
|
||||
|
||||
test("validateGraph allows missing bp_path (auto-detect mode)", () => {
|
||||
const errs = validateGraph({ nodes: [] });
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph rejects bp_path not under /Game/ when provided", () => {
|
||||
const errs = validateGraph({ bp_path: "/Engine/Foo", nodes: [] });
|
||||
assert.ok(errs.some(e => e.includes("bp_path")));
|
||||
});
|
||||
|
||||
test("validateGraph accepts empty-string bp_path as auto-detect", () => {
|
||||
const errs = validateGraph({ bp_path: "", nodes: [] });
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph accepts a minimal valid graph", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/Test/BP_Foo",
|
||||
nodes: [{ id: "n1", kind: "branch" }],
|
||||
edges: [],
|
||||
});
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph catches duplicate ids", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [
|
||||
{ id: "a", kind: "branch" },
|
||||
{ id: "a", kind: "sequence" },
|
||||
],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("duplicate")));
|
||||
});
|
||||
|
||||
test("validateGraph catches unknown kind", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [{ id: "a", kind: "teleport_to_mars" }],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("teleport_to_mars")));
|
||||
});
|
||||
|
||||
test("validateGraph catches edge to unknown node", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [{ id: "a", kind: "branch" }],
|
||||
edges: [{ from: ["a", "Then"], to: ["ghost", "Exec"] }],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("ghost")));
|
||||
});
|
||||
|
||||
test("NODE_KINDS contains the documented set", () => {
|
||||
for (const k of [
|
||||
"call_function", "event", "custom_event", "component_event",
|
||||
"variable_get", "variable_set", "self",
|
||||
"branch", "sequence",
|
||||
"switch_int", "switch_string", "switch_enum",
|
||||
"macro", "cast",
|
||||
"make_array", "make_struct", "break_struct", "set_fields_in_struct",
|
||||
"spawn_actor", "format_text", "get_subsystem", "load_asset",
|
||||
"bind_delegate", "unbind_delegate", "call_delegate", "assign_delegate",
|
||||
]) {
|
||||
assert.ok(NODE_KINDS.has(k), `missing kind: ${k}`);
|
||||
}
|
||||
});
|
||||
35
test/pythonGen.test.js
Normal file
35
test/pythonGen.test.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApplyCommand, parseResultEnvelope, _internal } from "../src/pythonGen.js";
|
||||
|
||||
test("pyRepr escapes quotes, backslashes and newlines", () => {
|
||||
const r = _internal.pyRepr(`a'b\\c\nd`);
|
||||
assert.equal(r, `'a\\'b\\\\c\\nd'`);
|
||||
});
|
||||
|
||||
test("buildApplyCommand emits importable bootstrap and embeds payload", () => {
|
||||
const graph = { bp_path: "/Game/X", nodes: [{ id: "n1", kind: "branch" }] };
|
||||
const cmd = buildApplyCommand(graph);
|
||||
assert.match(cmd, /import sys, json/);
|
||||
assert.match(cmd, /apply_graph\.entrypoint\(/);
|
||||
assert.match(cmd, /__MCP_RESULT__/);
|
||||
// Payload must survive as a python literal that, when eval'd, equals our JSON.
|
||||
const m = /apply_graph\.entrypoint\(('.*?')\)/s.exec(cmd);
|
||||
assert.ok(m, "no payload found in command");
|
||||
// Roughly verify: drop quotes & unescape single-quote/backslash and compare JSON.
|
||||
const lit = m[1].slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\")
|
||||
.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
||||
assert.deepEqual(JSON.parse(lit), graph);
|
||||
});
|
||||
|
||||
test("parseResultEnvelope extracts JSON between markers", () => {
|
||||
const stdout = "noise\n__MCP_RESULT__" + JSON.stringify({ ok: true, spawned: { a: "K2N_1" } }) + "__MCP_END__\nmore noise";
|
||||
const r = parseResultEnvelope(stdout);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.spawned.a, "K2N_1");
|
||||
});
|
||||
|
||||
test("parseResultEnvelope errors on missing envelope", () => {
|
||||
const r = parseResultEnvelope("no envelope here");
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
110
test/server.test.js
Normal file
110
test/server.test.js
Normal file
@ -0,0 +1,110 @@
|
||||
// End-to-end smoke: spawn the MCP server as a subprocess, speak the wire
|
||||
// protocol over stdio, verify tools/list and a dry_run apply_graph.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SERVER = path.resolve(HERE, "..", "src", "server.js");
|
||||
|
||||
function startServer() {
|
||||
const proc = spawn(process.execPath, [SERVER], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let buf = "";
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
proc.stdout.on("data", chunk => {
|
||||
buf += chunk.toString();
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
resolve(msg);
|
||||
}
|
||||
} catch { /* ignore non-JSON banner */ }
|
||||
}
|
||||
});
|
||||
proc.stderr.on("data", () => { /* swallow */ });
|
||||
|
||||
function send(method, params) {
|
||||
const id = nextId++;
|
||||
const msg = { jsonrpc: "2.0", id, method, params };
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
proc.stdin.write(JSON.stringify(msg) + "\n");
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) { pending.delete(id); reject(new Error(`timeout: ${method}`)); }
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
function close() { try { proc.kill(); } catch {} }
|
||||
return { send, close };
|
||||
}
|
||||
|
||||
async function initialize(send) {
|
||||
return send("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
});
|
||||
}
|
||||
|
||||
test("MCP server lists tools over stdio", async () => {
|
||||
const { send, close } = startServer();
|
||||
try {
|
||||
await initialize(send);
|
||||
const r = await send("tools/list", {});
|
||||
const names = r.result.tools.map(t => t.name);
|
||||
assert.ok(names.includes("apply_graph"));
|
||||
assert.ok(names.includes("introspect"));
|
||||
assert.ok(names.includes("compile"));
|
||||
assert.ok(names.includes("read_graph"));
|
||||
assert.ok(names.includes("ping_ue"));
|
||||
assert.ok(names.includes("selection_op"));
|
||||
assert.ok(names.includes("voxel_graph_op"));
|
||||
assert.ok(names.includes("material_op"));
|
||||
} finally { close(); }
|
||||
});
|
||||
|
||||
test("apply_graph dry_run returns python without calling UE", async () => {
|
||||
const { send, close } = startServer();
|
||||
try {
|
||||
await initialize(send);
|
||||
const r = await send("tools/call", {
|
||||
name: "apply_graph",
|
||||
arguments: {
|
||||
bp_path: "/Game/Test/BP_Foo",
|
||||
nodes: [
|
||||
{ id: "ev", kind: "event", target: "BeginPlay", position: [0, 0] },
|
||||
{ id: "br", kind: "branch", position: [400, 0] },
|
||||
],
|
||||
edges: [{ from: ["ev", "Then"], to: ["br", "Exec"] }],
|
||||
dry_run: true,
|
||||
},
|
||||
});
|
||||
assert.equal(r.result.isError ?? false, false);
|
||||
const payload = JSON.parse(r.result.content[0].text);
|
||||
assert.equal(payload.dry_run, true);
|
||||
assert.match(payload.python, /apply_graph\.entrypoint/);
|
||||
assert.match(payload.python, /BeginPlay/);
|
||||
} finally { close(); }
|
||||
});
|
||||
|
||||
test("apply_graph rejects invalid graphs", async () => {
|
||||
const { send, close } = startServer();
|
||||
try {
|
||||
await initialize(send);
|
||||
const r = await send("tools/call", {
|
||||
name: "apply_graph",
|
||||
arguments: { bp_path: "not-a-game-path", nodes: [] },
|
||||
});
|
||||
assert.equal(r.result.isError, true);
|
||||
assert.match(r.result.content[0].text, /bp_path/);
|
||||
} finally { close(); }
|
||||
});
|
||||
215
ue_side/ai.py
Normal file
215
ue_side/ai.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""AI ops — BehaviorTree, Blackboard, EQS, AIController.
|
||||
|
||||
Ops:
|
||||
create_behavior_tree {path, blackboard?}
|
||||
create_blackboard {path, parent?}
|
||||
create_eqs_query {path}
|
||||
create_aicontroller_bp {path, parent_class?:'AIController'}
|
||||
add_blackboard_key {path, name, type:'Bool|Int|Float|String|Name|Vector|Rotator|Object|Class|Enum', base_class?}
|
||||
remove_blackboard_key {path, name}
|
||||
list_blackboard_keys {path}
|
||||
set_bt_blackboard {bt_path, blackboard_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
KEY_TYPES = {
|
||||
"Bool": "BlackboardKeyType_Bool",
|
||||
"Int": "BlackboardKeyType_Int",
|
||||
"Float": "BlackboardKeyType_Float",
|
||||
"String": "BlackboardKeyType_String",
|
||||
"Name": "BlackboardKeyType_Name",
|
||||
"Vector": "BlackboardKeyType_Vector",
|
||||
"Rotator": "BlackboardKeyType_Rotator",
|
||||
"Object": "BlackboardKeyType_Object",
|
||||
"Class": "BlackboardKeyType_Class",
|
||||
"Enum": "BlackboardKeyType_Enum",
|
||||
}
|
||||
|
||||
|
||||
def create_behavior_tree(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "BehaviorTreeFactory", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BehaviorTreeFactory missing — enable AIModule"}
|
||||
asset = _at().create_asset(name, folder, unreal.BehaviorTree, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
if args.get("blackboard"):
|
||||
bb = _load(args["blackboard"])
|
||||
if bb is not None:
|
||||
asset.set_editor_property("blackboard_asset", bb)
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_blackboard(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "BlackboardDataFactory", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BlackboardDataFactory missing"}
|
||||
asset = _at().create_asset(name, folder, unreal.BlackboardData, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
if args.get("parent"):
|
||||
parent = _load(args["parent"])
|
||||
if parent is not None:
|
||||
try:
|
||||
asset.set_editor_property("parent", parent)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_eqs_query(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "EnvQueryFactory", None)
|
||||
asset_cls = getattr(unreal, "EnvQuery", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "EnvQuery factory missing — enable EQS in project settings"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_aicontroller_bp(args):
|
||||
folder, name = _split(args["path"])
|
||||
parent_name = args.get("parent_class") or "AIController"
|
||||
parent = unreal.load_class(None, "/Script/AIModule." + parent_name)
|
||||
if parent is None:
|
||||
parent = unreal.load_class(None, parent_name)
|
||||
if parent is None:
|
||||
return {"ok": False, "error": f"parent {parent_name!r} not found"}
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def add_blackboard_key(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
type_name = KEY_TYPES.get(args["type"])
|
||||
if not type_name:
|
||||
return {"ok": False, "error": f"unknown key type {args['type']!r}; valid: {list(KEY_TYPES)}"}
|
||||
key_type_cls = getattr(unreal, type_name, None)
|
||||
if key_type_cls is None:
|
||||
return {"ok": False, "error": f"{type_name} not exposed"}
|
||||
entry_cls = getattr(unreal, "BlackboardEntry", None)
|
||||
if entry_cls is None:
|
||||
return {"ok": False, "error": "BlackboardEntry not exposed; edit via UI"}
|
||||
entry = entry_cls()
|
||||
entry.set_editor_property("entry_name", args["name"])
|
||||
kt = key_type_cls()
|
||||
if args.get("base_class") and hasattr(kt, "base_class"):
|
||||
cls = unreal.load_class(None, args["base_class"])
|
||||
if cls is not None:
|
||||
kt.set_editor_property("base_class", cls)
|
||||
entry.set_editor_property("key_type", kt)
|
||||
keys = list(bb.get_editor_property("keys") or [])
|
||||
keys.append(entry)
|
||||
bb.set_editor_property("keys", keys)
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def remove_blackboard_key(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
target = args["name"]
|
||||
keys = [k for k in (bb.get_editor_property("keys") or [])
|
||||
if str(k.get_editor_property("entry_name")) != target]
|
||||
bb.set_editor_property("keys", keys)
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
return {"ok": True, "removed": target}
|
||||
|
||||
|
||||
def list_blackboard_keys(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
out = []
|
||||
for k in (bb.get_editor_property("keys") or []):
|
||||
try:
|
||||
kt = k.get_editor_property("key_type")
|
||||
out.append({"name": str(k.get_editor_property("entry_name")),
|
||||
"type": kt.get_class().get_name() if kt else None})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "keys": out}
|
||||
|
||||
|
||||
def set_bt_blackboard(args):
|
||||
bt = _load(args["bt_path"])
|
||||
bb = _load(args["blackboard_path"])
|
||||
if bt is None or bb is None:
|
||||
return {"ok": False, "error": "bt or blackboard not found"}
|
||||
bt.set_editor_property("blackboard_asset", bb)
|
||||
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_behavior_tree": create_behavior_tree,
|
||||
"create_blackboard": create_blackboard,
|
||||
"create_eqs_query": create_eqs_query,
|
||||
"create_aicontroller_bp": create_aicontroller_bp,
|
||||
"add_blackboard_key": add_blackboard_key,
|
||||
"remove_blackboard_key": remove_blackboard_key,
|
||||
"list_blackboard_keys": list_blackboard_keys,
|
||||
"set_bt_blackboard": set_bt_blackboard,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-AI] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
386
ue_side/animation.py
Normal file
386
ue_side/animation.py
Normal file
@ -0,0 +1,386 @@
|
||||
"""Animation ops — AnimBlueprint, AnimSequence, AnimMontage, Notifies, Skeleton sockets.
|
||||
|
||||
Ops:
|
||||
create_anim_blueprint {path, skeleton}
|
||||
create_anim_montage {path, sequence?}
|
||||
create_blend_space {path, skeleton, dim?:1|2}
|
||||
get_sequence_info {path} — length, fps, frames, additive type, skeleton
|
||||
list_anim_assets {folder?, skeleton?}
|
||||
list_sockets {skeleton}
|
||||
add_socket {skeleton, name, bone, location?, rotation?}
|
||||
remove_socket {skeleton, name}
|
||||
list_montage_sections {path}
|
||||
add_montage_section {path, name, start_time?}
|
||||
list_notifies {sequence}
|
||||
add_notify {sequence, name, time, notify_class?}
|
||||
remove_notify {sequence, index}
|
||||
list_state_machines {anim_bp} — best-effort (graph names)
|
||||
retarget_animation {src, dst_skeleton, dst_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load(path: str):
|
||||
return unreal.EditorAssetLibrary.load_asset(path)
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
folder, name = path.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def _vec(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def create_anim_blueprint(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": f"skeleton not found: {args['skeleton']}"}
|
||||
folder, name = _split_pkg(args["path"])
|
||||
factory = unreal.AnimBlueprintFactory()
|
||||
factory.set_editor_property("target_skeleton", skel)
|
||||
asset = _at().create_asset(name, folder, unreal.AnimBlueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_anim_montage(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
factory = unreal.AnimMontageFactory()
|
||||
seq_path = args.get("sequence")
|
||||
if seq_path:
|
||||
seq = _load(seq_path)
|
||||
if seq is None:
|
||||
return {"ok": False, "error": f"sequence not found: {seq_path}"}
|
||||
if hasattr(factory, "set_editor_property"):
|
||||
try:
|
||||
factory.set_editor_property("preview_skeletal_mesh", seq.get_editor_property("skeleton"))
|
||||
except Exception:
|
||||
pass
|
||||
asset = _at().create_asset(name, folder, unreal.AnimMontage, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_blend_space(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
folder, name = _split_pkg(args["path"])
|
||||
dim = int(args.get("dim") or 2)
|
||||
factory_cls = getattr(unreal, "BlendSpaceFactory1D" if dim == 1 else "BlendSpaceFactoryNew", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BlendSpace factory unavailable in this UE build"}
|
||||
factory = factory_cls()
|
||||
if hasattr(factory, "target_skeleton"):
|
||||
factory.target_skeleton = skel
|
||||
asset_cls = unreal.BlendSpace1D if dim == 1 else unreal.BlendSpace
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def get_sequence_info(args: dict) -> dict:
|
||||
seq = _load(args["path"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = {"ok": True, "path": args["path"], "class": seq.get_class().get_name()}
|
||||
for attr in ("get_play_length", "sequence_length"):
|
||||
try:
|
||||
v = getattr(seq, attr)
|
||||
out["length"] = v() if callable(v) else float(v)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["num_frames"] = int(seq.get_editor_property("number_of_sampled_keys") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["frame_rate"] = float(seq.get_editor_property("target_frame_rate") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
skel = seq.get_editor_property("skeleton")
|
||||
if skel:
|
||||
out["skeleton"] = skel.get_path_name().split(".")[0]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["additive_type"] = str(seq.get_editor_property("additive_anim_type"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def list_anim_assets(args: dict) -> dict:
|
||||
folder = args.get("folder") or "/Game"
|
||||
skel_filter = args.get("skeleton")
|
||||
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True,
|
||||
class_names=["AnimSequence", "AnimMontage", "BlendSpace", "BlendSpace1D", "AnimBlueprint"])
|
||||
items = []
|
||||
for d in ar.get_assets(filt):
|
||||
path = str(d.package_name)
|
||||
if skel_filter:
|
||||
try:
|
||||
asset = _load(path)
|
||||
sk = asset.get_editor_property("skeleton") if asset else None
|
||||
if not sk or sk.get_path_name().split(".")[0] != skel_filter:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
items.append({"path": path, "class": str(d.asset_class)})
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
|
||||
|
||||
def list_sockets(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
try:
|
||||
sockets = skel.get_editor_property("sockets") or []
|
||||
except Exception:
|
||||
sockets = []
|
||||
out = []
|
||||
for s in sockets:
|
||||
try:
|
||||
out.append({
|
||||
"name": str(s.get_editor_property("socket_name")),
|
||||
"bone": str(s.get_editor_property("bone_name")),
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "count": len(out), "sockets": out}
|
||||
|
||||
|
||||
def add_socket(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
sock = unreal.SkeletalMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("bone_name", args["bone"])
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sockets = list(skel.get_editor_property("sockets") or [])
|
||||
sockets.append(sock)
|
||||
skel.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def remove_socket(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
target = args["name"]
|
||||
sockets = [s for s in (skel.get_editor_property("sockets") or [])
|
||||
if str(s.get_editor_property("socket_name")) != target]
|
||||
skel.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
||||
return {"ok": True, "removed": target}
|
||||
|
||||
|
||||
def list_montage_sections(args: dict) -> dict:
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
secs = m.get_editor_property("composite_sections") or []
|
||||
except Exception:
|
||||
secs = []
|
||||
out = []
|
||||
for s in secs:
|
||||
try:
|
||||
out.append({"name": str(s.get_editor_property("section_name")),
|
||||
"start_time": float(s.get_editor_property("segment_begin_time") or 0)})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "sections": out}
|
||||
|
||||
|
||||
def add_montage_section(args: dict) -> dict:
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
sec = unreal.CompositeSection() if hasattr(unreal, "CompositeSection") else None
|
||||
if sec is None:
|
||||
return {"ok": False, "error": "CompositeSection not exposed in this UE build"}
|
||||
sec.set_editor_property("section_name", args["name"])
|
||||
sec.set_editor_property("segment_begin_time", float(args.get("start_time") or 0))
|
||||
secs = list(m.get_editor_property("composite_sections") or [])
|
||||
secs.append(sec)
|
||||
m.set_editor_property("composite_sections", secs)
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_notifies(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
notifies = seq.get_editor_property("notifies") or []
|
||||
except Exception:
|
||||
notifies = []
|
||||
out = []
|
||||
for n in notifies:
|
||||
try:
|
||||
cls = n.get_editor_property("notify")
|
||||
out.append({
|
||||
"name": str(n.get_editor_property("notify_name")),
|
||||
"time": float(n.get_editor_property("trigger_time_offset") or 0) + float(n.get_editor_property("link_value") or 0),
|
||||
"class": cls.get_class().get_name() if cls else None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "notifies": out}
|
||||
|
||||
|
||||
def add_notify(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
ev = unreal.AnimNotifyEvent() if hasattr(unreal, "AnimNotifyEvent") else None
|
||||
if ev is None:
|
||||
return {"ok": False, "error": "AnimNotifyEvent not exposed; use editor UI"}
|
||||
ev.set_editor_property("notify_name", args["name"])
|
||||
ev.set_editor_property("link_value", float(args["time"]))
|
||||
notifies = list(seq.get_editor_property("notifies") or [])
|
||||
notifies.append(ev)
|
||||
seq.set_editor_property("notifies", notifies)
|
||||
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def remove_notify(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
notifies = list(seq.get_editor_property("notifies") or [])
|
||||
idx = int(args["index"])
|
||||
if idx < 0 or idx >= len(notifies):
|
||||
return {"ok": False, "error": "index out of range"}
|
||||
notifies.pop(idx)
|
||||
seq.set_editor_property("notifies", notifies)
|
||||
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_state_machines(args: dict) -> dict:
|
||||
bp = _load(args["anim_bp"])
|
||||
if bp is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = []
|
||||
try:
|
||||
for g in bp.get_editor_property("ubergraph_pages") or []:
|
||||
out.append(g.get_name())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for g in bp.get_editor_property("function_graphs") or []:
|
||||
out.append(g.get_name())
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "graphs": out}
|
||||
|
||||
|
||||
def retarget_animation(args: dict) -> dict:
|
||||
src = _load(args["src"])
|
||||
dst_skel = _load(args["dst_skeleton"])
|
||||
if src is None or dst_skel is None:
|
||||
return {"ok": False, "error": "src or dst_skeleton not found"}
|
||||
folder, name = _split_pkg(args["dst_path"])
|
||||
try:
|
||||
new_asset = unreal.AnimationLibrary.duplicate_animation_asset(src, name, folder) \
|
||||
if hasattr(unreal, "AnimationLibrary") and hasattr(unreal.AnimationLibrary, "duplicate_animation_asset") \
|
||||
else unreal.EditorAssetLibrary.duplicate_asset(src.get_path_name(), folder + "/" + name)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"duplicate failed: {e}"}
|
||||
try:
|
||||
new_asset.set_editor_property("skeleton", dst_skel)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(new_asset.get_path_name())
|
||||
return {"ok": True, "path": new_asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_anim_blueprint": create_anim_blueprint,
|
||||
"create_anim_montage": create_anim_montage,
|
||||
"create_blend_space": create_blend_space,
|
||||
"get_sequence_info": get_sequence_info,
|
||||
"list_anim_assets": list_anim_assets,
|
||||
"list_sockets": list_sockets,
|
||||
"add_socket": add_socket,
|
||||
"remove_socket": remove_socket,
|
||||
"list_montage_sections": list_montage_sections,
|
||||
"add_montage_section": add_montage_section,
|
||||
"list_notifies": list_notifies,
|
||||
"add_notify": add_notify,
|
||||
"remove_notify": remove_notify,
|
||||
"list_state_machines": list_state_machines,
|
||||
"retarget_animation": retarget_animation,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-ANIM] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
519
ue_side/apply_graph.py
Normal file
519
ue_side/apply_graph.py
Normal file
@ -0,0 +1,519 @@
|
||||
"""
|
||||
UE-side runtime for the Blueprint MCP server.
|
||||
|
||||
Reaches into the editor via `unreal.MCPGraphLibrary` — a C++ BlueprintFunction
|
||||
library provided by Plugins/UEBlueprintMCP/Source. That library exposes K2 node
|
||||
spawning, which is NOT available in stock UE Python.
|
||||
|
||||
Flow per apply():
|
||||
1. Resolve target Blueprint (by path, or via Content Browser selection).
|
||||
2. Resolve target graph (EventGraph by default, else named function).
|
||||
3. For each NodeSpec — call the matching MCPGraphLibrary spawn function and
|
||||
remember NodeGuid keyed by local id.
|
||||
4. Set pin defaults (params).
|
||||
5. For each EdgeSpec — call ConnectPins using NodeGuids.
|
||||
6. Compile + save Blueprint.
|
||||
7. Write the result envelope to <Project>/Saved/MCP/last.json and unreal.log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError: # pragma: no cover — tests run outside UE
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
"""Return the MCPGraphLibrary class, or raise if the plugin isn't built."""
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError(
|
||||
"unreal.MCPGraphLibrary not found — the UEBlueprintMCP C++ plugin "
|
||||
"is not built. Right-click your .uproject → Generate VS project files, "
|
||||
"then build the editor."
|
||||
)
|
||||
return L
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blueprint / graph resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
def _load_blueprint(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"Blueprint not found at {path}")
|
||||
if not isinstance(asset, unreal.Blueprint):
|
||||
raise RuntimeError(f"Asset at {path} is not a Blueprint ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _active_blueprint():
|
||||
"""Return (Blueprint, path) from Content Browser selection."""
|
||||
selected = []
|
||||
try:
|
||||
selected = list(unreal.EditorUtilityLibrary.get_selected_assets() or [])
|
||||
except Exception:
|
||||
selected = []
|
||||
bps = [a for a in selected if isinstance(a, unreal.Blueprint)]
|
||||
if len(bps) == 1:
|
||||
bp = bps[0]
|
||||
return bp, bp.get_path_name().split(".")[0]
|
||||
if len(bps) > 1:
|
||||
raise RuntimeError(f"{len(bps)} Blueprints selected — select exactly one or pass bp_path")
|
||||
raise RuntimeError(
|
||||
"auto-detect needs one Blueprint selected in Content Browser. "
|
||||
"Click your BP asset and retry, or pass bp_path explicitly."
|
||||
)
|
||||
|
||||
|
||||
def _find_graph(bp, function_name: str):
|
||||
L = _lib()
|
||||
if function_name in (None, "", "EventGraph"):
|
||||
g = L.find_event_graph(bp)
|
||||
if g is None:
|
||||
raise RuntimeError("Blueprint has no EventGraph")
|
||||
return g
|
||||
g = L.find_function_graph(bp, function_name)
|
||||
if g is None:
|
||||
raise RuntimeError(f"Graph '{function_name}' not found on Blueprint")
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Function target parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resolve_function_owner(target: str):
|
||||
"""Parse 'KismetSystemLibrary.PrintString' or '/Script/Engine.KismetSystemLibrary:PrintString'.
|
||||
Returns (UClass, FName-able str).
|
||||
"""
|
||||
if ":" in target:
|
||||
cls_path, fn = target.split(":", 1)
|
||||
elif "." in target:
|
||||
cls_path, fn = target.rsplit(".", 1)
|
||||
else:
|
||||
raise RuntimeError(f"call_function target must be Class.Function, got {target!r}")
|
||||
|
||||
cls = None
|
||||
if "/" in cls_path:
|
||||
cls = unreal.load_class(None, cls_path)
|
||||
else:
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
|
||||
cls = unreal.load_class(None, prefix + cls_path)
|
||||
if cls is not None:
|
||||
break
|
||||
if cls is None:
|
||||
raise RuntimeError(f"Class not found for target {target!r}")
|
||||
return cls, fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply
|
||||
# ---------------------------------------------------------------------------
|
||||
def apply(graph: dict) -> dict:
|
||||
result = {
|
||||
"ok": False, "spawned": {}, "node_guids": {},
|
||||
"errors": [], "warnings": [], "compile": None,
|
||||
}
|
||||
if unreal is None:
|
||||
result["errors"].append("unreal module not importable — must run inside UE editor")
|
||||
return result
|
||||
|
||||
try:
|
||||
L = _lib()
|
||||
bp_path = graph.get("bp_path") or ""
|
||||
if bp_path:
|
||||
bp = _load_blueprint(bp_path)
|
||||
result["resolved_bp"] = bp_path
|
||||
else:
|
||||
bp, bp_path = _active_blueprint()
|
||||
result["resolved_bp"] = bp_path
|
||||
result["auto_detected"] = True
|
||||
|
||||
function_name = graph.get("function") or "EventGraph"
|
||||
target_graph = _find_graph(bp, function_name)
|
||||
|
||||
# ---- mode handling ----
|
||||
mode = (graph.get("mode") or "append").lower()
|
||||
if mode in ("replace_function", "replace_event"):
|
||||
try:
|
||||
cleared = L.clear_graph(target_graph)
|
||||
result["cleared_nodes"] = int(cleared)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"clear_graph failed: {e}")
|
||||
|
||||
layout = str(graph.get("layout") or "").lower()
|
||||
auto_layout = bool(graph.get("auto_layout", False))
|
||||
should_layout = auto_layout or layout in ("compact", "columns")
|
||||
|
||||
node_specs = graph_layout.normalize_specs(
|
||||
graph.get("nodes", []),
|
||||
graph.get("edges", []),
|
||||
domain="blueprint",
|
||||
) if should_layout else list(graph.get("nodes", []))
|
||||
|
||||
# ---- spawn nodes ----
|
||||
guid_map: dict[str, str] = {}
|
||||
for spec in node_specs:
|
||||
try:
|
||||
guid = _spawn_one(target_graph, spec, bp, L)
|
||||
if not guid:
|
||||
raise RuntimeError("spawn returned empty guid")
|
||||
guid_map[spec["id"]] = guid
|
||||
result["spawned"][spec["id"]] = guid
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(f"node {spec.get('id')!r}: {e}")
|
||||
|
||||
# ---- post-spawn config (switch cases, format text, sequence pins are spawn-time) ----
|
||||
for spec in node_specs:
|
||||
local_id = spec.get("id")
|
||||
guid = guid_map.get(local_id)
|
||||
if not guid:
|
||||
continue
|
||||
p = spec.get("params") or {}
|
||||
kind = spec.get("kind")
|
||||
|
||||
# FormatText: set Format & reconstruct so {arg} pins appear
|
||||
if kind == "format_text" and "Format" in p:
|
||||
try:
|
||||
L.format_text_set_format(target_graph, guid, str(p["Format"]))
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"format_text_set_format: {local_id}: {e}")
|
||||
|
||||
# Switch nodes: add case pins
|
||||
if kind in ("switch_int", "switch_string", "switch_enum"):
|
||||
cases = int(p.get("cases", 0))
|
||||
for _ in range(cases):
|
||||
try:
|
||||
L.add_case_pin_to_switch(target_graph, guid)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"add_case_pin_to_switch: {local_id}: {e}")
|
||||
|
||||
# ---- set pin defaults ----
|
||||
SKIP_PARAMS = {"cases", "then_count", "width", "height", "text", "Format"}
|
||||
for spec in node_specs:
|
||||
local_id = spec.get("id")
|
||||
guid = guid_map.get(local_id)
|
||||
if not guid:
|
||||
continue
|
||||
for pin_name, value in (spec.get("params") or {}).items():
|
||||
if pin_name in SKIP_PARAMS:
|
||||
continue
|
||||
try:
|
||||
ok = L.set_pin_default(target_graph, guid, pin_name, str(value))
|
||||
if not ok:
|
||||
result["warnings"].append(f"set_pin_default failed: {local_id}.{pin_name}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"set_pin_default raised: {local_id}.{pin_name}: {e}")
|
||||
|
||||
# ---- connect edges ----
|
||||
def _resolve_ref(ref: str):
|
||||
# "@guid:<NodeGuid>" -> raw guid (existing node)
|
||||
if isinstance(ref, str) and ref.startswith("@guid:"):
|
||||
return ref[len("@guid:"):]
|
||||
return guid_map.get(ref)
|
||||
|
||||
for edge in graph.get("edges", []):
|
||||
try:
|
||||
(fa, fp) = edge["from"]
|
||||
(tb, tp) = edge["to"]
|
||||
ga, gb = _resolve_ref(fa), _resolve_ref(tb)
|
||||
if not ga or not gb:
|
||||
raise RuntimeError("edge references unspawned/unknown node")
|
||||
ok = L.connect_pins(target_graph, ga, fp, gb, tp)
|
||||
if not ok:
|
||||
raise RuntimeError(f"connect_pins returned false ({fa}.{fp} -> {tb}.{tp})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(f"edge {edge}: {e}")
|
||||
|
||||
if result["errors"]:
|
||||
result["ok"] = False
|
||||
else:
|
||||
try:
|
||||
msgs = list(L.compile_with_messages(bp))
|
||||
result["compile_messages"] = msgs
|
||||
result["compile"] = "ok" if all(not m.startswith("ERROR|") for m in msgs) else "errors"
|
||||
except Exception:
|
||||
L.compile_and_save_blueprint(bp)
|
||||
result["compile"] = "ok"
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
result["ok"] = (result["compile"] == "ok")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(str(e))
|
||||
result["traceback"] = traceback.format_exc()
|
||||
return result
|
||||
|
||||
|
||||
def _load_class(path_or_name: str):
|
||||
if "/" in path_or_name:
|
||||
return unreal.load_class(None, path_or_name)
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
|
||||
c = unreal.load_class(None, prefix + path_or_name)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _load_struct(path: str):
|
||||
if "/" in path:
|
||||
return unreal.load_object(None, path)
|
||||
return unreal.load_object(None, "/Script/CoreUObject." + path)
|
||||
|
||||
|
||||
def _load_enum(path: str):
|
||||
if "/" in path:
|
||||
return unreal.load_object(None, path)
|
||||
return unreal.load_object(None, "/Script/CoreUObject." + path)
|
||||
|
||||
|
||||
def _spawn_one(graph, spec: dict, bp, L) -> str:
|
||||
kind = spec["kind"]
|
||||
pos = unreal.Vector2D(*(spec.get("position") or [0, 0]))
|
||||
target = spec.get("target") or ""
|
||||
|
||||
# ---- Function / event ----
|
||||
if kind == "call_function":
|
||||
cls, fn = _resolve_function_owner(target)
|
||||
return L.spawn_call_function_node(bp, graph, cls, fn, pos)
|
||||
if kind == "event":
|
||||
return L.spawn_event_node(bp, graph, None, target, pos)
|
||||
if kind == "custom_event":
|
||||
return L.spawn_custom_event_node(bp, graph, target, pos)
|
||||
if kind == "component_event":
|
||||
if "." not in target:
|
||||
raise RuntimeError(f"component_event target must be 'Component.Delegate', got {target!r}")
|
||||
comp, delegate = target.split(".", 1)
|
||||
return L.spawn_component_bound_event(bp, graph, comp, delegate, pos)
|
||||
|
||||
# ---- Variables ----
|
||||
if kind == "variable_get":
|
||||
return L.spawn_variable_get_node(bp, graph, target, pos)
|
||||
if kind == "variable_set":
|
||||
return L.spawn_variable_set_node(bp, graph, target, pos)
|
||||
if kind == "self":
|
||||
return L.spawn_self_node(bp, graph, pos)
|
||||
|
||||
# ---- Flow control ----
|
||||
if kind == "branch":
|
||||
return L.spawn_branch_node(bp, graph, pos)
|
||||
if kind == "sequence":
|
||||
out_count = int((spec.get("params") or {}).get("then_count", 2))
|
||||
return L.spawn_sequence_node(bp, graph, pos, out_count)
|
||||
if kind == "switch_int":
|
||||
return L.spawn_switch_on_int_node(bp, graph, pos)
|
||||
if kind == "switch_string":
|
||||
return L.spawn_switch_on_string_node(bp, graph, pos)
|
||||
if kind == "switch_enum":
|
||||
e = _load_enum(target)
|
||||
if e is None:
|
||||
raise RuntimeError(f"switch_enum: enum not found at {target!r}")
|
||||
return L.spawn_switch_on_enum_node(bp, graph, e, pos)
|
||||
if kind == "macro":
|
||||
# target: macro name from StandardMacros (e.g. "ForLoop", "ForEachLoop",
|
||||
# "WhileLoop", "DoOnce", "Gate", "FlipFlop", "MultiGate", "IsValid")
|
||||
return L.spawn_standard_macro_node(bp, graph, target, pos)
|
||||
|
||||
# ---- Cast ----
|
||||
if kind == "cast":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"cast: class not found at {target!r}")
|
||||
return L.spawn_cast_node(bp, graph, cls, pos)
|
||||
|
||||
# ---- Collections / structs ----
|
||||
if kind == "make_array":
|
||||
return L.spawn_make_array_node(bp, graph, pos)
|
||||
if kind == "make_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"make_struct: struct not found at {target!r}")
|
||||
return L.spawn_make_struct_node(bp, graph, s, pos)
|
||||
if kind == "break_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"break_struct: struct not found at {target!r}")
|
||||
return L.spawn_break_struct_node(bp, graph, s, pos)
|
||||
if kind == "set_fields_in_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"set_fields_in_struct: struct not found at {target!r}")
|
||||
return L.spawn_set_fields_in_struct_node(bp, graph, s, pos)
|
||||
|
||||
# ---- Misc ----
|
||||
if kind == "spawn_actor":
|
||||
return L.spawn_spawn_actor_from_class_node(bp, graph, pos)
|
||||
if kind == "format_text":
|
||||
return L.spawn_format_text_node(bp, graph, pos)
|
||||
if kind == "get_subsystem":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"get_subsystem: class not found at {target!r}")
|
||||
return L.spawn_get_subsystem_node(bp, graph, cls, pos)
|
||||
if kind == "load_asset":
|
||||
return L.spawn_load_asset_node(bp, graph, pos)
|
||||
|
||||
# ---- Delegates ----
|
||||
if kind in ("bind_delegate", "unbind_delegate", "assign_delegate"):
|
||||
if "." not in target:
|
||||
raise RuntimeError(f"{kind} target must be 'Component.Delegate', got {target!r}")
|
||||
comp, delegate = target.split(".", 1)
|
||||
fn = {
|
||||
"bind_delegate": L.spawn_bind_delegate_node,
|
||||
"unbind_delegate": L.spawn_unbind_delegate_node,
|
||||
"assign_delegate": L.spawn_assign_delegate_node,
|
||||
}[kind]
|
||||
return fn(bp, graph, comp, delegate, pos)
|
||||
if kind == "call_delegate":
|
||||
return L.spawn_call_delegate_node(bp, graph, target, pos)
|
||||
|
||||
# ---- Tier 2 nodes ----
|
||||
if kind == "knot":
|
||||
return L.spawn_knot_node(bp, graph, pos)
|
||||
if kind == "comment":
|
||||
p = spec.get("params") or {}
|
||||
size = unreal.Vector2D(float(p.get("width", 400)), float(p.get("height", 200)))
|
||||
text = str(p.get("text", target or "Comment"))
|
||||
return L.spawn_comment_node(bp, graph, pos, size, text)
|
||||
if kind == "enum_literal":
|
||||
e = _load_enum(target)
|
||||
if e is None:
|
||||
raise RuntimeError(f"enum_literal: enum not found at {target!r}")
|
||||
return L.spawn_enum_literal_node(bp, graph, e, pos)
|
||||
if kind == "get_class_defaults":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"get_class_defaults: class not found at {target!r}")
|
||||
return L.spawn_get_class_defaults_node(bp, graph, cls, pos)
|
||||
if kind == "function_result":
|
||||
return L.spawn_function_result_node(bp, graph, pos)
|
||||
if kind == "get_array_item":
|
||||
return L.spawn_get_array_item_node(bp, graph, pos)
|
||||
if kind == "operator":
|
||||
# target = "+", "-", "*", etc.
|
||||
return L.spawn_promotable_operator_node(bp, graph, target, pos)
|
||||
|
||||
raise RuntimeError(f"kind {kind!r} not supported")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation (dry-resolve all targets without spawning)
|
||||
# ---------------------------------------------------------------------------
|
||||
def validate(graph: dict) -> dict:
|
||||
"""Resolve all targets in NodeSpec without mutating UE.
|
||||
|
||||
Returns {ok, missing: [{id, kind, target, reason}], resolved: [{id, ...}]}.
|
||||
"""
|
||||
out = {"ok": True, "missing": [], "resolved": []}
|
||||
if unreal is None:
|
||||
out["ok"] = False
|
||||
out["missing"].append({"reason": "unreal module not importable"})
|
||||
return out
|
||||
for spec in graph.get("nodes", []):
|
||||
kind = spec.get("kind", "")
|
||||
target = spec.get("target") or ""
|
||||
try:
|
||||
if kind == "call_function":
|
||||
cls, fn = _resolve_function_owner(target)
|
||||
if not cls.find_function(fn):
|
||||
raise RuntimeError(f"function {fn!r} not on {cls.get_name()}")
|
||||
elif kind in ("cast", "get_subsystem", "get_class_defaults"):
|
||||
if _load_class(target) is None:
|
||||
raise RuntimeError(f"class not found: {target!r}")
|
||||
elif kind in ("make_struct", "break_struct", "set_fields_in_struct"):
|
||||
if _load_struct(target) is None:
|
||||
raise RuntimeError(f"struct not found: {target!r}")
|
||||
elif kind in ("switch_enum", "enum_literal"):
|
||||
if _load_enum(target) is None:
|
||||
raise RuntimeError(f"enum not found: {target!r}")
|
||||
out["resolved"].append({"id": spec.get("id"), "kind": kind, "target": target})
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["ok"] = False
|
||||
out["missing"].append({"id": spec.get("id"), "kind": kind, "target": target, "reason": str(e)})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_graph — dump existing graph via C++ DumpGraphToJson
|
||||
# ---------------------------------------------------------------------------
|
||||
def read_graph(bp_path: str, function_name: str = "EventGraph") -> dict:
|
||||
try:
|
||||
L = _lib()
|
||||
if bp_path:
|
||||
bp = _load_blueprint(bp_path)
|
||||
else:
|
||||
bp, bp_path = _active_blueprint()
|
||||
g = _find_graph(bp, function_name)
|
||||
raw = L.dump_graph_to_json(g)
|
||||
return {"ok": True, "bp_path": bp_path, "function": function_name, "graph": json.loads(raw)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint (called by the MCP server's bootstrap snippet)
|
||||
# ---------------------------------------------------------------------------
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
graph = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result_json = json.dumps({"ok": False, "errors": [f"bad payload json: {e}"]})
|
||||
_tee(result_json, {})
|
||||
return result_json
|
||||
op = graph.get("__op", "apply")
|
||||
if op == "read":
|
||||
out = read_graph(graph.get("bp_path") or "", graph.get("function") or "EventGraph")
|
||||
elif op == "validate":
|
||||
out = validate(graph)
|
||||
elif op == "batch":
|
||||
out = batch_apply(graph.get("ops") or [])
|
||||
else:
|
||||
out = apply(graph)
|
||||
result_json = json.dumps(out)
|
||||
_tee(result_json, graph)
|
||||
return result_json
|
||||
|
||||
|
||||
def batch_apply(ops: list) -> dict:
|
||||
"""Run a list of apply()s atomically-ish — same transaction, single undo."""
|
||||
results = []
|
||||
for i, op in enumerate(ops):
|
||||
op_kind = op.get("op") or "apply"
|
||||
if op_kind == "apply":
|
||||
results.append({"index": i, "op": "apply", "result": apply(op)})
|
||||
elif op_kind == "validate":
|
||||
results.append({"index": i, "op": "validate", "result": validate(op)})
|
||||
elif op_kind == "read":
|
||||
results.append({"index": i, "op": "read", "result": read_graph(op.get("bp_path") or "", op.get("function") or "EventGraph")})
|
||||
else:
|
||||
results.append({"index": i, "op": op_kind, "result": {"ok": False, "error": f"unknown op kind {op_kind!r}"}})
|
||||
overall_ok = all(r["result"].get("ok") for r in results)
|
||||
return {"ok": overall_ok, "results": results}
|
||||
|
||||
|
||||
def _tee(result_json: str, payload: dict) -> None:
|
||||
try:
|
||||
if unreal is None:
|
||||
return
|
||||
proj_dir = unreal.Paths.project_saved_dir()
|
||||
out_dir = proj_dir + "MCP/"
|
||||
try:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
rid = (payload or {}).get("__rid") or "last"
|
||||
# Always write to last.json for human inspection, and to {rid}.json for race-free reads
|
||||
with open(out_dir + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(result_json)
|
||||
if rid != "last":
|
||||
with open(out_dir + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(result_json)
|
||||
unreal.log("[MCP] " + result_json[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
243
ue_side/assets.py
Normal file
243
ue_side/assets.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""Asset CRUD via UE AssetTools — pure Python, no C++ needed.
|
||||
|
||||
Exposed ops (dispatched by `entrypoint(payload_json)` from the MCP server):
|
||||
create_blueprint {path, parent_class}
|
||||
create_widget_bp {path}
|
||||
create_enum {path, values:[]}
|
||||
create_struct {path, fields:[{name,type}]}
|
||||
create_data_asset {path, class}
|
||||
create_data_table {path, row_struct}
|
||||
duplicate {src, dst}
|
||||
rename {src, dst}
|
||||
move {src, dst_folder}
|
||||
delete {path}
|
||||
reparent_blueprint {bp, new_parent_class}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load_class(path_or_name: str):
|
||||
if "/" in path_or_name:
|
||||
return unreal.load_class(None, path_or_name)
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/UnrealEd.", "/Script/Kismet."):
|
||||
c = unreal.load_class(None, prefix + path_or_name)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
"""/Game/Foo/Bar/Asset -> (/Game/Foo/Bar, Asset)"""
|
||||
if "/" not in path:
|
||||
raise RuntimeError(f"bad asset path {path!r}")
|
||||
folder, name = path.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def create_blueprint(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
parent = _load_class(args.get("parent_class") or "Actor")
|
||||
if parent is None:
|
||||
return {"ok": False, "error": f"parent class not found: {args.get('parent_class')!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent.get_name()}
|
||||
|
||||
|
||||
def create_widget_bp(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.WidgetBlueprintFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.WidgetBlueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_enum(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
values = args.get("values") or []
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.EnumFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.UserDefinedEnum, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
for i, v in enumerate(values):
|
||||
try:
|
||||
unreal.EnumEditorUtils.add_enumerator_to_user_defined_enum(asset, str(v))
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "values_added": len(values)}
|
||||
|
||||
|
||||
def create_struct(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
fields = args.get("fields") or [] # [{name, type}]
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.StructureFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.UserDefinedStruct, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
# Field editing via StructureEditorUtils not exposed reliably in stock Python.
|
||||
# User can rename default member via UI; we just create the struct.
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "note": "Field add not exposed in stock Python; edit in editor or pre-create C++ struct."}
|
||||
|
||||
|
||||
def create_data_asset(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
cls = _load_class(args.get("class") or "")
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args.get('class')!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.DataAssetFactory()
|
||||
factory.set_editor_property("data_asset_class", cls)
|
||||
asset = _at().create_asset(name, folder, cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_data_table(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
row_struct_path = args.get("row_struct") or ""
|
||||
struct = unreal.load_object(None, row_struct_path) if row_struct_path else None
|
||||
if struct is None:
|
||||
return {"ok": False, "error": f"row_struct not found: {row_struct_path!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.DataTableFactory()
|
||||
factory.set_editor_property("struct", struct)
|
||||
asset = _at().create_asset(name, folder, unreal.DataTable, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def duplicate(args: dict) -> dict:
|
||||
src, dst = args["src"], args["dst"]
|
||||
asset = unreal.EditorAssetLibrary.duplicate_asset(src, dst)
|
||||
return {"ok": asset is not None, "src": src, "dst": dst}
|
||||
|
||||
|
||||
def rename(args: dict) -> dict:
|
||||
src, dst = args["src"], args["dst"]
|
||||
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
|
||||
return {"ok": bool(ok), "src": src, "dst": dst}
|
||||
|
||||
|
||||
def move(args: dict) -> dict:
|
||||
src, folder = args["src"], args["dst_folder"]
|
||||
name = src.rsplit("/", 1)[-1]
|
||||
dst = folder.rstrip("/") + "/" + name
|
||||
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
|
||||
return {"ok": bool(ok), "src": src, "dst": dst}
|
||||
|
||||
|
||||
def delete(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
ok = unreal.EditorAssetLibrary.delete_asset(path)
|
||||
return {"ok": bool(ok), "path": path}
|
||||
|
||||
|
||||
def reparent_blueprint(args: dict) -> dict:
|
||||
bp_path = args["bp"]
|
||||
new_parent = _load_class(args["new_parent_class"])
|
||||
if new_parent is None:
|
||||
return {"ok": False, "error": f"new_parent_class not found: {args['new_parent_class']!r}"}
|
||||
bp = unreal.EditorAssetLibrary.load_asset(bp_path)
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
return {"ok": False, "error": "not a blueprint"}
|
||||
# WidgetBlueprint (and others) don't expose ParentClass as a settable editor
|
||||
# property — use the proper reparent API, fall back to set_editor_property.
|
||||
try:
|
||||
unreal.BlueprintEditorLibrary.reparent_blueprint(bp, new_parent)
|
||||
except Exception:
|
||||
bp.set_editor_property("parent_class", new_parent)
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return {"ok": True, "bp": bp_path, "new_parent": new_parent.get_name()}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_blueprint": create_blueprint,
|
||||
"create_widget_bp": create_widget_bp,
|
||||
"create_enum": create_enum,
|
||||
"create_struct": create_struct,
|
||||
"create_data_asset": create_data_asset,
|
||||
"create_data_table": create_data_table,
|
||||
"duplicate": duplicate,
|
||||
"rename": rename,
|
||||
"move": move,
|
||||
"delete": delete,
|
||||
"reparent_blueprint": reparent_blueprint,
|
||||
}
|
||||
|
||||
# Member-variable authoring lives in variables.py and is surfaced as the
|
||||
# dedicated `variable_op` tool. Merge its NON-colliding op names here too so the
|
||||
# capability is reachable via `asset_op` without a JS server restart. (We skip
|
||||
# the short aliases add/rename/delete/list — they would shadow asset ops.)
|
||||
try:
|
||||
import importlib as _il
|
||||
import variables as _vars
|
||||
_il.reload(_vars)
|
||||
for _k in ("add_variable", "set_var_default", "set_var_meta",
|
||||
"rename_variable", "delete_variable", "list_variables"):
|
||||
OPS[_k] = _vars.VAR_OPS[_k]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-ASSETS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
145
ue_side/audio.py
Normal file
145
ue_side/audio.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""Audio ops — MetaSounds, SoundCue, Attenuation, SoundClass/Mix.
|
||||
|
||||
Ops:
|
||||
create_metasound_source {path}
|
||||
create_metasound_patch {path}
|
||||
create_sound_cue {path}
|
||||
create_sound_attenuation {path}
|
||||
create_sound_class {path}
|
||||
create_sound_mix {path}
|
||||
set_property {path, property, value}
|
||||
get_property {path, property}
|
||||
list_sound_assets {folder?}
|
||||
play_sound_2d {path} — preview in editor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def _create(path, asset_cls_name, factory_name):
|
||||
folder, name = _split(path)
|
||||
cls = getattr(unreal, asset_cls_name, None)
|
||||
factory_cls = getattr(unreal, factory_name, None)
|
||||
if cls is None or factory_cls is None:
|
||||
return {"ok": False, "error": f"{asset_cls_name}/{factory_name} not exposed (plugin disabled?)"}
|
||||
asset = _at().create_asset(name, folder, cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_metasound_source(args): return _create(args["path"], "MetaSoundSource", "MetaSoundSourceFactory")
|
||||
def create_metasound_patch(args): return _create(args["path"], "MetaSoundPatch", "MetaSoundPatchFactory")
|
||||
def create_sound_cue(args): return _create(args["path"], "SoundCue", "SoundCueFactoryNew")
|
||||
def create_sound_attenuation(args): return _create(args["path"], "SoundAttenuation", "SoundAttenuationFactory")
|
||||
def create_sound_class(args): return _create(args["path"], "SoundClass", "SoundClassFactory")
|
||||
def create_sound_mix(args): return _create(args["path"], "SoundMix", "SoundMixFactory")
|
||||
|
||||
|
||||
def set_property(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "asset not found"}
|
||||
try:
|
||||
a.set_editor_property(args["property"], args["value"])
|
||||
unreal.EditorAssetLibrary.save_asset(a.get_path_name())
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_property(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "asset not found"}
|
||||
try:
|
||||
v = a.get_editor_property(args["property"])
|
||||
return {"ok": True, "value": str(v)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_sound_assets(args):
|
||||
folder = args.get("folder") or "/Game"
|
||||
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
classes = ["SoundCue", "SoundWave", "MetaSoundSource", "MetaSoundPatch",
|
||||
"SoundAttenuation", "SoundClass", "SoundMix", "SoundConcurrency"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=classes)
|
||||
items = [{"path": str(d.package_name), "class": str(d.asset_class)} for d in ar.get_assets(filt)]
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
|
||||
|
||||
def play_sound_2d(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
unreal.GameplayStatics.play_sound2d(world, a)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_metasound_source": create_metasound_source,
|
||||
"create_metasound_patch": create_metasound_patch,
|
||||
"create_sound_cue": create_sound_cue,
|
||||
"create_sound_attenuation": create_sound_attenuation,
|
||||
"create_sound_class": create_sound_class,
|
||||
"create_sound_mix": create_sound_mix,
|
||||
"set_property": set_property,
|
||||
"get_property": get_property,
|
||||
"list_sound_assets": list_sound_assets,
|
||||
"play_sound_2d": play_sound_2d,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-AUDIO] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
213
ue_side/capture.py
Normal file
213
ue_side/capture.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""Headless capture ops — SEE editor content without opening any tab/window.
|
||||
|
||||
Backed by the C++ helper unreal.MCPCaptureLibrary (Live-Coding compile the
|
||||
plugin if it's missing). Every op writes a PNG under Saved/<out_dir>/ and
|
||||
returns its absolute path + file:// URL. The JS layer (capture_op) reads that
|
||||
PNG back and attaches it INLINE so the agent actually sees the image.
|
||||
|
||||
Ops:
|
||||
asset {asset_path, size?:[512,512], out_dir?:'MCP/capture'}
|
||||
Render ANY asset's thumbnail headlessly (mesh, material, texture,
|
||||
blueprint, niagara, anim, sound, data asset, ...). No editor open.
|
||||
thumbnail alias for asset.
|
||||
material {material_path, size?} — alias for asset, reads nicer in intent.
|
||||
mesh {mesh_path, size?} — alias for asset.
|
||||
scene {location?:[x,y,z], rotation?:[pitch,yaw,roll], fov?:90,
|
||||
size?:[1280,720], out_dir?} — render the editor world from an
|
||||
arbitrary camera via an offscreen SceneCapture2D. No viewport.
|
||||
scene_from_actor {actor, distance?:300, pitch?:-20, yaw?:0, fov?:75, size?}
|
||||
— frame an existing level actor and capture it.
|
||||
list_assets {dir} — quick content-browser listing to find paths to capture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _cap_lib():
|
||||
L = getattr(unreal, "MCPCaptureLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError(
|
||||
"MCPCaptureLibrary not exposed — Live-Coding compile the C++ plugin "
|
||||
"(live_coding_op compile), then retry."
|
||||
)
|
||||
return L
|
||||
|
||||
|
||||
def _out_dir(args) -> tuple[str, str]:
|
||||
"""Returns (abs_dir_with_os_sep, abs_dir_forward_slash)."""
|
||||
proj_saved = unreal.Paths.project_saved_dir()
|
||||
sub = (args.get("out_dir") or "MCP/capture").strip("/")
|
||||
fwd = proj_saved + sub + "/"
|
||||
os.makedirs(fwd, exist_ok=True)
|
||||
return fwd.rstrip("/").replace("/", os.sep), fwd
|
||||
|
||||
|
||||
def _safe_name(path: str) -> str:
|
||||
return path.rsplit("/", 1)[-1].split(".")[0] or "capture"
|
||||
|
||||
|
||||
def _ok_png(full_path: str, strategy: str, size) -> dict:
|
||||
norm = full_path.replace("\\", "/")
|
||||
return {
|
||||
"ok": True,
|
||||
"strategy": strategy,
|
||||
"png_path": full_path,
|
||||
"file_url": "file:///" + norm,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
def _vec(v, d=(0.0, 0.0, 0.0)):
|
||||
if v is None:
|
||||
v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, d=(0.0, 0.0, 0.0)):
|
||||
if v is None:
|
||||
v = d
|
||||
# UE Rotator(pitch, yaw, roll); we accept [pitch, yaw, roll].
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def asset(args: dict) -> dict:
|
||||
path = args.get("asset_path") or args.get("material_path") or args.get("mesh_path") or args.get("path")
|
||||
if not path:
|
||||
return {"ok": False, "error": "asset_path required"}
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
return {"ok": False, "error": f"asset does not exist: {path}"}
|
||||
size = args.get("size") or [512, 512]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
name = _safe_name(path)
|
||||
_ret = _cap_lib().render_asset_thumbnail_to_png(path, os_dir, name, w, h)
|
||||
if isinstance(_ret, (tuple, list)):
|
||||
if len(_ret) >= 3:
|
||||
ok, full, err = _ret[0], _ret[1], _ret[2]
|
||||
elif len(_ret) == 2:
|
||||
full, err = _ret[0], _ret[1]
|
||||
ok = bool(full)
|
||||
else:
|
||||
ok, full, err = bool(_ret[0]), (_ret[0] if isinstance(_ret[0], str) else ""), ""
|
||||
else:
|
||||
full = _ret
|
||||
ok = bool(full)
|
||||
err = ""
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "thumbnail render failed", "asset_path": path}
|
||||
res = _ok_png(full, "AssetThumbnail", [w, h])
|
||||
res["asset_path"] = path
|
||||
return res
|
||||
|
||||
|
||||
def scene(args: dict) -> dict:
|
||||
size = args.get("size") or [1280, 720]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
name = args.get("name") or "scene"
|
||||
loc = _vec(args.get("location"))
|
||||
rot = _rot(args.get("rotation"))
|
||||
fov = float(args.get("fov", 90.0))
|
||||
ok, full, err = _cap_lib().capture_editor_scene_to_png(loc, rot, fov, os_dir, name, w, h)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "scene capture failed"}
|
||||
res = _ok_png(full, "SceneCapture2D", [w, h])
|
||||
res["camera"] = {"location": [loc.x, loc.y, loc.z],
|
||||
"rotation": [rot.pitch, rot.yaw, rot.roll], "fov": fov}
|
||||
return res
|
||||
|
||||
|
||||
def scene_from_actor(args: dict) -> dict:
|
||||
name = args.get("actor")
|
||||
if not name:
|
||||
return {"ok": False, "error": "actor required"}
|
||||
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
target = None
|
||||
for a in ess.get_all_level_actors():
|
||||
if a and (a.get_actor_label() == name or a.get_name() == name):
|
||||
target = a
|
||||
break
|
||||
if target is None:
|
||||
return {"ok": False, "error": f"actor not found: {name}"}
|
||||
|
||||
origin, extent = target.get_actor_bounds(False)
|
||||
radius = max(extent.x, extent.y, extent.z, 50.0)
|
||||
pitch = float(args.get("pitch", -20.0))
|
||||
yaw = float(args.get("yaw", 0.0))
|
||||
fov = float(args.get("fov", 75.0))
|
||||
dist = float(args.get("distance", radius * 3.0))
|
||||
|
||||
# Place camera 'dist' away along yaw/pitch, looking back at the actor center.
|
||||
look_dir = unreal.MathLibrary.get_forward_vector(unreal.Rotator(pitch, yaw, 0.0))
|
||||
cam_loc = origin - (look_dir * dist)
|
||||
look_rot = unreal.MathLibrary.find_look_at_rotation(cam_loc, origin)
|
||||
|
||||
size = args.get("size") or [1280, 720]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
fname = args.get("name") or f"actor_{_safe_name(name)}"
|
||||
ok, full, err = _cap_lib().capture_editor_scene_to_png(cam_loc, look_rot, fov, os_dir, fname, w, h)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "scene capture failed"}
|
||||
res = _ok_png(full, "SceneCapture2D/actor", [w, h])
|
||||
res["actor"] = name
|
||||
res["camera"] = {"location": [cam_loc.x, cam_loc.y, cam_loc.z], "fov": fov}
|
||||
return res
|
||||
|
||||
|
||||
def list_assets(args: dict) -> dict:
|
||||
folder = args.get("dir") or "/Game"
|
||||
recursive = bool(args.get("recursive", False))
|
||||
paths = unreal.EditorAssetLibrary.list_assets(folder, recursive=recursive, include_folder=False)
|
||||
return {"ok": True, "dir": folder, "count": len(paths), "assets": list(paths)[:500]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"asset": asset,
|
||||
"thumbnail": asset,
|
||||
"material": asset,
|
||||
"mesh": asset,
|
||||
"scene": scene,
|
||||
"scene_from_actor": scene_from_actor,
|
||||
"list_assets": list_assets,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-CAPTURE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
118
ue_side/console.py
Normal file
118
ue_side/console.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Console commands and CVars.
|
||||
|
||||
Ops:
|
||||
run {command} — execute editor console command
|
||||
get_cvar {name}
|
||||
set_cvar {name, value}
|
||||
list_cvars {prefix?} — search registered cvars by prefix (best-effort)
|
||||
stat {name, enable?:true} — toggle stat command (`stat fps`, `stat unit`, ...)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _world():
|
||||
return unreal.EditorLevelLibrary.get_editor_world()
|
||||
|
||||
|
||||
def run(args):
|
||||
cmd = args["command"]
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_cvar(args):
|
||||
name = args["name"]
|
||||
# No direct read API in stable Python; we issue ?-style print and grab from log.
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), name)
|
||||
# Heuristic: also use ConsoleVariablesEditorFunctionLibrary if available
|
||||
cvl = getattr(unreal, "ConsoleVariablesEditorFunctionLibrary", None)
|
||||
if cvl and hasattr(cvl, "get_console_variable_string_value"):
|
||||
return {"ok": True, "name": name, "value": cvl.get_console_variable_string_value(name)}
|
||||
return {"ok": True, "name": name, "note": "value echoed to Output Log (no direct read API)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_cvar(args):
|
||||
name = args["name"]
|
||||
val = args["value"]
|
||||
cmd = f"{name} {val}"
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_cvars(args):
|
||||
prefix = args.get("prefix") or ""
|
||||
cmd = f"DumpConsoleCommands {prefix}".strip()
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "note": "results dumped to log; use read_python_log or grep the project log",
|
||||
"command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def stat(args):
|
||||
name = args["name"]
|
||||
if not name.lower().startswith("stat "):
|
||||
name = "stat " + name
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), name)
|
||||
return {"ok": True, "command": name}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"run": run,
|
||||
"get_cvar": get_cvar,
|
||||
"set_cvar": set_cvar,
|
||||
"list_cvars": list_cvars,
|
||||
"stat": stat,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-CONSOLE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
271
ue_side/cpp_scaffold.py
Normal file
271
ue_side/cpp_scaffold.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""C++ class scaffold — generate .h/.cpp templates under a project module.
|
||||
|
||||
This does NOT compile the code — pair with live_coding.compile or a regular
|
||||
hot-reload after the files appear. UE will pick up new files on next build.
|
||||
|
||||
Ops:
|
||||
list_modules {} — read Source/*/Build.cs
|
||||
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
|
||||
list_classes {module?, subfolder?} — list .h files under a module
|
||||
read_class {name, module?} — return .h+.cpp text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _project_dir():
|
||||
if unreal is not None:
|
||||
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _source_dir():
|
||||
return os.path.join(_project_dir(), "Source")
|
||||
|
||||
|
||||
def _project_name():
|
||||
"""Project name = the .uproject base name (also the conventional primary module)."""
|
||||
if unreal is not None:
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
|
||||
return os.path.splitext(os.path.basename(up))[0]
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.basename(_project_dir().rstrip("/\\"))
|
||||
|
||||
|
||||
def _default_module():
|
||||
"""Primary game module to scaffold into. Prefer the Runtime module declared in
|
||||
the .uproject; fall back to the module whose folder matches the project name;
|
||||
else the first Source module; else the project name."""
|
||||
proj = _project_name()
|
||||
# 1. Read the .uproject Modules list for a Runtime/Game module.
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() if unreal is not None else None
|
||||
if up and os.path.isfile(up):
|
||||
with open(up, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
mods = data.get("Modules") or []
|
||||
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
|
||||
if runtime:
|
||||
return runtime[0].get("Name") or proj
|
||||
if mods:
|
||||
return mods[0].get("Name") or proj
|
||||
except Exception:
|
||||
pass
|
||||
# 2. Folder matching the project name.
|
||||
src = _source_dir()
|
||||
if os.path.isdir(os.path.join(src, proj)):
|
||||
return proj
|
||||
# 3. First Source module with a Build.cs.
|
||||
try:
|
||||
for name in sorted(os.listdir(src)):
|
||||
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return proj
|
||||
|
||||
|
||||
def list_modules(args):
|
||||
out = []
|
||||
src = _source_dir()
|
||||
if not os.path.isdir(src):
|
||||
return {"ok": False, "error": f"no Source dir at {src}"}
|
||||
for name in os.listdir(src):
|
||||
p = os.path.join(src, name)
|
||||
if os.path.isdir(p):
|
||||
build_cs = os.path.join(p, f"{name}.Build.cs")
|
||||
if os.path.isfile(build_cs):
|
||||
out.append({"name": name, "build_cs": build_cs,
|
||||
"has_public": os.path.isdir(os.path.join(p, "Public")),
|
||||
"has_private": os.path.isdir(os.path.join(p, "Private"))})
|
||||
return {"ok": True, "modules": out}
|
||||
|
||||
|
||||
def _api_macro(module):
|
||||
return f"{module.upper()}_API"
|
||||
|
||||
|
||||
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "{base_header}"
|
||||
#include "{name}.generated.h"
|
||||
|
||||
UCLASS()
|
||||
class {api} {name} : public {parent}
|
||||
{{
|
||||
\tGENERATED_BODY()
|
||||
|
||||
public:
|
||||
\t{name}();
|
||||
}};
|
||||
"""
|
||||
|
||||
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#include "{include_path}{name}.h"
|
||||
|
||||
{name}::{name}()
|
||||
{{
|
||||
\tPrimaryActorTick.bCanEverTick = false;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# Map of common parents -> include they need
|
||||
_PARENT_HEADERS = {
|
||||
"AActor": "GameFramework/Actor.h",
|
||||
"APawn": "GameFramework/Pawn.h",
|
||||
"ACharacter": "GameFramework/Character.h",
|
||||
"APlayerController": "GameFramework/PlayerController.h",
|
||||
"AGameModeBase": "GameFramework/GameModeBase.h",
|
||||
"AAIController": "AIController.h",
|
||||
"UObject": "UObject/NoExportTypes.h",
|
||||
"UActorComponent": "Components/ActorComponent.h",
|
||||
"USceneComponent": "Components/SceneComponent.h",
|
||||
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
|
||||
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
|
||||
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
|
||||
"UAnimInstance": "Animation/AnimInstance.h",
|
||||
"UUserWidget": "Blueprint/UserWidget.h",
|
||||
"UDataAsset": "Engine/DataAsset.h",
|
||||
"UInterface": "UObject/Interface.h",
|
||||
}
|
||||
|
||||
|
||||
def create_class(args):
|
||||
name = args["name"]
|
||||
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
header_only = bool(args.get("header_only"))
|
||||
public = bool(args.get("public", True))
|
||||
|
||||
if not re.match(r"^[AU][A-Z]\w+$", name):
|
||||
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
|
||||
|
||||
src_mod = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(src_mod):
|
||||
return {"ok": False, "error": f"module not found: {module}"}
|
||||
|
||||
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
os.makedirs(pub_dir, exist_ok=True)
|
||||
if not header_only:
|
||||
os.makedirs(priv_dir, exist_ok=True)
|
||||
|
||||
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
|
||||
api = args.get("api_macro") or _api_macro(module)
|
||||
|
||||
h_path = os.path.join(pub_dir, f"{name}.h")
|
||||
c_path = os.path.join(priv_dir, f"{name}.cpp")
|
||||
|
||||
if os.path.exists(h_path):
|
||||
return {"ok": False, "error": f"file exists: {h_path}"}
|
||||
|
||||
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
|
||||
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(h_txt)
|
||||
out = {"ok": True, "header": h_path}
|
||||
|
||||
if not header_only:
|
||||
include_rel = (subfolder + "/") if subfolder else ""
|
||||
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
|
||||
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(c_txt)
|
||||
out["cpp"] = c_path
|
||||
|
||||
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
|
||||
return out
|
||||
|
||||
|
||||
def list_classes(args):
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
mod_root = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(mod_root):
|
||||
return {"ok": False, "error": f"module not found: {mod_root}"}
|
||||
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
|
||||
out = []
|
||||
for dirpath, _, fnames in os.walk(scan_root):
|
||||
for fn in fnames:
|
||||
if fn.endswith(".h"):
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, mod_root).replace("\\", "/")
|
||||
# Skip the module's own header (e.g. <Module>.h)
|
||||
if rel == f"{module}.h":
|
||||
continue
|
||||
out.append(rel)
|
||||
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
|
||||
|
||||
|
||||
def read_class(args):
|
||||
module = args.get("module") or _default_module()
|
||||
name = args["name"]
|
||||
found = {"header": None, "cpp": None}
|
||||
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
|
||||
for fn in fnames:
|
||||
if fn == f"{name}.h":
|
||||
found["header"] = os.path.join(dirpath, fn)
|
||||
elif fn == f"{name}.cpp":
|
||||
found["cpp"] = os.path.join(dirpath, fn)
|
||||
out = {"ok": True}
|
||||
for k, p in found.items():
|
||||
if p and os.path.isfile(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
out[k] = {"path": p, "content": f.read()}
|
||||
except Exception as e:
|
||||
out[k] = {"path": p, "error": str(e)}
|
||||
if not (found["header"] or found["cpp"]):
|
||||
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
|
||||
return out
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_modules": list_modules,
|
||||
"create_class": create_class,
|
||||
"list_classes": list_classes,
|
||||
"read_class": read_class,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-CPP] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
227
ue_side/cull.py
Normal file
227
ue_side/cull.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""Cull-distance ops — batch set/inspect Desired Max Draw Distance on ISM/HISM/StaticMesh components.
|
||||
|
||||
Encapsulates the gotchas learned the hard way:
|
||||
* CachedMaxDrawDistance is READ-ONLY -> we only write LDMaxDrawDistance (+ SetCullDistance on live instances).
|
||||
* SubobjectDataHandle must be unwrapped via k2_find_subobject_data_from_handle (UE 5.7).
|
||||
* target='asset_template' edits the Blueprint asset (default for ALL instances, survives reconstruct);
|
||||
target='level_instance' edits placed actors only (fast, but lost on BP recompile / PLA repack).
|
||||
* only_zero keeps it idempotent and never clobbers hand-tuned non-zero distances.
|
||||
|
||||
Ops:
|
||||
audit {scope?, asset_paths?, component_types?, target?} — read-only report, no mutation
|
||||
apply {distance, scope?, asset_paths?, component_types?, only_zero?, target?, save?:true}
|
||||
verify {...same as audit} — alias of audit (post-apply check)
|
||||
|
||||
scope: 'level' (default) | 'assets' | 'selection'
|
||||
target: 'asset_template' (default) | 'level_instance'
|
||||
component_types: subset of ['ISM','HISM','StaticMesh','Primitive'] (default ['ISM'])
|
||||
distance: cm; 0 disables culling
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _eas(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
def _sub(): return unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
|
||||
|
||||
|
||||
def _type_classes(args):
|
||||
types = args.get("component_types") or ["ISM"]
|
||||
mapping = {
|
||||
"ISM": unreal.InstancedStaticMeshComponent,
|
||||
"HISM": unreal.HierarchicalInstancedStaticMeshComponent,
|
||||
"StaticMesh": unreal.StaticMeshComponent,
|
||||
"Primitive": unreal.PrimitiveComponent,
|
||||
}
|
||||
out = []
|
||||
for t in types:
|
||||
cls = mapping.get(t)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown component_type {t!r}; allowed: {list(mapping)}")
|
||||
out.append(cls)
|
||||
return out
|
||||
|
||||
|
||||
def _matches(obj, classes):
|
||||
return any(isinstance(obj, c) for c in classes)
|
||||
|
||||
|
||||
def _asset_path_from_actor(actor):
|
||||
gen = actor.get_class().get_path_name()
|
||||
asset = gen.split(".")[0]
|
||||
return asset[:-2] if asset.endswith("_C") else asset
|
||||
|
||||
|
||||
# ---- target='asset_template' : edit Blueprint component templates -----------
|
||||
|
||||
def _template_components(bp, classes):
|
||||
"""Returns deduped list of (name, component_template) for matching types."""
|
||||
handles = _sub().k2_gather_subobject_data_for_blueprint(bp)
|
||||
seen, out = set(), []
|
||||
for h in handles:
|
||||
data = _sub().k2_find_subobject_data_from_handle(h)
|
||||
obj = unreal.SubobjectDataBlueprintFunctionLibrary.get_object(data)
|
||||
if obj is None or not _matches(obj, classes):
|
||||
continue
|
||||
nm = obj.get_name()
|
||||
if nm in seen:
|
||||
continue
|
||||
seen.add(nm)
|
||||
out.append((nm, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _collect_assets(args):
|
||||
"""Returns dict {asset_path: blueprint_asset} for the requested scope."""
|
||||
ael = unreal.EditorAssetLibrary
|
||||
paths = {}
|
||||
scope = args.get("scope") or "level"
|
||||
if scope == "assets":
|
||||
for p in (args.get("asset_paths") or []):
|
||||
paths[p.split(".")[0]] = None
|
||||
else: # level / selection -> discover from placed actors
|
||||
classes = _type_classes(args)
|
||||
actors = (_eas().get_selected_level_actors() if scope == "selection"
|
||||
else _eas().get_all_level_actors())
|
||||
for a in actors:
|
||||
if any(a.get_components_by_class(c) for c in classes):
|
||||
paths[_asset_path_from_actor(a)] = None
|
||||
result = {}
|
||||
for p in paths:
|
||||
result[p] = ael.load_asset(p)
|
||||
return result
|
||||
|
||||
|
||||
def _run_assets(args, mutate):
|
||||
classes = _type_classes(args)
|
||||
distance = float(args.get("distance", 0.0))
|
||||
only_zero = args.get("only_zero", True)
|
||||
save = args.get("save", True)
|
||||
rows, total_set = [], 0
|
||||
for path, bp in _collect_assets(args).items():
|
||||
if bp is None:
|
||||
rows.append({"asset": path, "error": "load failed"}); continue
|
||||
comps = _template_components(bp, classes)
|
||||
vals, set_n = [], 0
|
||||
for _nm, c in comps:
|
||||
cur = float(c.get_editor_property("ld_max_draw_distance"))
|
||||
if mutate:
|
||||
if not (only_zero and abs(cur) > 0.001):
|
||||
c.set_editor_property("ld_max_draw_distance", distance)
|
||||
set_n += 1; cur = distance
|
||||
vals.append(round(cur, 1))
|
||||
if mutate and set_n:
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
||||
if save:
|
||||
unreal.EditorAssetLibrary.save_asset(path)
|
||||
total_set += 1
|
||||
rows.append({
|
||||
"asset": path, "components": len(comps),
|
||||
"zeros": sum(1 for v in vals if abs(v) < 0.001),
|
||||
"distinct": sorted(set(vals)), "set": set_n,
|
||||
})
|
||||
return {"ok": True, "target": "asset_template",
|
||||
"assets_modified": total_set, "rows": rows}
|
||||
|
||||
|
||||
# ---- target='level_instance' : edit placed actors --------------------------
|
||||
|
||||
def _run_instances(args, mutate):
|
||||
classes = _type_classes(args)
|
||||
distance = float(args.get("distance", 0.0))
|
||||
only_zero = args.get("only_zero", True)
|
||||
scope = args.get("scope") or "level"
|
||||
actors = (_eas().get_selected_level_actors() if scope == "selection"
|
||||
else _eas().get_all_level_actors())
|
||||
rows, changed_actors, changed_comps = [], 0, 0
|
||||
for a in actors:
|
||||
comps, seen = [], set()
|
||||
for c in classes:
|
||||
for comp in a.get_components_by_class(c):
|
||||
if comp.get_name() not in seen:
|
||||
seen.add(comp.get_name()); comps.append(comp)
|
||||
if not comps:
|
||||
continue
|
||||
vals, set_n = [], 0
|
||||
for comp in comps:
|
||||
cur = float(comp.get_editor_property("ld_max_draw_distance"))
|
||||
if mutate and not (only_zero and abs(cur) > 0.001):
|
||||
comp.modify()
|
||||
comp.set_editor_property("ld_max_draw_distance", distance)
|
||||
comp.set_cull_distance(distance) # updates cached + re-registers
|
||||
set_n += 1; cur = distance
|
||||
vals.append(round(cur, 1))
|
||||
if mutate and set_n:
|
||||
a.modify(); changed_actors += 1; changed_comps += set_n
|
||||
rows.append({"actor": a.get_actor_label(), "components": len(comps),
|
||||
"zeros": sum(1 for v in vals if abs(v) < 0.001),
|
||||
"distinct": sorted(set(vals)), "set": set_n})
|
||||
return {"ok": True, "target": "level_instance",
|
||||
"actors_modified": changed_actors, "components_set": changed_comps,
|
||||
"rows": rows, "note": "instance edits are lost on BP recompile / PLA repack"}
|
||||
|
||||
|
||||
# ---- op table --------------------------------------------------------------
|
||||
|
||||
def _dispatch(args, mutate):
|
||||
target = args.get("target") or "asset_template"
|
||||
if target == "asset_template":
|
||||
return _run_assets(args, mutate)
|
||||
if target == "level_instance":
|
||||
return _run_instances(args, mutate)
|
||||
return {"ok": False, "error": f"unknown target {target!r}; use 'asset_template' or 'level_instance'"}
|
||||
|
||||
|
||||
def audit(args):
|
||||
return _dispatch(args, mutate=False)
|
||||
|
||||
|
||||
def verify(args):
|
||||
return _dispatch(args, mutate=False)
|
||||
|
||||
|
||||
def apply(args):
|
||||
if "distance" not in args:
|
||||
return {"ok": False, "error": "apply requires 'distance' (cm)"}
|
||||
return _dispatch(args, mutate=True)
|
||||
|
||||
|
||||
OPS = {"audit": audit, "verify": verify, "apply": apply}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-CULL] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
153
ue_side/discover.py
Normal file
153
ue_side/discover.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Read-only discovery ops via MCPGraphLibrary v0.3.
|
||||
|
||||
Ops:
|
||||
list_open_bps {} — open BP editors
|
||||
list_graphs {bp_path} — all graphs of a BP
|
||||
describe_bp {bp_path} — parent/interfaces/vars/components/funcs/dispatchers
|
||||
get_selection {bp_path} — selected nodes in BP editor
|
||||
resolve_call {bp_path, function, guid} — CallFunction node → source bp/class
|
||||
find_var_refs {bp_path, name} — variable usage sites
|
||||
find_fn_refs {bp_path, name} — function call sites in this BP
|
||||
find_bp_by_parent {parent_class_path} — AssetRegistry search
|
||||
get_refs_of {asset_path} — assets referenced by this asset
|
||||
get_referencers {asset_path} — assets that reference this asset
|
||||
read_function {bp_path, function} — wrapper over apply_graph.read_graph for function
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _find_graph(bp, name: str):
|
||||
L = _lib()
|
||||
if name in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, name)
|
||||
|
||||
|
||||
def list_open_bps(args: dict) -> dict:
|
||||
return {"ok": True, "open": list(_lib().list_open_blueprints())}
|
||||
|
||||
|
||||
def list_graphs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().list_graphs_json(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
|
||||
|
||||
|
||||
def describe_bp(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().describe_blueprint_json(bp)
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def get_selection(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().get_selected_nodes_json(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
|
||||
|
||||
|
||||
def resolve_call(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _find_graph(bp, args.get("function") or "EventGraph")
|
||||
if g is None:
|
||||
return {"ok": False, "error": "graph not found"}
|
||||
raw = _lib().resolve_function_source_json(g, args["guid"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_var_refs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().find_variable_references_json(bp, args["name"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_fn_refs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().find_function_references_json(bp, args["name"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_bp_by_parent(args: dict) -> dict:
|
||||
found = list(_lib().find_blueprints_by_parent(args["parent_class_path"]))
|
||||
return {"ok": True, "blueprints": found, "count": len(found)}
|
||||
|
||||
|
||||
def get_refs_of(args: dict) -> dict:
|
||||
return {"ok": True, "references": list(_lib().get_referenced_assets(args["asset_path"]))}
|
||||
|
||||
|
||||
def get_referencers(args: dict) -> dict:
|
||||
return {"ok": True, "referencers": list(_lib().get_referencers_of_asset(args["asset_path"]))}
|
||||
|
||||
|
||||
def read_function(args: dict) -> dict:
|
||||
# Convenience: read_graph for a named function
|
||||
import importlib, apply_graph
|
||||
importlib.reload(apply_graph)
|
||||
return apply_graph.read_graph(args["bp_path"], args.get("function") or "EventGraph")
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_open_bps": list_open_bps,
|
||||
"list_graphs": list_graphs,
|
||||
"describe_bp": describe_bp,
|
||||
"get_selection": get_selection,
|
||||
"resolve_call": resolve_call,
|
||||
"find_var_refs": find_var_refs,
|
||||
"find_fn_refs": find_fn_refs,
|
||||
"find_bp_by_parent": find_bp_by_parent,
|
||||
"get_refs_of": get_refs_of,
|
||||
"get_referencers": get_referencers,
|
||||
"read_function": read_function,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, p={})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-DISCOVER] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
186
ue_side/edit_ops.py
Normal file
186
ue_side/edit_ops.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""Single-node / single-edge edit ops + navigation.
|
||||
|
||||
Ops:
|
||||
delete_node {bp_path, function?, guid}
|
||||
break_link {bp_path, function?, from:[guid,pin], to:[guid,pin]}
|
||||
break_all {bp_path, function?, guid}
|
||||
move_nodes {bp_path, function?, moves:[{guid,x,y}]}
|
||||
resize_comment {bp_path, function?, guid, width, height}
|
||||
set_comment {bp_path, function?, guid, text, bubble?}
|
||||
open_bp {bp_path}
|
||||
open_graph {bp_path, function}
|
||||
jump_to_node {bp_path, function?, guid}
|
||||
add_case_pin {bp_path, function?, guid} — switch ops
|
||||
format_text {bp_path, function?, guid, format}
|
||||
reconstruct {bp_path, function?, guid}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _graph(bp, fn: str | None):
|
||||
L = _lib()
|
||||
if fn in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, fn)
|
||||
|
||||
|
||||
def _compile_save(bp):
|
||||
_lib().compile_and_save_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().delete_node(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "guid": args["guid"]}
|
||||
|
||||
|
||||
def break_link(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
(fg, fp) = args["from"]; (tg, tp) = args["to"]
|
||||
ok = bool(_lib().break_link(g, fg, fp, tg, tp))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def break_all(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().break_all_node_links(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
moves = args.get("moves") or []
|
||||
ids = [m["guid"] for m in moves]
|
||||
pos = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
|
||||
n = int(_lib().move_nodes(g, ids, pos))
|
||||
return {"ok": n > 0, "moved": n}
|
||||
|
||||
|
||||
def resize_comment(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().resize_comment_node(g, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_comment(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().set_node_comment(g, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def open_bp(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
return {"ok": bool(_lib().open_blueprint_editor(bp))}
|
||||
|
||||
|
||||
def open_graph(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
return {"ok": bool(_lib().open_graph_in_editor(bp, g))}
|
||||
|
||||
|
||||
def jump_to_node(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
return {"ok": bool(_lib().jump_to_node_in_editor(bp, g, args["guid"]))}
|
||||
|
||||
|
||||
def add_case_pin(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().add_case_pin_to_switch(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def format_text(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().format_text_set_format(g, args["guid"], args["format"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def reconstruct(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().reconstruct_node(g, args["guid"]))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
OPS = {
|
||||
"delete_node": delete_node,
|
||||
"break_link": break_link,
|
||||
"break_all": break_all,
|
||||
"move_nodes": move_nodes,
|
||||
"resize_comment": resize_comment,
|
||||
"set_comment": set_comment,
|
||||
"open_bp": open_bp,
|
||||
"open_graph": open_graph,
|
||||
"jump_to_node": jump_to_node,
|
||||
"add_case_pin": add_case_pin,
|
||||
"format_text": format_text,
|
||||
"reconstruct": reconstruct,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-EDIT] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
192
ue_side/graph_layout.py
Normal file
192
ue_side/graph_layout.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""Small deterministic graph layout helpers shared by MCP graph tools.
|
||||
|
||||
The goal is not to replace Unreal's graph layout. It is a guard rail for
|
||||
generated graphs: keep columns readable and prevent heavy nodes from being
|
||||
spawned on top of each other.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
DEFAULT_NODE_W = 260
|
||||
DEFAULT_NODE_H = 150
|
||||
DEFAULT_GAP_X = 180
|
||||
DEFAULT_GAP_Y = 80
|
||||
|
||||
|
||||
def estimate_size(spec: dict, domain: str = "blueprint") -> tuple[int, int]:
|
||||
if domain == "pcg":
|
||||
cls = str(spec.get("class") or spec.get("type") or "").lower()
|
||||
# I/O nodes and reroutes are slim; spawners/samplers carry big detail panels.
|
||||
if cls in ("input", "output"):
|
||||
return 220, 120
|
||||
if "spawner" in cls or "sampler" in cls or "subgraph" in cls:
|
||||
return 340, 240
|
||||
if "filter" in cls or "transform" in cls or "createpoints" in cls:
|
||||
return 320, 200
|
||||
return 300, 170
|
||||
|
||||
if domain == "material":
|
||||
cls = str(spec.get("class") or spec.get("target") or "").lower()
|
||||
if "texturesample" in cls:
|
||||
return 310, 360
|
||||
if "vectorparameter" in cls or "constant3vector" in cls or "constant4vector" in cls:
|
||||
return 300, 300
|
||||
if "scalarparameter" in cls:
|
||||
return 260, 150
|
||||
if "panner" in cls:
|
||||
return 280, 190
|
||||
if "linearinterpolate" in cls or "multiply" in cls or "add" in cls:
|
||||
return 260, 170
|
||||
if "texturecoordinate" in cls:
|
||||
return 260, 130
|
||||
return 280, 180
|
||||
|
||||
kind = str(spec.get("kind") or "").lower()
|
||||
params = spec.get("params") or {}
|
||||
if kind == "comment":
|
||||
return int(params.get("width", 400)), int(params.get("height", 220))
|
||||
if kind in ("event", "custom_event", "branch", "sequence", "operator", "knot"):
|
||||
return 240, 120
|
||||
if kind in ("call_function", "spawn_actor", "format_text"):
|
||||
return 340, 190
|
||||
if kind in ("make_struct", "break_struct", "set_fields_in_struct"):
|
||||
return 360, 260
|
||||
if kind in ("variable_get", "variable_set", "self"):
|
||||
return 260, 130
|
||||
return DEFAULT_NODE_W, DEFAULT_NODE_H
|
||||
|
||||
|
||||
def normalize_specs(nodes: list[dict], edges: list[dict] | None = None, domain: str = "blueprint",
|
||||
gap_x: int = DEFAULT_GAP_X, gap_y: int = DEFAULT_GAP_Y,
|
||||
snap_x: int = 360) -> list[dict]:
|
||||
"""Return copied nodes with collision-resistant positions.
|
||||
|
||||
The algorithm preserves the user's left-to-right intent from positions, but
|
||||
snaps nearby x values into columns and stacks each column by estimated node
|
||||
height. This keeps generated graphs stable and readable.
|
||||
"""
|
||||
copied = [dict(n) for n in (nodes or [])]
|
||||
if len(copied) < 2:
|
||||
return copied
|
||||
|
||||
with_meta = []
|
||||
for index, node in enumerate(copied):
|
||||
pos = node.get("position") or [0, 0]
|
||||
x = float(pos[0]) if len(pos) > 0 else 0.0
|
||||
y = float(pos[1]) if len(pos) > 1 else 0.0
|
||||
w, h = estimate_size(node, domain)
|
||||
with_meta.append({"index": index, "node": node, "x": x, "y": y, "w": w, "h": h})
|
||||
|
||||
sorted_by_x = sorted(with_meta, key=lambda item: (item["x"], item["y"], item["index"]))
|
||||
columns: list[list[dict]] = []
|
||||
for item in sorted_by_x:
|
||||
if not columns:
|
||||
columns.append([item])
|
||||
continue
|
||||
avg_x = sum(i["x"] for i in columns[-1]) / len(columns[-1])
|
||||
if abs(item["x"] - avg_x) <= max(120, snap_x * 0.45):
|
||||
columns[-1].append(item)
|
||||
else:
|
||||
columns.append([item])
|
||||
|
||||
min_x = min(item["x"] for item in with_meta)
|
||||
for column_index, column in enumerate(columns):
|
||||
column_x = min_x + column_index * (max(item["w"] for item in column) + gap_x)
|
||||
column.sort(key=lambda item: (item["y"], item["index"]))
|
||||
cursor_y = min(item["y"] for item in column)
|
||||
for item in column:
|
||||
desired_y = item["y"]
|
||||
y = max(desired_y, cursor_y)
|
||||
item["node"]["position"] = [round(column_x), round(y)]
|
||||
cursor_y = y + item["h"] + gap_y
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def tidy_existing_nodes(nodes: list[dict], edges: list[dict] | None = None, domain: str = "material") -> list[dict]:
|
||||
"""Return [{guid/name,x,y}] for existing graph dump nodes."""
|
||||
if edges:
|
||||
return _tidy_existing_by_edges(nodes, edges, domain)
|
||||
|
||||
specs = []
|
||||
for node in nodes or []:
|
||||
cls = node.get("class") or ""
|
||||
x = node.get("x", (node.get("position") or [0, 0])[0])
|
||||
y = node.get("y", (node.get("position") or [0, 0])[1])
|
||||
specs.append({
|
||||
"id": node.get("guid") or node.get("name"),
|
||||
"class": cls,
|
||||
"position": [float(x), float(y)],
|
||||
})
|
||||
normalized = normalize_specs(specs, domain=domain)
|
||||
moves = []
|
||||
for spec in normalized:
|
||||
x, y = spec.get("position") or [0, 0]
|
||||
moves.append({"guid": spec.get("id"), "x": x, "y": y})
|
||||
return moves
|
||||
|
||||
|
||||
def _tidy_existing_by_edges(nodes: list[dict], edges: list[dict], domain: str) -> list[dict]:
|
||||
by_id = {}
|
||||
for node in nodes or []:
|
||||
node_id = node.get("guid") or node.get("name")
|
||||
if node_id:
|
||||
by_id[node_id] = node
|
||||
if not by_id:
|
||||
return []
|
||||
|
||||
incoming = {node_id: set() for node_id in by_id}
|
||||
outgoing = {node_id: set() for node_id in by_id}
|
||||
for edge in edges or []:
|
||||
try:
|
||||
src = edge["from"][0]
|
||||
dst = edge["to"][0]
|
||||
except Exception:
|
||||
continue
|
||||
if src in by_id and dst in by_id:
|
||||
outgoing[src].add(dst)
|
||||
incoming[dst].add(src)
|
||||
|
||||
depths = {node_id: 0 for node_id in by_id}
|
||||
changed = True
|
||||
guard = 0
|
||||
while changed and guard < max(4, len(by_id) * 3):
|
||||
changed = False
|
||||
guard += 1
|
||||
for src, dsts in outgoing.items():
|
||||
for dst in dsts:
|
||||
candidate = depths[src] + 1
|
||||
if candidate > depths[dst]:
|
||||
depths[dst] = candidate
|
||||
changed = True
|
||||
|
||||
columns: dict[int, list[dict]] = {}
|
||||
for node_id, node in by_id.items():
|
||||
columns.setdefault(depths.get(node_id, 0), []).append(node)
|
||||
|
||||
all_x = []
|
||||
all_y = []
|
||||
for node in by_id.values():
|
||||
pos = node.get("position")
|
||||
all_x.append(float(node.get("x", pos[0] if pos else 0)))
|
||||
all_y.append(float(node.get("y", pos[1] if pos else 0)))
|
||||
min_x = min(all_x) if all_x else 0
|
||||
min_y = min(all_y) if all_y else 0
|
||||
|
||||
moves = []
|
||||
sorted_depths = sorted(columns)
|
||||
col_x = min_x
|
||||
for depth in sorted_depths:
|
||||
column = columns[depth]
|
||||
column.sort(key=lambda node: float(node.get("y", (node.get("position") or [0, 0])[1])))
|
||||
max_w = 0
|
||||
cursor_y = min_y
|
||||
for node in column:
|
||||
spec = {"class": node.get("class"), "kind": node.get("kind")}
|
||||
w, h = estimate_size(spec, domain)
|
||||
max_w = max(max_w, w)
|
||||
node_id = node.get("guid") or node.get("name")
|
||||
moves.append({"guid": node_id, "x": round(col_x), "y": round(cursor_y)})
|
||||
cursor_y += h + DEFAULT_GAP_Y
|
||||
col_x += max_w + DEFAULT_GAP_X
|
||||
return moves
|
||||
187
ue_side/input.py
Normal file
187
ue_side/input.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Enhanced Input ops — Input Actions, Input Mapping Contexts.
|
||||
|
||||
Ops:
|
||||
create_input_action {path, value_type?:'Bool|Axis1D|Axis2D|Axis3D'}
|
||||
create_input_mapping_context {path}
|
||||
add_mapping {imc_path, action_path, key, modifiers?:[class_path], triggers?:[class_path]}
|
||||
remove_mapping {imc_path, index}
|
||||
list_mappings {imc_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
VALUE_TYPES = {
|
||||
"Bool": "Boolean",
|
||||
"Axis1D": "Axis1D",
|
||||
"Axis2D": "Axis2D",
|
||||
"Axis3D": "Axis3D",
|
||||
}
|
||||
|
||||
|
||||
def create_input_action(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "InputActionFactory", None)
|
||||
asset_cls = getattr(unreal, "InputAction", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
vt = args.get("value_type") or "Bool"
|
||||
enum_name = VALUE_TYPES.get(vt)
|
||||
if enum_name:
|
||||
try:
|
||||
enum_obj = getattr(unreal.InputActionValueType, enum_name)
|
||||
asset.set_editor_property("value_type", enum_obj)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_input_mapping_context(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "InputMappingContextFactory", None)
|
||||
asset_cls = getattr(unreal, "InputMappingContext", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def add_mapping(args):
|
||||
imc = _load(args["imc_path"])
|
||||
ia = _load(args["action_path"])
|
||||
if imc is None or ia is None:
|
||||
return {"ok": False, "error": "imc or action not found"}
|
||||
key = unreal.InputCoreLibrary.input_key_from_string(args["key"]) \
|
||||
if hasattr(unreal, "InputCoreLibrary") and hasattr(unreal.InputCoreLibrary, "input_key_from_string") \
|
||||
else unreal.Key(args["key"])
|
||||
mapping_cls = getattr(unreal, "EnhancedActionKeyMapping", None)
|
||||
if mapping_cls is None:
|
||||
try:
|
||||
imc.map_key(ia, key)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True, "note": "added via map_key()"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"map_key failed: {e}"}
|
||||
m = mapping_cls()
|
||||
m.set_editor_property("action", ia)
|
||||
m.set_editor_property("key", key)
|
||||
# Modifiers/triggers (class CDOs):
|
||||
for cp in (args.get("modifiers") or []):
|
||||
mc = unreal.load_class(None, cp)
|
||||
if mc is not None:
|
||||
try:
|
||||
inst = unreal.new_object(mc)
|
||||
mods = list(m.get_editor_property("modifiers") or [])
|
||||
mods.append(inst)
|
||||
m.set_editor_property("modifiers", mods)
|
||||
except Exception:
|
||||
pass
|
||||
for cp in (args.get("triggers") or []):
|
||||
tc = unreal.load_class(None, cp)
|
||||
if tc is not None:
|
||||
try:
|
||||
inst = unreal.new_object(tc)
|
||||
trigs = list(m.get_editor_property("triggers") or [])
|
||||
trigs.append(inst)
|
||||
m.set_editor_property("triggers", trigs)
|
||||
except Exception:
|
||||
pass
|
||||
mappings = list(imc.get_editor_property("mappings") or [])
|
||||
mappings.append(m)
|
||||
imc.set_editor_property("mappings", mappings)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True, "index": len(mappings) - 1}
|
||||
|
||||
|
||||
def remove_mapping(args):
|
||||
imc = _load(args["imc_path"])
|
||||
if imc is None:
|
||||
return {"ok": False, "error": "imc not found"}
|
||||
mappings = list(imc.get_editor_property("mappings") or [])
|
||||
idx = int(args["index"])
|
||||
if idx < 0 or idx >= len(mappings):
|
||||
return {"ok": False, "error": "index out of range"}
|
||||
mappings.pop(idx)
|
||||
imc.set_editor_property("mappings", mappings)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_mappings(args):
|
||||
imc = _load(args["imc_path"])
|
||||
if imc is None:
|
||||
return {"ok": False, "error": "imc not found"}
|
||||
out = []
|
||||
for i, m in enumerate(imc.get_editor_property("mappings") or []):
|
||||
try:
|
||||
a = m.get_editor_property("action")
|
||||
k = m.get_editor_property("key")
|
||||
out.append({
|
||||
"index": i,
|
||||
"action": a.get_path_name().split(".")[0] if a else None,
|
||||
"key": str(k),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "mappings": out}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_input_action": create_input_action,
|
||||
"create_input_mapping_context": create_input_mapping_context,
|
||||
"add_mapping": add_mapping,
|
||||
"remove_mapping": remove_mapping,
|
||||
"list_mappings": list_mappings,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-INPUT] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
202
ue_side/level.py
Normal file
202
ue_side/level.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""Level / scene ops via UE EditorActorSubsystem — pure Python, no C++ needed.
|
||||
|
||||
Ops (dispatched by `entrypoint(payload_json)`):
|
||||
spawn_actor {class, location:[x,y,z]?, rotation:[p,y,r]?, scale:[x,y,z]?, name?}
|
||||
destroy {name} — by actor label or path
|
||||
get_selected {} — list selected actors
|
||||
select {names:[]}
|
||||
get_by_class {class}
|
||||
find_by_name {name}
|
||||
set_transform {name, location?, rotation?, scale?}
|
||||
attach {child, parent, socket?}
|
||||
save_level {}
|
||||
load_level {path}
|
||||
current_level {}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ess():
|
||||
return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
|
||||
|
||||
def _load_class(p: str):
|
||||
if "/" in p:
|
||||
return unreal.load_class(None, p)
|
||||
for prefix in ("/Script/Engine.", "/Script/UnrealEd."):
|
||||
c = unreal.load_class(None, prefix + p)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _vec(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
# rotator: pitch, yaw, roll
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _find_actor(name: str):
|
||||
for a in _ess().get_all_level_actors():
|
||||
if not a:
|
||||
continue
|
||||
if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def spawn_actor(args: dict) -> dict:
|
||||
cls = _load_class(args["class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args['class']!r}"}
|
||||
loc = _vec(args.get("location"))
|
||||
rot = _rot(args.get("rotation"))
|
||||
scale = _vec(args.get("scale"), (1, 1, 1))
|
||||
actor = _ess().spawn_actor_from_class(cls, loc, rot)
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn returned None"}
|
||||
actor.set_actor_scale3d(scale)
|
||||
if args.get("name"):
|
||||
actor.set_actor_label(args["name"])
|
||||
return {"ok": True, "name": actor.get_actor_label(), "path": actor.get_path_name()}
|
||||
|
||||
|
||||
def destroy(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
_ess().destroy_actor(a)
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def get_selected(args: dict) -> dict:
|
||||
return {"ok": True, "actors": [a.get_actor_label() for a in _ess().get_selected_level_actors()]}
|
||||
|
||||
|
||||
def select(args: dict) -> dict:
|
||||
names = args.get("names") or []
|
||||
found = [a for a in (_find_actor(n) for n in names) if a]
|
||||
_ess().set_selected_level_actors(found)
|
||||
return {"ok": True, "selected": [a.get_actor_label() for a in found]}
|
||||
|
||||
|
||||
def get_by_class(args: dict) -> dict:
|
||||
cls = _load_class(args["class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args['class']!r}"}
|
||||
found = [a for a in _ess().get_all_level_actors() if isinstance(a, cls)]
|
||||
return {"ok": True, "actors": [a.get_actor_label() for a in found]}
|
||||
|
||||
|
||||
def find_by_name(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "not found"}
|
||||
return {"ok": True, "name": a.get_actor_label(), "path": a.get_path_name(), "class": a.get_class().get_name()}
|
||||
|
||||
|
||||
def set_transform(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
if args.get("location") is not None:
|
||||
a.set_actor_location(_vec(args["location"]), False, False)
|
||||
if args.get("rotation") is not None:
|
||||
a.set_actor_rotation(_rot(args["rotation"]), False)
|
||||
if args.get("scale") is not None:
|
||||
a.set_actor_scale3d(_vec(args["scale"], (1, 1, 1)))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def attach(args: dict) -> dict:
|
||||
child = _find_actor(args["child"])
|
||||
parent = _find_actor(args["parent"])
|
||||
if not child or not parent:
|
||||
return {"ok": False, "error": "child or parent not found"}
|
||||
socket = args.get("socket") or ""
|
||||
child.attach_to_actor(parent, socket, unreal.AttachmentRule.KEEP_WORLD,
|
||||
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def save_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
ok = les.save_current_level()
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def load_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
ok = les.load_level(args["path"])
|
||||
return {"ok": bool(ok), "path": args["path"]}
|
||||
|
||||
|
||||
def current_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
name = les.get_current_level_name() if hasattr(les, "get_current_level_name") else ""
|
||||
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
|
||||
return {"ok": True, "name": name, "world": world.get_path_name() if world else None}
|
||||
|
||||
|
||||
OPS = {
|
||||
"spawn_actor": spawn_actor,
|
||||
"destroy": destroy,
|
||||
"get_selected": get_selected,
|
||||
"select": select,
|
||||
"get_by_class": get_by_class,
|
||||
"find_by_name": find_by_name,
|
||||
"set_transform": set_transform,
|
||||
"attach": attach,
|
||||
"save_level": save_level,
|
||||
"load_level": load_level,
|
||||
"current_level": current_level,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-LEVEL] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
307
ue_side/live_coding.py
Normal file
307
ue_side/live_coding.py
Normal file
@ -0,0 +1,307 @@
|
||||
"""Live Coding control — status, compile trigger, error parsing.
|
||||
|
||||
Why this exists: the AI repeatedly hits "Live Coding is open but won't build /
|
||||
won't recompile". This module wraps ILiveCodingModule via Python where exposed,
|
||||
falls back to console commands otherwise, and scrapes the latest LiveCoding log
|
||||
for compile errors so the AI can iterate without screenshots.
|
||||
|
||||
Ops:
|
||||
status {} — is_enabled, has_started_compile, pending_changes (best-effort)
|
||||
compile {} — kick off a Live Coding patch
|
||||
enable {}
|
||||
disable {}
|
||||
get_errors {tail?:200} — read last LiveCoding.log lines, filter for errors
|
||||
open_lc_console{} — opens the LC window
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _project_log_path():
|
||||
"""The main editor log: Saved/Logs/<ProjectName>.log. Project name is derived
|
||||
from the .uproject so this is correct in any project, not just one."""
|
||||
if unreal is None:
|
||||
return None
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path()
|
||||
name = os.path.splitext(os.path.basename(up))[0]
|
||||
return unreal.Paths.project_saved_dir() + f"Logs/{name}.log"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _world():
|
||||
return unreal.EditorLevelLibrary.get_editor_world()
|
||||
|
||||
|
||||
def _exec(cmd):
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
|
||||
|
||||
def _lc_log_path():
|
||||
"""Search a wide range of locations for LiveCoding.log + LiveCoding-backup-*.log.
|
||||
Returns newest file by mtime."""
|
||||
candidates = []
|
||||
|
||||
def _add_dir(d):
|
||||
if not d or not os.path.isdir(d):
|
||||
return
|
||||
try:
|
||||
for fn in os.listdir(d):
|
||||
if fn.lower().startswith("livecoding") and fn.lower().endswith(".log"):
|
||||
candidates.append(os.path.join(d, fn))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Project Saved/Logs
|
||||
try:
|
||||
_add_dir(unreal.Paths.project_saved_dir() + "Logs")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Engine Saved/Logs (project-relative)
|
||||
try:
|
||||
_add_dir(unreal.Paths.engine_saved_dir() + "Logs")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Engine Programs / LiveCodingConsole — this is where LC actually writes its server log.
|
||||
# unreal.Paths.engine_dir() gives the engine root (relative or absolute).
|
||||
try:
|
||||
engine_root = unreal.Paths.convert_relative_path_to_full(unreal.Paths.engine_dir())
|
||||
_add_dir(os.path.join(engine_root, "Programs", "LiveCodingConsole", "Saved", "Logs"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# %LOCALAPPDATA%\UnrealEngine\<Engine>\Saved\Logs
|
||||
appdata = os.environ.get("LOCALAPPDATA", "")
|
||||
if appdata:
|
||||
ue_root = os.path.join(appdata, "UnrealEngine")
|
||||
if os.path.isdir(ue_root):
|
||||
for sub in os.listdir(ue_root):
|
||||
_add_dir(os.path.join(ue_root, sub, "Saved", "Logs"))
|
||||
|
||||
# %APPDATA%\Unreal Engine\AutomationTool\Logs (sometimes UBT writes here)
|
||||
appdata_roam = os.environ.get("APPDATA", "")
|
||||
if appdata_roam:
|
||||
_add_dir(os.path.join(appdata_roam, "Unreal Engine", "AutomationTool", "Logs"))
|
||||
|
||||
# Programs/UnrealBuildTool/Log.txt — different file but useful for compile errors
|
||||
# We only care about LiveCoding here; UBT log is separate.
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def status(args):
|
||||
out = {"ok": True}
|
||||
try:
|
||||
lcs = getattr(unreal, "LiveCodingSubsystem", None)
|
||||
sub = unreal.get_editor_subsystem(lcs) if lcs else None
|
||||
if sub is not None:
|
||||
for attr in ("is_enabled", "has_started_compile", "is_compiling"):
|
||||
if hasattr(sub, attr):
|
||||
out[attr] = bool(getattr(sub, attr)())
|
||||
else:
|
||||
out["note"] = "LiveCodingSubsystem not exposed in Python; using log scrape + console"
|
||||
except Exception as e:
|
||||
out["query_error"] = str(e)
|
||||
log = _lc_log_path()
|
||||
out["lc_log_path"] = log
|
||||
out["lc_log_exists"] = bool(log)
|
||||
# Always also expose the main project log as the fallback error source.
|
||||
fb = _project_log_path()
|
||||
if fb:
|
||||
out["fallback_log"] = fb
|
||||
return out
|
||||
|
||||
|
||||
def compile(args):
|
||||
try:
|
||||
lcs = getattr(unreal, "LiveCodingSubsystem", None)
|
||||
sub = unreal.get_editor_subsystem(lcs) if lcs else None
|
||||
if sub is not None and hasattr(sub, "compile"):
|
||||
sub.compile()
|
||||
return {"ok": True, "via": "subsystem"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
try:
|
||||
_exec("LiveCoding.Compile")
|
||||
return {"ok": True, "via": "console"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def enable(args):
|
||||
try: _exec("LiveCoding.Enable")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def disable(args):
|
||||
try: _exec("LiveCoding.Disable")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def open_lc_console(args):
|
||||
try: _exec("LiveCoding.Console")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
_ERR_RX = re.compile(r"\b(error\s+[A-Z]\d{3,5}|fatal error|undefined reference|cannot open|LINK\s*:\s*fatal|: error:|: warning:)\b", re.I)
|
||||
_LC_CATEGORY_RX = re.compile(r"Log(LiveCoding|LiveCodingServer|Compile|HotReload|UnrealBuildTool)\s*:", re.I)
|
||||
_LC_COMPILE_START_RX = re.compile(r"(Manual recompile triggered|Creating patch|Compiling|Starting compile)", re.I)
|
||||
_LC_COMPILE_END_RX = re.compile(r"Finished\s*\(([\d.]+)s\)", re.I)
|
||||
_LC_NO_CHANGES_RX = re.compile(r"No changes detected|nothing to compile", re.I)
|
||||
|
||||
|
||||
def get_errors(args):
|
||||
tail = int(args.get("tail") or 200)
|
||||
log = _lc_log_path()
|
||||
sources_used = []
|
||||
|
||||
if log is not None:
|
||||
sources_used.append(log)
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
errs = [l.rstrip() for l in lines if _ERR_RX.search(l)]
|
||||
if errs:
|
||||
return {"ok": True, "source": "LiveCoding.log", "log_path": log,
|
||||
"total_lines": len(lines), "errors_count": len(errs),
|
||||
"errors": errs[-tail:]}
|
||||
except Exception as e:
|
||||
sources_used.append(f"(read error: {e})")
|
||||
|
||||
# Fallback: project log, filtered by LiveCoding/Compile/HotReload/UBT categories
|
||||
fallback = _project_log_path()
|
||||
if fallback and os.path.isfile(fallback):
|
||||
sources_used.append(fallback)
|
||||
try:
|
||||
with open(fallback, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
relevant = [l.rstrip() for l in lines
|
||||
if _LC_CATEGORY_RX.search(l) or _ERR_RX.search(l)]
|
||||
errs = [l for l in relevant if _ERR_RX.search(l) or "Error" in l or "Fatal" in l]
|
||||
return {"ok": True, "source": "project_log_fallback",
|
||||
"log_path": fallback, "total_lines": len(lines),
|
||||
"lc_lines": len(relevant), "errors_count": len(errs),
|
||||
"lc_recent": relevant[-tail:],
|
||||
"errors": errs[-tail:],
|
||||
"sources_searched": sources_used}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"fallback read failed: {e}",
|
||||
"sources_searched": sources_used}
|
||||
|
||||
return {"ok": False, "error": "no LiveCoding.log and no fallback project log",
|
||||
"sources_searched": sources_used}
|
||||
|
||||
|
||||
def last_compile(args):
|
||||
"""Find the most recent compile cycle in LiveCodingConsole.log.
|
||||
|
||||
Returns: status ('success'|'failed'|'in_progress'|'no_recent'),
|
||||
duration_s, timestamp, lines for the cycle, and any errors.
|
||||
"""
|
||||
log = _lc_log_path()
|
||||
if log is None:
|
||||
return {"ok": False, "error": "no LC log found"}
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# Walk backwards: find the last "Creating patch" / "Manual recompile" marker
|
||||
start_idx = -1
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if _LC_COMPILE_START_RX.search(lines[i]):
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx == -1:
|
||||
return {"ok": True, "log_path": log, "status": "no_recent",
|
||||
"note": "no compile cycle found in log"}
|
||||
|
||||
cycle = [l.rstrip() for l in lines[start_idx:]]
|
||||
timestamp_match = re.search(r"\[([\d.\-:]+)\]", cycle[0])
|
||||
timestamp = timestamp_match.group(1) if timestamp_match else None
|
||||
|
||||
end_match = None
|
||||
for l in cycle:
|
||||
m = _LC_COMPILE_END_RX.search(l)
|
||||
if m:
|
||||
end_match = m
|
||||
break
|
||||
|
||||
errors = [l for l in cycle if _ERR_RX.search(l)]
|
||||
if errors:
|
||||
status_str = "failed"
|
||||
elif end_match:
|
||||
status_str = "success"
|
||||
else:
|
||||
status_str = "in_progress"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"log_path": log,
|
||||
"status": status_str,
|
||||
"timestamp": timestamp,
|
||||
"duration_s": float(end_match.group(1)) if end_match else None,
|
||||
"errors_count": len(errors),
|
||||
"errors": errors,
|
||||
"cycle_lines": cycle,
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"status": status,
|
||||
"compile": compile,
|
||||
"enable": enable,
|
||||
"disable": disable,
|
||||
"open_lc_console": open_lc_console,
|
||||
"get_errors": get_errors,
|
||||
"last_compile": last_compile,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-LC] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
776
ue_side/materials.py
Normal file
776
ue_side/materials.py
Normal file
@ -0,0 +1,776 @@
|
||||
"""Material graph and material instance operations for UEBlueprintMCP.
|
||||
|
||||
Ops:
|
||||
create_material{path}
|
||||
set_material_settings{asset_path,properties}
|
||||
inspect_material_settings{asset_path,properties:[...]}
|
||||
create_material_instance{path,parent?}
|
||||
read_graph{asset_path}
|
||||
apply_graph{asset_path,clear_existing?,layout?,save?,nodes:[...],edges:[...],outputs:{...}}
|
||||
add_node{asset_path,id?,class,position?,properties?}
|
||||
delete_node{asset_path,guid|name}
|
||||
move_nodes{asset_path,moves:[{guid|name,x,y}]}
|
||||
set_node_property{asset_path,guid|name,property,value}
|
||||
connect{asset_path,from:[guid|name,output],to:[guid|name,input]}
|
||||
connect_output{asset_path,from:[guid|name,output],property}
|
||||
layout{asset_path}
|
||||
recompile_save{asset_path}
|
||||
set_instance_parent{asset_path,parent}
|
||||
set_instance_scalar{asset_path,name,value}
|
||||
set_instance_vector{asset_path,name,value}
|
||||
set_instance_texture{asset_path,name,value}
|
||||
list_parameters{asset_path}
|
||||
open_asset{asset_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
MATERIAL_PROPERTY_MAP = {
|
||||
"basecolor": "MP_BASE_COLOR",
|
||||
"base_color": "MP_BASE_COLOR",
|
||||
"metallic": "MP_METALLIC",
|
||||
"specular": "MP_SPECULAR",
|
||||
"roughness": "MP_ROUGHNESS",
|
||||
"emissive": "MP_EMISSIVE_COLOR",
|
||||
"emissivecolor": "MP_EMISSIVE_COLOR",
|
||||
"emissive_color": "MP_EMISSIVE_COLOR",
|
||||
"opacity": "MP_OPACITY",
|
||||
"opacitymask": "MP_OPACITY_MASK",
|
||||
"opacity_mask": "MP_OPACITY_MASK",
|
||||
"normal": "MP_NORMAL",
|
||||
"worldpositionoffset": "MP_WORLD_POSITION_OFFSET",
|
||||
"world_position_offset": "MP_WORLD_POSITION_OFFSET",
|
||||
"ambientocclusion": "MP_AMBIENT_OCCLUSION",
|
||||
"ambient_occlusion": "MP_AMBIENT_OCCLUSION",
|
||||
"refraction": "MP_REFRACTION",
|
||||
"tangent": "MP_TANGENT",
|
||||
"displacement": "MP_DISPLACEMENT",
|
||||
"subsurfacecolor": "MP_SUBSURFACE_COLOR",
|
||||
"subsurface_color": "MP_SUBSURFACE_COLOR",
|
||||
"customizeduvs0": "MP_CUSTOMIZED_UV_0",
|
||||
"customizeduv0": "MP_CUSTOMIZED_UV_0",
|
||||
"customizeduvs1": "MP_CUSTOMIZED_UV_1",
|
||||
"customizeduv1": "MP_CUSTOMIZED_UV_1",
|
||||
"customizeduvs2": "MP_CUSTOMIZED_UV_2",
|
||||
"customizeduv2": "MP_CUSTOMIZED_UV_2",
|
||||
"customizeduvs3": "MP_CUSTOMIZED_UV_3",
|
||||
"customizeduv3": "MP_CUSTOMIZED_UV_3",
|
||||
"customizeduvs4": "MP_CUSTOMIZED_UV_4",
|
||||
"customizeduv4": "MP_CUSTOMIZED_UV_4",
|
||||
"customizeduvs5": "MP_CUSTOMIZED_UV_5",
|
||||
"customizeduv5": "MP_CUSTOMIZED_UV_5",
|
||||
"customizeduvs6": "MP_CUSTOMIZED_UV_6",
|
||||
"customizeduv6": "MP_CUSTOMIZED_UV_6",
|
||||
"customizeduvs7": "MP_CUSTOMIZED_UV_7",
|
||||
"customizeduv7": "MP_CUSTOMIZED_UV_7",
|
||||
}
|
||||
|
||||
|
||||
def _asset_tools():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _mel():
|
||||
lib = getattr(unreal, "MaterialEditingLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("unreal.MaterialEditingLibrary is unavailable. Enable the Material Editor scripting support in UE.")
|
||||
return lib
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
if "/" not in path:
|
||||
raise RuntimeError(f"bad asset path {path!r}")
|
||||
return path.rsplit("/", 1)
|
||||
|
||||
|
||||
def _load_asset(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
try:
|
||||
asset = unreal.load_asset(path)
|
||||
except Exception:
|
||||
asset = None
|
||||
if asset is None and "." not in path.rsplit("/", 1)[-1]:
|
||||
try:
|
||||
object_path = path + "." + path.rsplit("/", 1)[-1]
|
||||
asset = unreal.load_asset(object_path)
|
||||
except Exception:
|
||||
asset = None
|
||||
if asset is None:
|
||||
try:
|
||||
for selected_asset in unreal.EditorUtilityLibrary.get_selected_assets():
|
||||
if selected_asset.get_path_name().startswith(path):
|
||||
asset = selected_asset
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if asset is None:
|
||||
raise RuntimeError(f"asset not found: {path}")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.Material):
|
||||
raise RuntimeError(f"{path} is not a Material ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material_function(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.MaterialFunction):
|
||||
raise RuntimeError(f"{path} is not a MaterialFunction ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material_interface(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.MaterialInterface):
|
||||
raise RuntimeError(f"{path} is not a MaterialInterface ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_expression_class(path_or_name: str):
|
||||
if not path_or_name:
|
||||
raise RuntimeError("expression class is required")
|
||||
candidates = [path_or_name]
|
||||
if "/" not in path_or_name:
|
||||
name = path_or_name
|
||||
if not name.startswith("MaterialExpression"):
|
||||
candidates.append("MaterialExpression" + name)
|
||||
candidates.extend([
|
||||
"/Script/Engine." + name,
|
||||
"/Script/Engine.MaterialExpression" + name,
|
||||
])
|
||||
for candidate in candidates:
|
||||
cls = unreal.load_class(None, candidate) if "/" in candidate else None
|
||||
if cls is not None:
|
||||
return cls
|
||||
raise RuntimeError(f"material expression class not found: {path_or_name!r}")
|
||||
|
||||
|
||||
def _material_expressions(asset):
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
return list(lib.get_material_expressions(asset) or []), "MCPMaterialLibrary"
|
||||
except Exception:
|
||||
pass
|
||||
for prop in ("expressions", "function_expressions"):
|
||||
try:
|
||||
expressions = asset.get_editor_property(prop)
|
||||
if expressions is not None:
|
||||
return list(expressions), prop
|
||||
except Exception:
|
||||
pass
|
||||
return [], ""
|
||||
|
||||
|
||||
def _find_expression(asset, ident: str):
|
||||
if not ident:
|
||||
raise RuntimeError("node guid/name is required")
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
expr = lib.find_material_expression(asset, ident)
|
||||
if expr is not None:
|
||||
return expr
|
||||
except Exception:
|
||||
pass
|
||||
expressions, _ = _material_expressions(asset)
|
||||
for expr in expressions:
|
||||
if _expr_guid(expr) == ident or expr.get_name() == ident:
|
||||
return expr
|
||||
raise RuntimeError(f"material expression not found: {ident}")
|
||||
|
||||
|
||||
def _connect_material_expressions(asset, from_expr, from_output: str, to_expr, to_input: str) -> dict:
|
||||
try:
|
||||
ok = _mel().connect_material_expressions(from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
||||
if ok:
|
||||
return {"ok": True, "method": "MaterialEditingLibrary"}
|
||||
except Exception as e:
|
||||
mel_error = str(e)
|
||||
else:
|
||||
mel_error = "MaterialEditingLibrary.connect_material_expressions returned false"
|
||||
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is None or not hasattr(lib, "connect_material_expressions_raw"):
|
||||
return {"ok": False, "error": mel_error}
|
||||
|
||||
try:
|
||||
raw = lib.connect_material_expressions_raw(asset, from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
||||
result = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
||||
if result.get("ok"):
|
||||
result.setdefault("method", "MCPMaterialLibrary.ConnectMaterialExpressionsRaw")
|
||||
return result
|
||||
result.setdefault("error", mel_error)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{mel_error}; raw fallback failed: {e}"}
|
||||
|
||||
|
||||
def _expr_guid(expr) -> str:
|
||||
try:
|
||||
guid = expr.get_editor_property("material_expression_editor_x_guid")
|
||||
if guid:
|
||||
return str(guid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return str(expr.get_outer().get_path_name()) + ":" + expr.get_name()
|
||||
except Exception:
|
||||
return expr.get_name()
|
||||
|
||||
|
||||
def _expr_pos(expr):
|
||||
try:
|
||||
x, y = 0, 0
|
||||
_mel().get_material_expression_node_position(expr, x, y)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return [int(expr.get_editor_property("material_expression_editor_x")),
|
||||
int(expr.get_editor_property("material_expression_editor_y"))]
|
||||
except Exception:
|
||||
return [0, 0]
|
||||
|
||||
|
||||
def _material_property(name: str):
|
||||
enum_name = MATERIAL_PROPERTY_MAP.get(str(name).replace(" ", "").lower(), str(name))
|
||||
try:
|
||||
return getattr(unreal.MaterialProperty, enum_name)
|
||||
except Exception:
|
||||
pass
|
||||
if not str(enum_name).startswith("MP_"):
|
||||
try:
|
||||
return getattr(unreal.MaterialProperty, "MP_" + str(enum_name).upper())
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"unknown material property {name!r}")
|
||||
|
||||
|
||||
def _coerce_value(value, current=None):
|
||||
# The loosely-typed "value" tool field arrives JSON-stringified; unwrap it.
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if current is not None and isinstance(current, bool):
|
||||
if s.lower() in ("true", "1"):
|
||||
return True
|
||||
if s.lower() in ("false", "0"):
|
||||
return False
|
||||
if current is not None and isinstance(current, float):
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
pass
|
||||
if current is not None and isinstance(current, int) and not isinstance(current, bool):
|
||||
try:
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
pass
|
||||
if s[:1] in ("[", "{"):
|
||||
try:
|
||||
import json
|
||||
return _coerce_value(json.loads(s), current)
|
||||
except Exception:
|
||||
pass
|
||||
if s[:1] == "(" and "=" in s:
|
||||
import re
|
||||
low = {k.lower(): float(v) for k, v in re.findall(r"([A-Za-z]+)\s*=\s*(-?[0-9.eE+]+)", s)}
|
||||
if {"r", "g", "b"}.issubset(low):
|
||||
return unreal.LinearColor(low["r"], low["g"], low["b"], low.get("a", 1.0))
|
||||
if {"x", "y", "z"}.issubset(low):
|
||||
return unreal.Vector(low["x"], low["y"], low["z"])
|
||||
if {"x", "y"}.issubset(low):
|
||||
return unreal.Vector2D(low["x"], low["y"])
|
||||
if current is not None:
|
||||
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
||||
if enum_class is not None and hasattr(enum_class, s):
|
||||
return getattr(enum_class, s)
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
if "asset" in value:
|
||||
return _load_asset(value["asset"])
|
||||
low = {str(k).lower(): v for k, v in value.items()}
|
||||
if {"r", "g", "b"}.issubset(low):
|
||||
return unreal.LinearColor(float(low["r"]), float(low["g"]), float(low["b"]), float(low.get("a", 1.0)))
|
||||
if {"x", "y", "z"}.issubset(low):
|
||||
return unreal.Vector(float(low["x"]), float(low["y"]), float(low["z"]))
|
||||
if {"x", "y"}.issubset(low):
|
||||
return unreal.Vector2D(float(low["x"]), float(low["y"]))
|
||||
if isinstance(value, list):
|
||||
if len(value) in (3, 4):
|
||||
if current is not None and type(current).__name__ in ("LinearColor", "Color"):
|
||||
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
||||
if current is not None and type(current).__name__ in ("Vector", "Vector3f"):
|
||||
return unreal.Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
||||
if len(value) == 2:
|
||||
return unreal.Vector2D(float(value[0]), float(value[1]))
|
||||
if isinstance(value, str) and current is not None:
|
||||
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
||||
if enum_class is not None and hasattr(enum_class, value):
|
||||
return getattr(enum_class, value)
|
||||
if isinstance(value, int) and current is not None and hasattr(current, "__class__"):
|
||||
try:
|
||||
return current.__class__(value)
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _set_expr_properties(expr, properties: dict):
|
||||
changed = []
|
||||
for key, value in (properties or {}).items():
|
||||
if key.lower() == "inputs" and isinstance(expr, unreal.MaterialExpressionCustom):
|
||||
custom_inputs = []
|
||||
for item in value:
|
||||
input_name = item.get("name") if isinstance(item, dict) else str(item)
|
||||
custom_input = unreal.CustomInput()
|
||||
custom_input.set_editor_property("input_name", input_name)
|
||||
custom_inputs.append(custom_input)
|
||||
expr.set_editor_property("inputs", custom_inputs)
|
||||
changed.append(key)
|
||||
continue
|
||||
current = None
|
||||
try:
|
||||
current = expr.get_editor_property(key)
|
||||
except Exception:
|
||||
pass
|
||||
expr.set_editor_property(key, _coerce_value(value, current))
|
||||
changed.append(key)
|
||||
return changed
|
||||
|
||||
|
||||
def _create_expression(asset, spec: dict):
|
||||
cls = _load_expression_class(spec.get("class") or spec.get("target") or "")
|
||||
pos = spec.get("position") or [0, 0]
|
||||
x = int(pos[0]) if len(pos) > 0 else 0
|
||||
y = int(pos[1]) if len(pos) > 1 else 0
|
||||
if isinstance(asset, unreal.Material):
|
||||
expr = _mel().create_material_expression(asset, cls, x, y)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
expr = _mel().create_material_expression_in_function(asset, cls, x, y)
|
||||
else:
|
||||
raise RuntimeError(f"unsupported graph asset type {type(asset).__name__}")
|
||||
if expr is None:
|
||||
raise RuntimeError("create_material_expression returned None")
|
||||
if spec.get("name"):
|
||||
try:
|
||||
expr.rename(str(spec["name"]))
|
||||
except Exception:
|
||||
pass
|
||||
_set_expr_properties(expr, spec.get("properties") or spec.get("params") or {})
|
||||
return expr
|
||||
|
||||
|
||||
def create_material(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
asset = _asset_tools().create_asset(name, folder, unreal.Material, unreal.MaterialFactoryNew())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def set_material_settings(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
changed = []
|
||||
for key, value in (args.get("properties") or {}).items():
|
||||
current = None
|
||||
try:
|
||||
current = asset.get_editor_property(key)
|
||||
except Exception:
|
||||
pass
|
||||
asset.set_editor_property(key, _coerce_value(value, current))
|
||||
changed.append(key)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "changed": changed}
|
||||
|
||||
|
||||
def inspect_material_settings(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
result = {"ok": True, "properties": {}}
|
||||
for key in args.get("properties") or []:
|
||||
value = asset.get_editor_property(key)
|
||||
result["properties"][key] = {
|
||||
"type": type(value).__name__,
|
||||
"repr": repr(value),
|
||||
"str": str(value),
|
||||
"members": list(getattr(value.__class__, "__members__", {}).keys()),
|
||||
"unreal_class": repr(getattr(unreal, type(value).__name__, None)),
|
||||
"class_dir": [n for n in dir(value.__class__) if n.startswith("BL_") or n.startswith("MD_")],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def create_material_instance(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
asset = _asset_tools().create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
parent = args.get("parent")
|
||||
if parent:
|
||||
_mel().set_material_instance_parent(asset, _load_material_interface(parent))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent or None}
|
||||
|
||||
|
||||
def read_graph(args: dict) -> dict:
|
||||
asset_path = args["asset_path"]
|
||||
asset = _load_asset(asset_path)
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
return json.loads(lib.dump_material_graph_to_json(asset))
|
||||
except Exception:
|
||||
pass
|
||||
expressions, _ = _material_expressions(asset)
|
||||
nodes = []
|
||||
edges = []
|
||||
for expr in expressions:
|
||||
node = {
|
||||
"guid": _expr_guid(expr),
|
||||
"name": expr.get_name(),
|
||||
"class": expr.get_class().get_path_name(),
|
||||
"position": _expr_pos(expr),
|
||||
"inputs": [],
|
||||
}
|
||||
try:
|
||||
node["input_names"] = list(_mel().get_material_expression_input_names(expr))
|
||||
except Exception:
|
||||
node["input_names"] = []
|
||||
nodes.append(node)
|
||||
if isinstance(asset, unreal.Material):
|
||||
try:
|
||||
inputs = list(_mel().get_inputs_for_material_expression(asset, expr))
|
||||
for input_expr in inputs:
|
||||
out_name = ""
|
||||
try:
|
||||
out_name = str(_mel().get_input_node_output_name_for_material_expression(expr, input_expr))
|
||||
except Exception:
|
||||
pass
|
||||
edges.append({
|
||||
"from": [_expr_guid(input_expr), out_name],
|
||||
"to": [_expr_guid(expr), ""],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
outputs = {}
|
||||
if isinstance(asset, unreal.Material):
|
||||
for pretty, enum_name in MATERIAL_PROPERTY_MAP.items():
|
||||
if pretty != pretty.replace("_", ""):
|
||||
continue
|
||||
try:
|
||||
prop = getattr(unreal.MaterialProperty, enum_name)
|
||||
input_node = _mel().get_material_property_input_node(asset, prop)
|
||||
if input_node is not None:
|
||||
outputs[pretty] = {
|
||||
"from": [_expr_guid(input_node), str(_mel().get_material_property_input_node_output_name(asset, prop))],
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "asset_path": asset_path, "nodes": nodes, "edges": edges, "outputs": outputs}
|
||||
|
||||
|
||||
def apply_graph(args: dict) -> dict:
|
||||
asset_path = args["asset_path"]
|
||||
asset = _load_asset(asset_path)
|
||||
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
|
||||
return {"ok": False, "error": f"{asset_path} is not a Material or MaterialFunction"}
|
||||
result = {"ok": False, "asset_path": asset_path, "spawned": {}, "errors": [], "warnings": []}
|
||||
if args.get("clear_existing"):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().delete_all_material_expressions(asset)
|
||||
else:
|
||||
_mel().delete_all_material_expressions_in_function(asset)
|
||||
local_map = {}
|
||||
node_specs = graph_layout.normalize_specs(
|
||||
args.get("nodes") or [],
|
||||
args.get("edges") or [],
|
||||
domain="material",
|
||||
) if args.get("auto_layout", True) else list(args.get("nodes") or [])
|
||||
|
||||
for spec in node_specs:
|
||||
try:
|
||||
expr = _create_expression(asset, spec)
|
||||
local_id = spec.get("id") or expr.get_name()
|
||||
guid = _expr_guid(expr)
|
||||
local_map[local_id] = expr
|
||||
result["spawned"][local_id] = {"guid": guid, "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
||||
except Exception as e:
|
||||
result["errors"].append(f"node {spec.get('id') or spec.get('class')}: {e}")
|
||||
|
||||
def resolve_node(ref):
|
||||
if ref in local_map:
|
||||
return local_map[ref]
|
||||
return _find_expression(asset, ref)
|
||||
|
||||
for edge in args.get("edges") or []:
|
||||
try:
|
||||
from_ref, from_output = edge["from"]
|
||||
to_ref, to_input = edge["to"]
|
||||
connect_result = _connect_material_expressions(asset, resolve_node(from_ref), str(from_output or ""), resolve_node(to_ref), str(to_input or ""))
|
||||
if not connect_result.get("ok"):
|
||||
result["warnings"].append(f"connect returned false: {edge}: {connect_result.get('error') or connect_result}")
|
||||
except Exception as e:
|
||||
result["errors"].append(f"edge {edge}: {e}")
|
||||
if isinstance(asset, unreal.Material):
|
||||
for prop_name, out_ref in (args.get("outputs") or {}).items():
|
||||
try:
|
||||
if isinstance(out_ref, dict):
|
||||
out_ref = out_ref.get("from")
|
||||
from_ref, from_output = out_ref
|
||||
ok = _mel().connect_material_property(resolve_node(from_ref), str(from_output or ""), _material_property(prop_name))
|
||||
if not ok:
|
||||
result["warnings"].append(f"connect output returned false: {prop_name}")
|
||||
except Exception as e:
|
||||
result["errors"].append(f"output {prop_name}: {e}")
|
||||
if args.get("layout", False):
|
||||
_layout_asset(asset)
|
||||
_update_asset(asset)
|
||||
if args.get("save", True):
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
result["ok"] = not result["errors"]
|
||||
return result
|
||||
|
||||
|
||||
def add_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _create_expression(asset, args)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "guid": _expr_guid(expr), "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().delete_material_expression(asset, expr)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().delete_material_expression_in_function(asset, expr)
|
||||
else:
|
||||
raise RuntimeError("asset is not Material or MaterialFunction")
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
moved = []
|
||||
for move in args.get("moves") or []:
|
||||
expr = _find_expression(asset, move.get("guid") or move.get("name"))
|
||||
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
||||
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
||||
moved.append(_expr_guid(expr))
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
def set_node_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
||||
changed = _set_expr_properties(expr, {args["property"]: args.get("value")})
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "changed": changed}
|
||||
|
||||
|
||||
def connect(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
from_ref, from_output = args["from"]
|
||||
to_ref, to_input = args["to"]
|
||||
result = _connect_material_expressions(
|
||||
asset,
|
||||
_find_expression(asset, from_ref),
|
||||
str(from_output or ""),
|
||||
_find_expression(asset, to_ref),
|
||||
str(to_input or ""),
|
||||
)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return result
|
||||
|
||||
|
||||
def connect_output(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
from_ref, from_output = args["from"]
|
||||
ok = _mel().connect_material_property(_find_expression(asset, from_ref), str(from_output or ""), _material_property(args["property"]))
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def layout(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
_layout_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def tidy_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = read_graph({"asset_path": args["asset_path"]})
|
||||
if not graph.get("ok"):
|
||||
return graph
|
||||
moves = graph_layout.tidy_existing_nodes(graph.get("nodes") or [], graph.get("edges") or [], "material")
|
||||
moved = []
|
||||
for move in moves:
|
||||
expr = _find_expression(asset, move["guid"])
|
||||
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
||||
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
||||
moved.append(move)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "moved": moved, "count": len(moved)}
|
||||
|
||||
|
||||
def recompile_save(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_instance_parent(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
if not isinstance(asset, unreal.MaterialInstanceConstant):
|
||||
return {"ok": False, "error": "asset is not MaterialInstanceConstant"}
|
||||
_mel().set_material_instance_parent(asset, _load_material_interface(args["parent"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_instance_scalar(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_scalar_parameter_value(asset, str(args["name"]), float(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_instance_vector(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_vector_parameter_value(asset, str(args["name"]), _coerce_value(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_instance_texture(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_texture_parameter_value(asset, str(args["name"]), _load_asset(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def list_parameters(args: dict) -> dict:
|
||||
asset = _load_material_interface(args["asset_path"])
|
||||
out = {"ok": True, "asset_path": args["asset_path"], "scalar": [], "vector": [], "texture": [], "static_switch": []}
|
||||
for key, fn_name in (
|
||||
("scalar", "get_scalar_parameter_names"),
|
||||
("vector", "get_vector_parameter_names"),
|
||||
("texture", "get_texture_parameter_names"),
|
||||
("static_switch", "get_static_switch_parameter_names"),
|
||||
):
|
||||
try:
|
||||
out[key] = [str(x) for x in getattr(_mel(), fn_name)(asset)]
|
||||
except Exception as e:
|
||||
out[key + "_error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
def open_asset(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = unreal.AssetEditorSubsystem().open_editor_for_assets([asset])
|
||||
return {"ok": bool(ok), "asset_path": args["asset_path"]}
|
||||
|
||||
|
||||
def _layout_asset(asset):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().layout_material_expressions(asset)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().layout_material_function_expressions(asset)
|
||||
|
||||
|
||||
def _update_asset(asset):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().recompile_material(asset)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().update_material_function(asset, None)
|
||||
elif isinstance(asset, unreal.MaterialInstanceConstant):
|
||||
_mel().update_material_instance(asset)
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_material": create_material,
|
||||
"set_material_settings": set_material_settings,
|
||||
"inspect_material_settings": inspect_material_settings,
|
||||
"create_material_instance": create_material_instance,
|
||||
"read_graph": read_graph,
|
||||
"apply_graph": apply_graph,
|
||||
"add_node": add_node,
|
||||
"delete_node": delete_node,
|
||||
"move_nodes": move_nodes,
|
||||
"set_node_property": set_node_property,
|
||||
"connect": connect,
|
||||
"connect_output": connect_output,
|
||||
"layout": layout,
|
||||
"tidy_graph": tidy_graph,
|
||||
"recompile_save": recompile_save,
|
||||
"set_instance_parent": set_instance_parent,
|
||||
"set_instance_scalar": set_instance_scalar,
|
||||
"set_instance_vector": set_instance_vector,
|
||||
"set_instance_texture": set_instance_texture,
|
||||
"list_parameters": list_parameters,
|
||||
"open_asset": open_asset,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-MATERIALS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
361
ue_side/mesh.py
Normal file
361
ue_side/mesh.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""Static / Skeletal Mesh ops — info, LODs, sockets, collision.
|
||||
|
||||
Ops:
|
||||
static_info {path} — LODs, materials, collision, sockets, bounds, vertex/tri count
|
||||
skeletal_info {path} — LODs, bones, sockets, skeleton, physics asset
|
||||
list_sockets {path} — works for both static and skeletal
|
||||
add_socket {path, name, bone?, location?, rotation?, scale?}
|
||||
remove_socket {path, name}
|
||||
list_lods {path}
|
||||
set_lod_screen_size {path, lod_index, screen_size}
|
||||
remove_lod {path, lod_index}
|
||||
set_collision_complexity{path, complexity:'Default|UseSimpleAsComplex|UseComplexAsSimple'}
|
||||
add_simple_collision {path, shape:'Box|Sphere|Capsule', extents_or_radius}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _vec(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _rot(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def static_info(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "not a StaticMesh"}
|
||||
out = {"ok": True, "path": args["path"]}
|
||||
# UE 5.4+ moved methods from EditorStaticMeshLibrary to StaticMeshEditorSubsystem.
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
|
||||
|
||||
def _call(*sources, **kw):
|
||||
method = kw["method"]; args_ = kw.get("args", ())
|
||||
for src in sources:
|
||||
if src is None: continue
|
||||
fn = getattr(src, method, None)
|
||||
if fn:
|
||||
try: return fn(m, *args_)
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
try:
|
||||
v = _call(smes, legacy, method="get_lod_count")
|
||||
if v is not None: out["lod_count"] = v
|
||||
v = _call(smes, legacy, method="get_number_verts", args=(0,))
|
||||
if v is not None: out["vertex_count"] = v
|
||||
v = _call(smes, legacy, method="get_number_triangles", args=(0,))
|
||||
if v is not None: out["triangle_count"] = v
|
||||
slots = _call(smes, legacy, method="get_lod_material_slot_names", args=(0,))
|
||||
if slots: out["material_slots"] = [str(n) for n in slots]
|
||||
b = _call(smes, legacy, method="get_bounds")
|
||||
if b is not None:
|
||||
try: out["bounds"] = list(b.box_extent)
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
out["info_error"] = str(e)
|
||||
try:
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
out["sockets"] = [str(n) for n in (smes.get_sockets(m) if smes else [])]
|
||||
except Exception:
|
||||
out["sockets"] = []
|
||||
try:
|
||||
out["materials"] = [{"slot": str(s.material_slot_name),
|
||||
"material": s.material_interface.get_path_name() if s.material_interface else None}
|
||||
for s in (m.get_editor_property("static_materials") or [])]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def skeletal_info(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = {"ok": True, "path": args["path"], "class": m.get_class().get_name()}
|
||||
try:
|
||||
sk = m.get_editor_property("skeleton")
|
||||
out["skeleton"] = sk.get_path_name().split(".")[0] if sk else None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pa = m.get_editor_property("physics_asset")
|
||||
out["physics_asset"] = pa.get_path_name().split(".")[0] if pa else None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
|
||||
if ssel:
|
||||
out["lod_count"] = ssel.get_lod_count(m)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["sockets"] = [str(s.socket_name) for s in (m.get_editor_property("sockets") or [])]
|
||||
except Exception:
|
||||
out["sockets"] = []
|
||||
try:
|
||||
mats = m.get_editor_property("materials") or []
|
||||
out["materials"] = [(str(x.material_slot_name), x.material_interface.get_path_name() if x.material_interface else None) for x in mats]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def list_sockets(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
out = []
|
||||
# Sockets array is protected — use StaticMeshEditorSubsystem or find_socket fallback.
|
||||
names = []
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
if smes and hasattr(smes, "get_sockets"):
|
||||
try: names = [str(n) for n in (smes.get_sockets(m) or [])]
|
||||
except Exception: names = []
|
||||
for n in names:
|
||||
try:
|
||||
s = m.find_socket(n)
|
||||
if s:
|
||||
out.append({"name": n,
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation"))})
|
||||
except Exception:
|
||||
out.append({"name": n})
|
||||
else:
|
||||
out = []
|
||||
for s in (m.get_editor_property("sockets") or []):
|
||||
try:
|
||||
out.append({"name": str(s.get_editor_property("socket_name")),
|
||||
"bone": str(s.get_editor_property("bone_name")),
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation"))})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "sockets": out}
|
||||
|
||||
|
||||
def add_socket(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
# The Sockets array is a protected UPROPERTY in Python — fall back to
|
||||
# StaticMeshEditorSubsystem which exposes set_socket / set_sockets
|
||||
# depending on UE version. If neither is exposed, instruct user to use UI.
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
sock = unreal.StaticMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sock.set_editor_property("relative_scale", _vec(args.get("scale"), (1, 1, 1)))
|
||||
added = False
|
||||
if smes:
|
||||
for fn_name in ("set_socket", "add_socket"):
|
||||
fn = getattr(smes, fn_name, None)
|
||||
if fn:
|
||||
try: fn(m, sock); added = True; break
|
||||
except Exception: pass
|
||||
if not added:
|
||||
return {"ok": False, "error": "StaticMesh sockets array is protected; StaticMeshEditorSubsystem has no set_socket/add_socket on this build. Use Static Mesh Editor UI."}
|
||||
else:
|
||||
sock = unreal.SkeletalMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("bone_name", args.get("bone") or "")
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sockets = list(m.get_editor_property("sockets") or [])
|
||||
sockets.append(sock)
|
||||
m.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def remove_socket(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
target = args["name"]
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
removed = False
|
||||
if smes:
|
||||
for fn_name in ("remove_socket",):
|
||||
fn = getattr(smes, fn_name, None)
|
||||
if fn:
|
||||
try: fn(m, target); removed = True; break
|
||||
except Exception: pass
|
||||
if not removed:
|
||||
return {"ok": False, "error": "StaticMesh sockets array is protected; no remove_socket on subsystem in this build. Use UI."}
|
||||
else:
|
||||
m.set_editor_property("sockets",
|
||||
[s for s in (m.get_editor_property("sockets") or [])
|
||||
if str(s.get_editor_property("socket_name")) != target])
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_lods(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = []
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
|
||||
def _c(method, *a):
|
||||
for src in (smes, legacy):
|
||||
if src is None: continue
|
||||
fn = getattr(src, method, None)
|
||||
if fn:
|
||||
try: return fn(m, *a)
|
||||
except Exception: pass
|
||||
return None
|
||||
n = _c("get_lod_count") or 0
|
||||
for i in range(n):
|
||||
lod = {"index": i}
|
||||
ss = _c("get_lod_screen_size", i)
|
||||
if ss is not None: lod["screen_size"] = ss
|
||||
tr = _c("get_number_triangles", i)
|
||||
if tr is not None: lod["tris"] = tr
|
||||
out.append(lod)
|
||||
else:
|
||||
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
|
||||
sses = unreal.get_editor_subsystem(unreal.SkeletalMeshEditorSubsystem) if hasattr(unreal, "SkeletalMeshEditorSubsystem") else None
|
||||
n = 0
|
||||
for src in (sses, ssel):
|
||||
if src and hasattr(src, "get_lod_count"):
|
||||
try: n = src.get_lod_count(m); break
|
||||
except Exception: pass
|
||||
for i in range(n):
|
||||
out.append({"index": i})
|
||||
return {"ok": True, "lods": out}
|
||||
|
||||
|
||||
def set_lod_screen_size(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
smel = unreal.EditorStaticMeshLibrary
|
||||
try:
|
||||
smel.set_lod_reduction_settings if False else None
|
||||
# No direct setter; use BuildSettings via set_lod_screen_size if exposed.
|
||||
if hasattr(smel, "set_lod_screen_size"):
|
||||
smel.set_lod_screen_size(m, int(args["lod_index"]), float(args["screen_size"]))
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "set_lod_screen_size not exposed in this UE build"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def remove_lod(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
try:
|
||||
unreal.EditorStaticMeshLibrary.remove_lods(m)
|
||||
return {"ok": True, "note": "all generated LODs removed (UE API limitation)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_collision_complexity(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
mode = args["complexity"]
|
||||
enum_map = {
|
||||
"Default": unreal.CollisionTraceFlag.CTF_USE_DEFAULT,
|
||||
"UseSimpleAsComplex": unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AS_COMPLEX,
|
||||
"UseComplexAsSimple": unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE,
|
||||
}
|
||||
if mode not in enum_map:
|
||||
return {"ok": False, "error": f"unknown mode; valid: {list(enum_map)}"}
|
||||
try:
|
||||
bs = m.get_editor_property("body_setup")
|
||||
if bs:
|
||||
bs.set_editor_property("collision_trace_flag", enum_map[mode])
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "body_setup missing"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def add_simple_collision(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
smel = unreal.EditorStaticMeshLibrary
|
||||
shape = args["shape"]
|
||||
try:
|
||||
if shape == "Box":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.BOX)
|
||||
elif shape == "Sphere":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.SPHERE)
|
||||
elif shape == "Capsule":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.CAPSULE)
|
||||
else:
|
||||
return {"ok": False, "error": "shape must be Box|Sphere|Capsule"}
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"static_info": static_info,
|
||||
"skeletal_info": skeletal_info,
|
||||
"list_sockets": list_sockets,
|
||||
"add_socket": add_socket,
|
||||
"remove_socket": remove_socket,
|
||||
"list_lods": list_lods,
|
||||
"set_lod_screen_size": set_lod_screen_size,
|
||||
"remove_lod": remove_lod,
|
||||
"set_collision_complexity": set_collision_complexity,
|
||||
"add_simple_collision": add_simple_collision,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-MESH] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
302
ue_side/network.py
Normal file
302
ue_side/network.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Network ops for Blueprint assets.
|
||||
|
||||
Ops:
|
||||
audit_bp {bp_path}
|
||||
set_actor_flags {bp_path, replicates?, replicate_movement?, net_load_on_client?,
|
||||
always_relevant?, only_relevant_to_owner?, use_owner_relevancy?,
|
||||
net_cull_distance_squared?, net_update_frequency?,
|
||||
min_net_update_frequency?, net_priority?, net_dormancy?}
|
||||
set_component_flags {bp_path, component, replicated?, net_addressable?}
|
||||
set_custom_event_rpc {bp_path, function?, event?, guid?, mode, reliable?}
|
||||
apply_profile {bp_path, profile}
|
||||
|
||||
RPC modes for set_custom_event_rpc:
|
||||
none | server | client | multicast
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
ACTOR_PROP_MAP = {
|
||||
"replicates": "replicates",
|
||||
"replicate_movement": "replicate_movement",
|
||||
"net_load_on_client": "net_load_on_client",
|
||||
"always_relevant": "always_relevant",
|
||||
"only_relevant_to_owner": "only_relevant_to_owner",
|
||||
"use_owner_relevancy": "net_use_owner_relevancy",
|
||||
"net_cull_distance_squared": "net_cull_distance_squared",
|
||||
"net_update_frequency": "net_update_frequency",
|
||||
"min_net_update_frequency": "min_net_update_frequency",
|
||||
"net_priority": "net_priority",
|
||||
"net_dormancy": "net_dormancy",
|
||||
}
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
bp = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a Blueprint: {path!r}")
|
||||
return bp
|
||||
|
||||
|
||||
def _generated_class(bp_path: str):
|
||||
cls_path = f"{bp_path}.{bp_path.rsplit('/', 1)[-1]}_C"
|
||||
cls = unreal.load_class(None, cls_path)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"generated class not found: {cls_path}")
|
||||
return cls
|
||||
|
||||
|
||||
def _cdo(bp_path: str):
|
||||
return unreal.get_default_object(_generated_class(bp_path))
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _find_graph(bp, name: str):
|
||||
L = _lib()
|
||||
if name in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, name)
|
||||
|
||||
|
||||
def _get_prop(obj, prop):
|
||||
try:
|
||||
return obj.get_editor_property(prop)
|
||||
except Exception:
|
||||
return getattr(obj, prop)
|
||||
|
||||
|
||||
def _set_prop(obj, prop, value):
|
||||
try:
|
||||
obj.set_editor_property(prop, value)
|
||||
except Exception:
|
||||
setattr(obj, prop, value)
|
||||
|
||||
|
||||
def _save_compile(bp):
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
messages = []
|
||||
try:
|
||||
if L:
|
||||
messages = list(L.compile_with_messages(bp))
|
||||
else:
|
||||
unreal.KismetEditorUtilities.compile_blueprint(bp)
|
||||
except Exception as e:
|
||||
messages = [f"WARN||compile failed from network.py: {e}"]
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return messages
|
||||
|
||||
|
||||
def _read_actor_flags(cdo):
|
||||
out = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
try:
|
||||
v = _get_prop(cdo, prop)
|
||||
out[public_name] = str(v) if public_name == "net_dormancy" else v
|
||||
except Exception as e:
|
||||
out[public_name] = {"error": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
def audit_bp(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
cdo = _cdo(bp_path)
|
||||
result = {
|
||||
"ok": True,
|
||||
"bp_path": bp_path,
|
||||
"actor": _read_actor_flags(cdo),
|
||||
"components": [],
|
||||
"custom_events": [],
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
for name in args.get("components") or []:
|
||||
item = {"name": name}
|
||||
try:
|
||||
comp = _get_prop(cdo, name)
|
||||
item["class"] = comp.get_class().get_name()
|
||||
for prop in ("replicates", "is_replicated", "net_addressable"):
|
||||
try:
|
||||
item[prop] = _get_prop(comp, prop)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
item["error"] = str(e)
|
||||
result["components"].append(item)
|
||||
|
||||
try:
|
||||
import apply_graph
|
||||
raw = apply_graph.read_graph(bp_path, args.get("function") or "EventGraph")
|
||||
for node in raw.get("graph", {}).get("nodes", []):
|
||||
if node.get("class") == "K2Node_CustomEvent":
|
||||
result["custom_events"].append({
|
||||
"guid": node.get("guid"),
|
||||
"title": node.get("title"),
|
||||
"target": node.get("target"),
|
||||
})
|
||||
result["notes"].append("Custom event RPC flags require set_custom_event_rpc; read_graph does not expose them.")
|
||||
except Exception as e:
|
||||
result["notes"].append(f"custom event scan failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def set_actor_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
try:
|
||||
_set_prop(cdo, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
except Exception as e:
|
||||
errors[public_name] = str(e)
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def _component_from_cdo(cdo, name: str):
|
||||
try:
|
||||
return _get_prop(cdo, name)
|
||||
except Exception:
|
||||
pass
|
||||
getter = getattr(cdo, "get_component_by_class", None)
|
||||
if getter:
|
||||
# Fallback is intentionally conservative: caller should pass the BP component variable name.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_component_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
comp_name = args["component"]
|
||||
comp = _component_from_cdo(cdo, comp_name)
|
||||
if comp is None:
|
||||
return {"ok": False, "error": f"component not found on CDO by name: {comp_name}"}
|
||||
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, candidates in {
|
||||
"replicated": ("replicates", "is_replicated"),
|
||||
"net_addressable": ("net_addressable",),
|
||||
}.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
done = False
|
||||
for prop in candidates:
|
||||
try:
|
||||
_set_prop(comp, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
done = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not done:
|
||||
errors[public_name] = f"no writable property among {candidates}"
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "component": comp_name, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def set_custom_event_rpc(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
graph = _find_graph(bp, args.get("function") or "EventGraph")
|
||||
if graph is None:
|
||||
return {"ok": False, "error": "graph not found"}
|
||||
|
||||
target = args.get("guid") or args.get("event")
|
||||
if not target:
|
||||
return {"ok": False, "error": "set_custom_event_rpc requires guid or event"}
|
||||
mode = args.get("mode") or "none"
|
||||
reliable = bool(args.get("reliable", False))
|
||||
|
||||
L = _lib()
|
||||
setter = getattr(L, "set_custom_event_network_flags", None)
|
||||
if setter is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "MCPGraphLibrary.set_custom_event_network_flags is unavailable. Rebuild/restart UE after the plugin C++ update.",
|
||||
"requested": {"target": target, "mode": mode, "reliable": reliable},
|
||||
}
|
||||
ok = bool(setter(bp, graph, target, mode, reliable))
|
||||
messages = _save_compile(bp) if ok else []
|
||||
return {"ok": ok, "bp_path": bp_path, "target": target, "mode": mode, "reliable": reliable, "compile_messages": messages}
|
||||
|
||||
|
||||
def apply_profile(args: dict) -> dict:
|
||||
profile = args["profile"]
|
||||
bp_path = args["bp_path"]
|
||||
if profile == "replicated_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
if profile == "always_relevant_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"always_relevant": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
return {"ok": False, "error": f"unknown profile {profile!r}", "available": ["replicated_world_actor", "always_relevant_world_actor"]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"audit_bp": audit_bp,
|
||||
"set_actor_flags": set_actor_flags,
|
||||
"set_component_flags": set_component_flags,
|
||||
"set_custom_event_rpc": set_custom_event_rpc,
|
||||
"apply_profile": apply_profile,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-NETWORK] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
271
ue_side/niagara.py
Normal file
271
ue_side/niagara.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""Niagara authoring ops. Bridges to unreal.MCPNiagaraLibrary (C++) where
|
||||
editor-only APIs are required, falls back to pure Python where possible.
|
||||
|
||||
Ops:
|
||||
create_system {path}
|
||||
create_emitter_asset {path, template_emitter?} — create empty UNiagaraEmitter asset
|
||||
add_emitter {system, emitter_asset, name?} — add emitter to system, returns handle name
|
||||
remove_emitter {system, emitter_name}
|
||||
list_emitters {system}
|
||||
list_modules {system, emitter_name?, group} — group: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate
|
||||
add_module {system, emitter_name?, group, module_script}
|
||||
remove_module {system, emitter_name?, group, module_name}
|
||||
add_renderer {system, emitter_name, renderer_class} — e.g. NiagaraSpriteRendererProperties
|
||||
remove_renderer {system, emitter_name, renderer_class}
|
||||
compile {system}
|
||||
set_module_input STUB — see NOTES at bottom.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
# ---------- loaders ----------
|
||||
|
||||
def _load_asset(path: str):
|
||||
if not path:
|
||||
return None
|
||||
return unreal.EditorAssetLibrary.load_asset(path)
|
||||
|
||||
|
||||
def _load_class(p: str):
|
||||
if not p:
|
||||
return None
|
||||
if "/" in p:
|
||||
return unreal.load_class(None, p)
|
||||
# Try common Niagara paths
|
||||
for prefix in ("/Script/Niagara.", "/Script/NiagaraEditor."):
|
||||
c = unreal.load_class(None, prefix + p)
|
||||
if c is not None:
|
||||
return c
|
||||
return unreal.load_class(None, p)
|
||||
|
||||
|
||||
def _lib():
|
||||
if not hasattr(unreal, "MCPNiagaraLibrary"):
|
||||
raise RuntimeError("MCPNiagaraLibrary not found — rebuild the UEBlueprintMCPEditor C++ module")
|
||||
return unreal.MCPNiagaraLibrary
|
||||
|
||||
|
||||
# ---------- ops ----------
|
||||
|
||||
def create_system(p):
|
||||
path = p["path"]
|
||||
ok = _lib().create_niagara_system(path)
|
||||
return {"ok": bool(ok), "path": path}
|
||||
|
||||
|
||||
def create_emitter_asset(p):
|
||||
"""Create an empty UNiagaraEmitter asset. Niagara typically doesn't support
|
||||
fully empty emitters via UI — users start from a template. We create the bare
|
||||
asset; for fully usable emitters duplicate an existing template via asset_op.
|
||||
"""
|
||||
path = p["path"]
|
||||
package_name, _, asset_name = path.rpartition("/")
|
||||
factory = unreal.NiagaraEmitterFactoryNew() if hasattr(unreal, "NiagaraEmitterFactoryNew") else None
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
if factory is None:
|
||||
return {"ok": False, "error": "NiagaraEmitterFactoryNew not exposed to Python — duplicate a template emitter via asset_op instead"}
|
||||
asset = tools.create_asset(asset_name, package_name, unreal.NiagaraEmitter, factory)
|
||||
return {"ok": asset is not None, "path": path}
|
||||
|
||||
|
||||
def add_emitter(p):
|
||||
system = _load_asset(p["system"])
|
||||
emitter = _load_asset(p["emitter_asset"])
|
||||
if system is None or emitter is None:
|
||||
return {"ok": False, "error": "system or emitter_asset not found"}
|
||||
name = p.get("name", "")
|
||||
handle_name = _lib().add_emitter_to_system(system, emitter, name)
|
||||
if not handle_name:
|
||||
return {"ok": False, "error": "AddEmitterToSystem returned empty"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "handle_name": handle_name}
|
||||
|
||||
|
||||
def remove_emitter(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().remove_emitter_from_system(system, p["emitter_name"])
|
||||
if ok:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def list_emitters(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
names = list(_lib().get_emitter_handle_names(system))
|
||||
return {"ok": True, "emitters": names}
|
||||
|
||||
|
||||
def list_modules(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
modules = list(_lib().list_modules(system, p.get("emitter_name", ""), p["group"]))
|
||||
return {"ok": True, "modules": modules}
|
||||
|
||||
|
||||
def add_module(p):
|
||||
system = _load_asset(p["system"])
|
||||
script = _load_asset(p["module_script"])
|
||||
if system is None or script is None:
|
||||
return {"ok": False, "error": "system or module_script not found"}
|
||||
name = _lib().add_module_to_stack(
|
||||
system,
|
||||
p.get("emitter_name", ""),
|
||||
p["group"],
|
||||
script,
|
||||
int(p.get("index", -1)),
|
||||
)
|
||||
if not name:
|
||||
return {"ok": False, "error": "AddModuleToStack returned empty (wrong group or script not a module?)"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "module_name": name}
|
||||
|
||||
|
||||
def remove_module(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().remove_module_from_stack(
|
||||
system,
|
||||
p.get("emitter_name", ""),
|
||||
p["group"],
|
||||
p["module_name"],
|
||||
)
|
||||
if ok:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def add_renderer(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
cls = _load_class(p["renderer_class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
|
||||
result = _lib().add_renderer_to_emitter(system, p["emitter_name"], cls)
|
||||
if not result:
|
||||
return {"ok": False, "error": "AddRendererToEmitter returned empty"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "renderer": result}
|
||||
|
||||
|
||||
def remove_renderer(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
cls = _load_class(p["renderer_class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
|
||||
n = int(_lib().remove_renderers_from_emitter(system, p["emitter_name"], cls))
|
||||
if n:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "removed": n}
|
||||
|
||||
|
||||
def assign_to_actor(p):
|
||||
"""Assign a UNiagaraSystem to the NiagaraComponent of a NiagaraActor in the
|
||||
current level. Identifies the actor by its label (use `name` field).
|
||||
"""
|
||||
system_path = p["system"]
|
||||
actor_name = p["name"]
|
||||
system = _load_asset(system_path)
|
||||
if system is None:
|
||||
return {"ok": False, "error": f"system {system_path!r} not found"}
|
||||
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
target = None
|
||||
for a in ess.get_all_level_actors():
|
||||
if a and (a.get_actor_label() == actor_name or a.get_name() == actor_name):
|
||||
target = a
|
||||
break
|
||||
if target is None:
|
||||
return {"ok": False, "error": f"actor {actor_name!r} not found"}
|
||||
comp = target.get_component_by_class(unreal.NiagaraComponent)
|
||||
if comp is None:
|
||||
return {"ok": False, "error": "actor has no NiagaraComponent"}
|
||||
comp.set_asset(system)
|
||||
comp.activate(reset=True)
|
||||
return {"ok": True, "actor": actor_name, "system": system_path}
|
||||
|
||||
|
||||
def compile_system(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().request_compile(system)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_module_input(p):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"set_module_input not implemented yet — requires "
|
||||
"FNiagaraStackFunctionInputBinder + NiagaraClipboard plumbing. "
|
||||
"Workaround: open the system in editor and use UI, or extend "
|
||||
"MCPNiagaraLibrary with SetModuleInput(scalar|vector|enum) wrappers."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_system": create_system,
|
||||
"create_emitter_asset": create_emitter_asset,
|
||||
"add_emitter": add_emitter,
|
||||
"remove_emitter": remove_emitter,
|
||||
"list_emitters": list_emitters,
|
||||
"list_modules": list_modules,
|
||||
"add_module": add_module,
|
||||
"remove_module": remove_module,
|
||||
"add_renderer": add_renderer,
|
||||
"remove_renderer": remove_renderer,
|
||||
"compile": compile_system,
|
||||
"assign_to_actor": assign_to_actor,
|
||||
"set_module_input": set_module_input,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-NIAGARA] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
1237
ue_side/pcg.py
Normal file
1237
ue_side/pcg.py
Normal file
File diff suppressed because it is too large
Load Diff
167
ue_side/presets.py
Normal file
167
ue_side/presets.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Preset library — typical Blueprint patterns packaged as one-line MCP calls.
|
||||
|
||||
Each preset is a pure function: args dict → NodeSpec graph dict (apply_graph format).
|
||||
The dispatcher then runs apply_graph on it.
|
||||
|
||||
Add a new preset:
|
||||
1. Write `def my_preset(args): return {...nodes/edges...}` below.
|
||||
2. Register it in PRESETS at the bottom.
|
||||
3. Call via MCP: preset_op({name: "my_preset", args: {...}}).
|
||||
|
||||
Ships with:
|
||||
print_on_begin_play {bp_path, text?} — BeginPlay → PrintString
|
||||
print_on_event {bp_path, event, text?} — any event → PrintString
|
||||
toggle_visibility {bp_path, event, component} — event → ToggleVisibility on a component
|
||||
damage_drops_health {bp_path, health_var} — AnyDamage → Health -= Damage, branch on <=0 → DestroyActor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
# --- preset implementations ---------------------------------------------------
|
||||
|
||||
def print_on_begin_play(args: dict) -> dict:
|
||||
text = args.get("text", "BeginPlay")
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": "ReceiveBeginPlay", "position": [0, 0]},
|
||||
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
|
||||
"position": [300, 0], "params": {"InString": text}},
|
||||
],
|
||||
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
|
||||
}
|
||||
|
||||
|
||||
def print_on_event(args: dict) -> dict:
|
||||
event = args["event"] # e.g. "ReceiveActorBeginOverlap"
|
||||
text = args.get("text", event)
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
|
||||
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
|
||||
"position": [300, 0], "params": {"InString": text}},
|
||||
],
|
||||
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
|
||||
}
|
||||
|
||||
|
||||
def toggle_visibility(args: dict) -> dict:
|
||||
event = args.get("event", "ReceiveActorBeginOverlap")
|
||||
comp = args["component"] # variable name of a SceneComponent on this BP
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
|
||||
{"id": "getc", "kind": "variable_get", "target": comp, "position": [180, 80]},
|
||||
{"id": "toggle", "kind": "call_function",
|
||||
"target": "/Script/Engine.SceneComponent:ToggleVisibility",
|
||||
"position": [380, 0]},
|
||||
],
|
||||
"edges": [
|
||||
{"from": ["ev", "then"], "to": ["toggle", "execute"]},
|
||||
{"from": ["getc", comp], "to": ["toggle", "self"]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def damage_drops_health(args: dict) -> dict:
|
||||
"""AnyDamage → Health -= Damage; if Health<=0 → DestroyActor."""
|
||||
hv = args.get("health_var", "Health")
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": "ReceiveAnyDamage", "position": [-200, 0]},
|
||||
{"id": "getH", "kind": "variable_get", "target": hv, "position": [-20, 60]},
|
||||
{"id": "sub", "kind": "call_function",
|
||||
"target": "/Script/Engine.KismetMathLibrary:Subtract_FloatFloat",
|
||||
"position": [160, 60]},
|
||||
{"id": "setH", "kind": "variable_set", "target": hv, "position": [340, 0]},
|
||||
{"id": "getH2", "kind": "variable_get", "target": hv, "position": [560, 80]},
|
||||
{"id": "le", "kind": "call_function",
|
||||
"target": "/Script/Engine.KismetMathLibrary:LessEqual_FloatFloat",
|
||||
"position": [720, 60]},
|
||||
{"id": "br", "kind": "branch", "position": [900, 0]},
|
||||
{"id": "destroy", "kind": "call_function",
|
||||
"target": "/Script/Engine.Actor:K2_DestroyActor",
|
||||
"position": [1080, 0]},
|
||||
],
|
||||
"edges": [
|
||||
{"from": ["ev", "then"], "to": ["setH", "execute"]},
|
||||
{"from": ["getH", hv], "to": ["sub", "A"]},
|
||||
{"from": ["ev", "Damage"], "to": ["sub", "B"]},
|
||||
{"from": ["sub", "ReturnValue"], "to": ["setH", hv]},
|
||||
{"from": ["setH", "then"], "to": ["br", "execute"]},
|
||||
{"from": ["getH2", hv], "to": ["le", "A"]},
|
||||
{"from": ["le", "ReturnValue"], "to": ["br", "Condition"]},
|
||||
{"from": ["br", "then"], "to": ["destroy", "execute"]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- dispatch -----------------------------------------------------------------
|
||||
|
||||
PRESETS = {
|
||||
"print_on_begin_play": print_on_begin_play,
|
||||
"print_on_event": print_on_event,
|
||||
"toggle_visibility": toggle_visibility,
|
||||
"damage_drops_health": damage_drops_health,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
name = p.get("name")
|
||||
if name == "__list__":
|
||||
return _tee({"ok": True, "presets": sorted(PRESETS.keys())}, p)
|
||||
fn = PRESETS.get(name)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown preset {name!r}", "available": sorted(PRESETS.keys())}, p)
|
||||
try:
|
||||
spec = fn(p.get("args") or {})
|
||||
# Run through apply_graph
|
||||
import importlib, apply_graph
|
||||
importlib.reload(apply_graph)
|
||||
result = apply_graph.apply(spec)
|
||||
result["preset"] = name
|
||||
return _tee(result, p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc(), "preset": name}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-PRESET] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
198
ue_side/render.py
Normal file
198
ue_side/render.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Render ops — PostProcess volumes, Render targets, Scene captures, viewport screenshots.
|
||||
|
||||
Ops:
|
||||
spawn_post_process_volume {location?, unbound?:true, settings?:{key:value}}
|
||||
set_pp_setting {actor_name, key, value} — sets PostProcessVolume.Settings entries
|
||||
list_pp_volumes {}
|
||||
create_render_target_2d {path, width?:512, height?:512, format?:'RGBA16f|RGBA8'}
|
||||
spawn_scene_capture_2d {target_render_target_path, location?, rotation?, name?}
|
||||
take_screenshot {filename?, resolution?, hdr?:false} — uses HighResShot console cmd
|
||||
set_viewport_realtime {enabled:bool}
|
||||
capture_thumbnail {asset_path} — refresh editor thumbnail
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ess(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
def _world(): return unreal.EditorLevelLibrary.get_editor_world()
|
||||
def _vec(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _rot(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _find(name):
|
||||
for a in _ess().get_all_level_actors():
|
||||
if a and (a.get_actor_label() == name or a.get_name() == name):
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def spawn_post_process_volume(args):
|
||||
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
|
||||
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(None))
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn failed"}
|
||||
if args.get("unbound", True):
|
||||
actor.set_editor_property("unbound", True)
|
||||
settings = args.get("settings") or {}
|
||||
if settings:
|
||||
pp = actor.get_editor_property("settings")
|
||||
for k, v in settings.items():
|
||||
try:
|
||||
pp.set_editor_property(k, v)
|
||||
except Exception:
|
||||
pass
|
||||
actor.set_editor_property("settings", pp)
|
||||
label = args.get("name") or "PostProcessVolume_MCP"
|
||||
actor.set_actor_label(label)
|
||||
return {"ok": True, "name": label, "path": actor.get_path_name()}
|
||||
|
||||
|
||||
def set_pp_setting(args):
|
||||
a = _find(args["actor_name"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
try:
|
||||
pp = a.get_editor_property("settings")
|
||||
pp.set_editor_property(args["key"], args["value"])
|
||||
a.set_editor_property("settings", pp)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_pp_volumes(args):
|
||||
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
|
||||
out = []
|
||||
for a in _ess().get_all_level_actors():
|
||||
if a and a.get_class() == cls.get_default_object().get_class():
|
||||
out.append({"name": a.get_actor_label(), "unbound": bool(a.get_editor_property("unbound"))})
|
||||
return {"ok": True, "volumes": out}
|
||||
|
||||
|
||||
def create_render_target_2d(args):
|
||||
folder, name = args["path"].rsplit("/", 1)
|
||||
factory = unreal.CanvasRenderTarget2DFactoryNew() if hasattr(unreal, "CanvasRenderTarget2DFactoryNew") else None
|
||||
asset_cls = unreal.TextureRenderTarget2D
|
||||
factory = unreal.TextureRenderTargetFactoryNew() if hasattr(unreal, "TextureRenderTargetFactoryNew") else factory
|
||||
if factory is None:
|
||||
return {"ok": False, "error": "no RT factory exposed"}
|
||||
at = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = at.create_asset(name, folder, asset_cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
asset.set_editor_property("size_x", int(args.get("width") or 512))
|
||||
asset.set_editor_property("size_y", int(args.get("height") or 512))
|
||||
fmt = args.get("format") or "RGBA8"
|
||||
fmt_enum = unreal.TextureRenderTargetFormat.RTF_RGBA16F if fmt == "RGBA16f" else unreal.TextureRenderTargetFormat.RTF_RGBA8
|
||||
try:
|
||||
asset.set_editor_property("render_target_format", fmt_enum)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def spawn_scene_capture_2d(args):
|
||||
rt = unreal.EditorAssetLibrary.load_asset(args["target_render_target_path"])
|
||||
if rt is None:
|
||||
return {"ok": False, "error": "render target not found"}
|
||||
cls = unreal.load_class(None, "/Script/Engine.SceneCapture2D")
|
||||
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(args.get("rotation")))
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn failed"}
|
||||
try:
|
||||
comp = actor.get_component_by_class(unreal.SceneCaptureComponent2D)
|
||||
comp.set_editor_property("texture_target", rt)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"set RT failed: {e}"}
|
||||
label = args.get("name") or "SceneCapture2D_MCP"
|
||||
actor.set_actor_label(label)
|
||||
return {"ok": True, "name": label}
|
||||
|
||||
|
||||
def take_screenshot(args):
|
||||
res = args.get("resolution") or "1920x1080"
|
||||
fname = args.get("filename") or ""
|
||||
hdr = "1" if args.get("hdr") else ""
|
||||
cmd = f"HighResShot {res} {fname} {hdr}".strip()
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd, "note": "Saved to <Project>/Saved/Screenshots/<Platform>/"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_viewport_realtime(args):
|
||||
try:
|
||||
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
# No direct API; use console command
|
||||
cmd = "r.Realtime 1" if args.get("enabled") else "r.Realtime 0"
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def capture_thumbnail(args):
|
||||
paths = unreal.Array(str)
|
||||
paths.append(args["asset_path"])
|
||||
try:
|
||||
unreal.AssetEditorSubsystem if False else None
|
||||
# No direct python wrapper; rely on AssetTools? Fall back to editor cmd
|
||||
unreal.SystemLibrary.execute_console_command(_world(), f"AssetThumbnailCapture {args['asset_path']}")
|
||||
return {"ok": True, "note": "issued console command"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"spawn_post_process_volume": spawn_post_process_volume,
|
||||
"set_pp_setting": set_pp_setting,
|
||||
"list_pp_volumes": list_pp_volumes,
|
||||
"create_render_target_2d": create_render_target_2d,
|
||||
"spawn_scene_capture_2d": spawn_scene_capture_2d,
|
||||
"take_screenshot": take_screenshot,
|
||||
"set_viewport_realtime": set_viewport_realtime,
|
||||
"capture_thumbnail": capture_thumbnail,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-RENDER] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
246
ue_side/selection.py
Normal file
246
ue_side/selection.py
Normal file
@ -0,0 +1,246 @@
|
||||
"""Selection-aware Blueprint graph ops.
|
||||
|
||||
Ops:
|
||||
get_context {bp_path, function?}
|
||||
apply_relative {bp_path, function?, placement?, gap_x?, gap_y?,
|
||||
delete_selection?, nodes:[], edges:[]}
|
||||
|
||||
This module keeps partial graph generation out of the full-graph replace path:
|
||||
LLMs can inspect the user's selected nodes, then append or replace only that
|
||||
local area.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _graph(bp, fn: str | None):
|
||||
L = _lib()
|
||||
if fn in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, fn)
|
||||
|
||||
|
||||
def _selected(bp) -> dict:
|
||||
raw = _lib().get_selected_nodes_json(bp)
|
||||
data = json.loads(raw)
|
||||
nodes = data.get("selected") or []
|
||||
bbox = _bbox(nodes)
|
||||
focused_graph = data.get("focused_graph")
|
||||
draft_zones = _draft_zones(bp, focused_graph)
|
||||
return {
|
||||
"focused_graph": focused_graph,
|
||||
"selected": nodes,
|
||||
"selected_count": len(nodes),
|
||||
"bbox": bbox,
|
||||
"anchors": _anchors(bbox),
|
||||
"draft_zones": draft_zones,
|
||||
}
|
||||
|
||||
|
||||
def _draft_zones(bp, function_name: str | None) -> list[dict]:
|
||||
graph = _graph(bp, function_name or "EventGraph")
|
||||
if graph is None:
|
||||
return []
|
||||
raw = _lib().dump_graph_to_json(graph)
|
||||
data = json.loads(raw)
|
||||
zones = []
|
||||
for node in data.get("nodes") or []:
|
||||
if node.get("class") != "EdGraphNode_Comment":
|
||||
continue
|
||||
comment = str(node.get("comment") or node.get("title") or "")
|
||||
if comment.strip().lower() not in ("mcp zone", "mcp draft zone"):
|
||||
continue
|
||||
zone = dict(node)
|
||||
zone["bbox"] = {
|
||||
"min_x": float(node.get("x", 0)),
|
||||
"min_y": float(node.get("y", 0)),
|
||||
"max_x": float(node.get("x", 0)) + float(node.get("w", 0)),
|
||||
"max_y": float(node.get("y", 0)) + float(node.get("h", 0)),
|
||||
"width": float(node.get("w", 0)),
|
||||
"height": float(node.get("h", 0)),
|
||||
"center_x": float(node.get("x", 0)) + (float(node.get("w", 0)) / 2.0),
|
||||
"center_y": float(node.get("y", 0)) + (float(node.get("h", 0)) / 2.0),
|
||||
}
|
||||
zones.append(zone)
|
||||
return zones
|
||||
|
||||
|
||||
def _bbox(nodes: list[dict]) -> dict | None:
|
||||
if not nodes:
|
||||
return None
|
||||
min_x = min(float(n.get("x", 0)) for n in nodes)
|
||||
min_y = min(float(n.get("y", 0)) for n in nodes)
|
||||
max_x = max(float(n.get("x", 0)) + float(n.get("w", 220)) for n in nodes)
|
||||
max_y = max(float(n.get("y", 0)) + float(n.get("h", 120)) for n in nodes)
|
||||
return {
|
||||
"min_x": min_x,
|
||||
"min_y": min_y,
|
||||
"max_x": max_x,
|
||||
"max_y": max_y,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"center_x": min_x + ((max_x - min_x) / 2.0),
|
||||
"center_y": min_y + ((max_y - min_y) / 2.0),
|
||||
}
|
||||
|
||||
|
||||
def _anchors(bbox: dict | None) -> dict:
|
||||
if not bbox:
|
||||
return {
|
||||
"right": [0, 0],
|
||||
"below": [0, 320],
|
||||
"center": [0, 0],
|
||||
}
|
||||
return {
|
||||
"right": [bbox["max_x"] + 320, bbox["min_y"]],
|
||||
"below": [bbox["min_x"], bbox["max_y"] + 220],
|
||||
"center": [bbox["center_x"], bbox["center_y"]],
|
||||
}
|
||||
|
||||
|
||||
def get_context(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ctx = _selected(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **ctx}
|
||||
|
||||
|
||||
def apply_relative(args: dict) -> dict:
|
||||
import importlib
|
||||
import apply_graph
|
||||
|
||||
importlib.reload(apply_graph)
|
||||
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ctx = _selected(bp)
|
||||
function_name = args.get("function") or ctx.get("focused_graph") or "EventGraph"
|
||||
target_graph = _graph(bp, function_name)
|
||||
if target_graph is None:
|
||||
return {"ok": False, "error": f"graph not found: {function_name!r}", "selection": ctx}
|
||||
|
||||
placement = args.get("placement") or "right"
|
||||
gap_x = float(args.get("gap_x", 0))
|
||||
gap_y = float(args.get("gap_y", 0))
|
||||
|
||||
zone_guid = args.get("zone_guid") or ""
|
||||
zone = None
|
||||
for candidate in ctx.get("draft_zones") or []:
|
||||
if zone_guid and candidate.get("guid") == zone_guid:
|
||||
zone = candidate
|
||||
break
|
||||
if zone is None and not ctx.get("selected") and ctx.get("draft_zones"):
|
||||
zone = ctx["draft_zones"][-1]
|
||||
|
||||
if zone is not None:
|
||||
bbox = zone["bbox"]
|
||||
zone_anchors = {
|
||||
"right": [bbox["min_x"] + 80, bbox["min_y"] + 80],
|
||||
"below": [bbox["min_x"] + 80, bbox["min_y"] + 80],
|
||||
"center": [bbox["center_x"], bbox["center_y"]],
|
||||
}
|
||||
anchor = zone_anchors.get(placement) or zone_anchors["right"]
|
||||
else:
|
||||
anchor = ctx["anchors"].get(placement)
|
||||
if anchor is None:
|
||||
anchor = ctx["anchors"]["right"]
|
||||
ax, ay = float(anchor[0]) + gap_x, float(anchor[1]) + gap_y
|
||||
|
||||
relative_nodes = []
|
||||
for spec in args.get("nodes") or []:
|
||||
n = dict(spec)
|
||||
x, y = n.get("position") or [0, 0]
|
||||
n["position"] = [float(x) + ax, float(y) + ay]
|
||||
relative_nodes.append(n)
|
||||
|
||||
nodes = graph_layout.normalize_specs(
|
||||
relative_nodes,
|
||||
args.get("edges") or [],
|
||||
domain="blueprint",
|
||||
) if args.get("auto_layout", True) else relative_nodes
|
||||
|
||||
deleted = []
|
||||
if args.get("delete_selection"):
|
||||
for n in ctx.get("selected") or []:
|
||||
guid = n.get("guid")
|
||||
if guid and _lib().delete_node(target_graph, guid):
|
||||
deleted.append(guid)
|
||||
|
||||
graph = {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": function_name,
|
||||
"mode": "append",
|
||||
"nodes": nodes,
|
||||
"edges": args.get("edges") or [],
|
||||
}
|
||||
result = apply_graph.apply(graph)
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"bp_path": args["bp_path"],
|
||||
"function": function_name,
|
||||
"placement": placement,
|
||||
"anchor": [ax, ay],
|
||||
"zone_guid": zone.get("guid") if zone else None,
|
||||
"deleted_selection": deleted,
|
||||
"selection": ctx,
|
||||
"applied": result,
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"get_context": get_context,
|
||||
"apply_relative": apply_relative,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-SELECTION] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
156
ue_side/validation.py
Normal file
156
ue_side/validation.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Asset validation, redirector cleanup, reference analysis.
|
||||
|
||||
Ops:
|
||||
validate_assets {paths:[]} — run EditorValidatorSubsystem
|
||||
validate_folder {path, recursive?}
|
||||
fix_redirectors {path} — recursively resolve UObjectRedirectors under folder
|
||||
get_references {asset_path} — assets THIS asset depends on
|
||||
get_referencers {asset_path} — assets that REFER TO this asset
|
||||
find_unused {folder, ignore_paths?:[]} — assets in folder with zero referencers
|
||||
disk_size {folder} — total .uasset size on disk
|
||||
list_redirectors {folder}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ar():
|
||||
return unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
|
||||
|
||||
def validate_assets(args):
|
||||
paths = args.get("paths") or []
|
||||
evs = unreal.get_editor_subsystem(unreal.EditorValidatorSubsystem)
|
||||
if evs is None:
|
||||
return {"ok": False, "error": "EditorValidatorSubsystem unavailable"}
|
||||
objs = [unreal.EditorAssetLibrary.load_asset(p) for p in paths]
|
||||
objs = [o for o in objs if o is not None]
|
||||
results = evs.validate_assets(objs, True, True) if hasattr(evs, "validate_assets") else None
|
||||
return {"ok": True, "validated_count": len(objs), "result": str(results) if results is not None else None}
|
||||
|
||||
|
||||
def validate_folder(args):
|
||||
folder = args["path"]
|
||||
recursive = bool(args.get("recursive", True))
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=recursive)
|
||||
assets = _ar().get_assets(filt)
|
||||
paths = [str(d.package_name) for d in assets]
|
||||
return validate_assets({"paths": paths})
|
||||
|
||||
|
||||
def fix_redirectors(args):
|
||||
folder = args["path"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
|
||||
redirs = _ar().get_assets(filt)
|
||||
if not redirs:
|
||||
return {"ok": True, "fixed": 0}
|
||||
objs = [d.get_asset() for d in redirs]
|
||||
objs = [o for o in objs if o is not None]
|
||||
try:
|
||||
unreal.AssetToolsHelpers.get_asset_tools().fixup_referencers(objs)
|
||||
return {"ok": True, "fixed": len(objs)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "found": len(redirs)}
|
||||
|
||||
|
||||
def get_references(args):
|
||||
deps = _ar().get_dependencies(unreal.Name(args["asset_path"]),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True))
|
||||
return {"ok": True, "dependencies": [str(d) for d in (deps or [])]}
|
||||
|
||||
|
||||
def get_referencers(args):
|
||||
refs = _ar().get_referencers(unreal.Name(args["asset_path"]),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True))
|
||||
return {"ok": True, "referencers": [str(d) for d in (refs or [])]}
|
||||
|
||||
|
||||
def find_unused(args):
|
||||
folder = args["folder"]
|
||||
ignore = set(args.get("ignore_paths") or [])
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True)
|
||||
out = []
|
||||
for d in _ar().get_assets(filt):
|
||||
pkg = str(d.package_name)
|
||||
if pkg in ignore or any(pkg.startswith(p) for p in ignore):
|
||||
continue
|
||||
refs = _ar().get_referencers(unreal.Name(pkg),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True)) or []
|
||||
if not refs:
|
||||
out.append({"path": pkg, "class": str(d.asset_class)})
|
||||
return {"ok": True, "count": len(out), "unused": out}
|
||||
|
||||
|
||||
def disk_size(args):
|
||||
folder = args["folder"]
|
||||
# Translate /Game -> Content path
|
||||
proj_content = unreal.Paths.project_content_dir()
|
||||
rel = folder.replace("/Game", "").lstrip("/")
|
||||
root = os.path.join(proj_content, rel)
|
||||
total = 0; files = 0
|
||||
for dirpath, _, fnames in os.walk(root):
|
||||
for f in fnames:
|
||||
if f.endswith((".uasset", ".umap")):
|
||||
total += os.path.getsize(os.path.join(dirpath, f))
|
||||
files += 1
|
||||
return {"ok": True, "folder": folder, "bytes": total, "files": files, "mb": round(total / 1024 / 1024, 2)}
|
||||
|
||||
|
||||
def list_redirectors(args):
|
||||
folder = args["folder"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
|
||||
out = [str(d.package_name) for d in _ar().get_assets(filt)]
|
||||
return {"ok": True, "count": len(out), "redirectors": out}
|
||||
|
||||
|
||||
OPS = {
|
||||
"validate_assets": validate_assets,
|
||||
"validate_folder": validate_folder,
|
||||
"fix_redirectors": fix_redirectors,
|
||||
"get_references": get_references,
|
||||
"get_referencers": get_referencers,
|
||||
"find_unused": find_unused,
|
||||
"disk_size": disk_size,
|
||||
"list_redirectors": list_redirectors,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
||||
unreal.log("[MCP-VALIDATE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
180
ue_side/variables.py
Normal file
180
ue_side/variables.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Blueprint member-variable authoring ops.
|
||||
|
||||
Wraps the (already-compiled) C++ helpers on unreal.MCPGraphLibrary:
|
||||
AddMemberVariable / SetVariableDefaultValue / SetVariableMetadata /
|
||||
RenameVariable / DeleteVariable / DescribeBlueprintJson.
|
||||
|
||||
Reachable from JS as the `variable_op` tool, and also merged into `asset_op`'s
|
||||
op-table for zero-restart availability (see assets.py).
|
||||
|
||||
Ops:
|
||||
add {bp_path, name, type, default?, instance_editable?, expose_on_spawn?,
|
||||
category?, tooltip?} — create a member variable, optionally set its
|
||||
default value + metadata. type is one of
|
||||
bool|int|float|double|string|name|text or a
|
||||
class path / short name for an object ref.
|
||||
set_default {bp_path, name, value}
|
||||
set_meta {bp_path, name, instance_editable?, expose_on_spawn?, category?, tooltip?}
|
||||
rename {bp_path, old, new}
|
||||
delete {bp_path, name}
|
||||
list {bp_path} — current member variables (name+type) as JSON.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _compile_save(bp):
|
||||
_lib().compile_and_save_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
|
||||
def _variables(bp) -> list:
|
||||
"""Return [{name,type,...}] from DescribeBlueprintJson."""
|
||||
try:
|
||||
info = json.loads(_lib().describe_blueprint_json(bp))
|
||||
return info.get("variables", [])
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def _has_var(bp, name: str) -> bool:
|
||||
return any(v.get("name") == name for v in _variables(bp))
|
||||
|
||||
|
||||
def add(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
name = args["name"]
|
||||
type_spec = args.get("type") or args.get("type_spec")
|
||||
if not name or not type_spec:
|
||||
return {"ok": False, "error": "add requires {name, type}"}
|
||||
if _has_var(bp, name):
|
||||
return {"ok": False, "error": f"variable {name!r} already exists", "exists": True}
|
||||
|
||||
ok = bool(_lib().add_member_variable(bp, name, type_spec))
|
||||
if not ok:
|
||||
return {"ok": False, "error": f"add_member_variable failed (bad type {type_spec!r}?)"}
|
||||
|
||||
# Optional default value + metadata in the same transaction.
|
||||
if "default" in args and args["default"] is not None:
|
||||
_lib().set_variable_default_value(bp, name, str(args["default"]))
|
||||
if any(k in args for k in ("instance_editable", "expose_on_spawn", "category", "tooltip")):
|
||||
_lib().set_variable_metadata(
|
||||
bp, name,
|
||||
bool(args.get("instance_editable", False)),
|
||||
bool(args.get("expose_on_spawn", False)),
|
||||
str(args.get("category", "")),
|
||||
str(args.get("tooltip", "")),
|
||||
)
|
||||
_compile_save(bp)
|
||||
return {"ok": True, "name": name, "type": type_spec}
|
||||
|
||||
|
||||
def set_default(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_variable_default_value(bp, args["name"], str(args.get("value", ""))))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def set_meta(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_variable_metadata(
|
||||
bp, args["name"],
|
||||
bool(args.get("instance_editable", False)),
|
||||
bool(args.get("expose_on_spawn", False)),
|
||||
str(args.get("category", "")),
|
||||
str(args.get("tooltip", "")),
|
||||
))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def rename(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().rename_variable(bp, args["old"], args["new"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "old": args["old"], "new": args["new"]}
|
||||
|
||||
|
||||
def delete(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().delete_variable(bp, args["name"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def list_vars(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
return {"ok": True, "variables": _variables(bp)}
|
||||
|
||||
|
||||
# op-table — also merged into assets.OPS for zero-restart availability.
|
||||
VAR_OPS = {
|
||||
"add_variable": add,
|
||||
"add": add,
|
||||
"set_var_default": set_default,
|
||||
"set_default": set_default,
|
||||
"set_var_meta": set_meta,
|
||||
"set_meta": set_meta,
|
||||
"rename_variable": rename,
|
||||
"rename": rename,
|
||||
"delete_variable": delete,
|
||||
"delete": delete,
|
||||
"list_variables": list_vars,
|
||||
"list": list_vars,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = VAR_OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": sorted(VAR_OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-VARS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
430
ue_side/voxel_graph.py
Normal file
430
ue_side/voxel_graph.py
Normal file
@ -0,0 +1,430 @@
|
||||
"""Voxel Graph MCP ops.
|
||||
|
||||
This worker mirrors the small, dependable graph-editing surface used by the
|
||||
Blueprint worker, but targets Voxel Plugin terminal graphs.
|
||||
|
||||
Ops:
|
||||
list_terminals {asset_path}
|
||||
read_graph {asset_path, terminal?}
|
||||
get_selection {asset_path, terminal?}
|
||||
get_context {asset_path, terminal?}
|
||||
list_node_types {asset_path?, filter?, limit?}
|
||||
spawn_node {asset_path, terminal?, node, x,y, compile?}
|
||||
create_graph {path}
|
||||
add_property {asset_path, terminal?, kind, name, type?, category?}
|
||||
list_properties {asset_path, terminal?, kind}
|
||||
spawn_property {asset_path, terminal?, kind, ref, x,y, declaration?, compile?}
|
||||
spawn_call {asset_path, terminal?, function, x,y, compile?}
|
||||
compile {asset_path, terminal?}
|
||||
spawn_comment {asset_path, terminal?, x,y,width,height,text?, color?}
|
||||
clear_zones {asset_path, terminal?}
|
||||
delete_node {asset_path, terminal?, guid}
|
||||
break_link {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
|
||||
break_all {asset_path, terminal?, guid}
|
||||
connect_pins {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
|
||||
set_pin_default {asset_path, terminal?, guid,pin,value}
|
||||
move_nodes {asset_path, terminal?, moves:[{guid,x,y}]}
|
||||
resize_comment {asset_path, terminal?, guid,width,height}
|
||||
set_comment {asset_path, terminal?, guid,text,bubble?}
|
||||
open_asset {asset_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _graph_lib():
|
||||
lib = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return lib
|
||||
|
||||
|
||||
def _voxel_lib():
|
||||
lib = getattr(unreal, "MCPVoxelGraphLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("MCPVoxelGraphLibrary missing - rebuild C++ plugin")
|
||||
return lib
|
||||
|
||||
|
||||
def _load_asset(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"asset not found: {path!r}")
|
||||
if not bool(_voxel_lib().is_voxel_graph_asset(asset)):
|
||||
raise RuntimeError(f"asset is not a UVoxelGraph: {path!r} ({asset.get_class().get_name()})")
|
||||
return asset
|
||||
|
||||
|
||||
def _terminal(args: dict) -> str:
|
||||
return str(args.get("terminal") or "main")
|
||||
|
||||
|
||||
def _ed_graph(asset, args: dict):
|
||||
graph = _voxel_lib().find_terminal_ed_graph(asset, _terminal(args))
|
||||
if graph is None:
|
||||
raise RuntimeError(f"terminal graph not found: {_terminal(args)!r}")
|
||||
return graph
|
||||
|
||||
|
||||
def _save(asset):
|
||||
try:
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _changed(asset, graph, compile_graph: bool = True):
|
||||
_voxel_lib().notify_graph_changed(graph, bool(compile_graph))
|
||||
_save(asset)
|
||||
|
||||
|
||||
def list_terminals(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().list_terminal_graphs_json(asset))
|
||||
|
||||
|
||||
def read_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().dump_terminal_graph_json(asset, _terminal(args)))
|
||||
|
||||
|
||||
def get_selection(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().get_selected_nodes_json(asset, _terminal(args)))
|
||||
|
||||
|
||||
def _bbox(nodes: list[dict]) -> dict | None:
|
||||
if not nodes:
|
||||
return None
|
||||
min_x = min(float(node.get("x", 0)) for node in nodes)
|
||||
min_y = min(float(node.get("y", 0)) for node in nodes)
|
||||
max_x = max(float(node.get("x", 0)) + float(node.get("w", 220)) for node in nodes)
|
||||
max_y = max(float(node.get("y", 0)) + float(node.get("h", 120)) for node in nodes)
|
||||
return {
|
||||
"min_x": min_x,
|
||||
"min_y": min_y,
|
||||
"max_x": max_x,
|
||||
"max_y": max_y,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"center_x": min_x + ((max_x - min_x) / 2.0),
|
||||
"center_y": min_y + ((max_y - min_y) / 2.0),
|
||||
}
|
||||
|
||||
|
||||
def _zones(nodes: list[dict]) -> list[dict]:
|
||||
out = []
|
||||
for node in nodes:
|
||||
if node.get("class") != "EdGraphNode_Comment":
|
||||
continue
|
||||
comment = str(node.get("comment") or node.get("title") or "").strip().lower()
|
||||
if comment not in ("mcp zone", "mcp draft zone"):
|
||||
continue
|
||||
zone = dict(node)
|
||||
zone["bbox"] = _bbox([node])
|
||||
out.append(zone)
|
||||
return out
|
||||
|
||||
|
||||
def get_context(args: dict) -> dict:
|
||||
graph_data = read_graph(args)
|
||||
selection_data = get_selection(args)
|
||||
selected = selection_data.get("selected") or []
|
||||
return {
|
||||
"ok": bool(graph_data.get("ok")),
|
||||
"asset": graph_data.get("asset"),
|
||||
"terminal": graph_data.get("terminal"),
|
||||
"nodes": graph_data.get("nodes") or [],
|
||||
"selected": selected,
|
||||
"selected_count": len(selected),
|
||||
"bbox": _bbox(selected),
|
||||
"zones": _zones(graph_data.get("nodes") or []),
|
||||
"selection_error": selection_data.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def list_node_types(args: dict) -> dict:
|
||||
# asset_path is optional here: the catalogue is global, but we keep the
|
||||
# signature consistent with the rest of the worker when one is supplied.
|
||||
flt = str(args.get("filter") or "")
|
||||
limit = int(args.get("limit") or 0)
|
||||
return json.loads(_voxel_lib().list_node_types_json(flt, limit))
|
||||
|
||||
|
||||
def spawn_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
node_id = str(args.get("node") or args.get("node_id") or "").strip()
|
||||
if not node_id:
|
||||
raise RuntimeError("spawn_node requires 'node' (id / display name)")
|
||||
guid = _voxel_lib().spawn_struct_node(
|
||||
graph,
|
||||
node_id,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "node": node_id, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def create_graph(args: dict) -> dict:
|
||||
path = str(args["path"]).rstrip("/")
|
||||
pkg_dir, _, name = path.rpartition("/")
|
||||
if not name or not pkg_dir:
|
||||
raise RuntimeError(f"bad path: {path!r} (expected e.g. /Game/MyGraph)")
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = tools.create_asset(name, pkg_dir, unreal.VoxelGraph, None)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"failed to create voxel graph at {path}")
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "asset": asset.get_path_name()}
|
||||
|
||||
|
||||
def add_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
result = json.loads(_voxel_lib().add_graph_property_json(
|
||||
asset,
|
||||
_terminal(args),
|
||||
str(args.get("kind") or "parameter"),
|
||||
str(args.get("name") or ""),
|
||||
str(args.get("type") or "float"),
|
||||
str(args.get("category") or ""),
|
||||
))
|
||||
if result.get("ok"):
|
||||
_save(asset)
|
||||
return result
|
||||
|
||||
|
||||
def list_properties(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().list_graph_properties_json(
|
||||
asset, _terminal(args), str(args.get("kind") or "parameter")))
|
||||
|
||||
|
||||
def spawn_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ref = str(args.get("ref") or args.get("name") or "").strip()
|
||||
if not ref:
|
||||
raise RuntimeError("spawn_property requires 'ref' (guid or name)")
|
||||
guid = _voxel_lib().spawn_property_node(
|
||||
graph,
|
||||
str(args.get("kind") or "parameter"),
|
||||
ref,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
bool(args.get("declaration", False)),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "kind": str(args.get("kind") or "parameter"), "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def spawn_call(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
func = str(args.get("function") or "").strip()
|
||||
if not func:
|
||||
raise RuntimeError("spawn_call requires 'function' (terminal guid or name)")
|
||||
guid = _voxel_lib().spawn_call_function_node(
|
||||
graph,
|
||||
func,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "function": func, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def compile_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
_changed(asset, graph, compile_graph=True)
|
||||
return {"ok": True, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def spawn_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
color = args.get("color") or [0.05, 0.45, 0.9, 0.35]
|
||||
while len(color) < 4:
|
||||
color.append(1.0)
|
||||
guid = _voxel_lib().spawn_comment_node(
|
||||
graph,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
unreal.Vector2D(float(args.get("width", 420)), float(args.get("height", 260))),
|
||||
str(args.get("text") or "MCP Zone"),
|
||||
unreal.LinearColor(float(color[0]), float(color[1]), float(color[2]), float(color[3])),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok, "guid": guid, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def clear_zones(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
data = read_graph(args)
|
||||
deleted = []
|
||||
for zone in _zones(data.get("nodes") or []):
|
||||
guid = zone.get("guid")
|
||||
if guid and _graph_lib().delete_node(graph, guid):
|
||||
deleted.append(guid)
|
||||
if deleted:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": True, "deleted": deleted, "deleted_count": len(deleted)}
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().delete_node(graph, args["guid"]))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok, "guid": args["guid"]}
|
||||
|
||||
|
||||
def break_link(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
from_guid, from_pin = args["from"]
|
||||
to_guid, to_pin = args["to"]
|
||||
ok = bool(_graph_lib().break_link(graph, from_guid, from_pin, to_guid, to_pin))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def break_all(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().break_all_node_links(graph, args["guid"]))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def connect_pins(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
from_guid, from_pin = args["from"]
|
||||
to_guid, to_pin = args["to"]
|
||||
ok = bool(_graph_lib().connect_pins(graph, from_guid, from_pin, to_guid, to_pin))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_pin_default(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().set_pin_default(graph, args["guid"], args["pin"], str(args.get("value", ""))))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
moves = args.get("moves") or []
|
||||
ids = [m["guid"] for m in moves]
|
||||
positions = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
|
||||
count = int(_graph_lib().move_nodes(graph, ids, positions))
|
||||
if count:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": count > 0, "moved": count}
|
||||
|
||||
|
||||
def resize_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().resize_comment_node(graph, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().set_node_comment(graph, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def open_asset(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return {"ok": bool(_voxel_lib().open_voxel_graph_asset(asset))}
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_terminals": list_terminals,
|
||||
"read_graph": read_graph,
|
||||
"get_selection": get_selection,
|
||||
"get_context": get_context,
|
||||
"list_node_types": list_node_types,
|
||||
"spawn_node": spawn_node,
|
||||
"create_graph": create_graph,
|
||||
"add_property": add_property,
|
||||
"list_properties": list_properties,
|
||||
"spawn_property": spawn_property,
|
||||
"spawn_call": spawn_call,
|
||||
"compile": compile_graph,
|
||||
"spawn_comment": spawn_comment,
|
||||
"clear_zones": clear_zones,
|
||||
"delete_node": delete_node,
|
||||
"break_link": break_link,
|
||||
"break_all": break_all,
|
||||
"connect_pins": connect_pins,
|
||||
"set_pin_default": set_pin_default,
|
||||
"move_nodes": move_nodes,
|
||||
"resize_comment": resize_comment,
|
||||
"set_comment": set_comment,
|
||||
"open_asset": open_asset,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {exc}"}, {})
|
||||
|
||||
fn = OPS.get(payload.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {payload.get('op')!r}", "available": list(OPS)}, payload)
|
||||
|
||||
try:
|
||||
return _tee(fn(payload), payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(exc), "traceback": traceback.format_exc()}, payload)
|
||||
|
||||
|
||||
def _tee(result: dict, payload: dict) -> str:
|
||||
text = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
saved_dir = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(saved_dir, exist_ok=True)
|
||||
rid = payload.get("__rid") or "last"
|
||||
with open(saved_dir + f"{rid}.json", "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
with open(saved_dir + "last.json", "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
unreal.log("[MCP-VOXEL-GRAPH] " + text[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return text
|
||||
451
ue_side/widget_tree.py
Normal file
451
ue_side/widget_tree.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""UMG Designer / WidgetTree ops.
|
||||
|
||||
Ops:
|
||||
describe {bp_path}
|
||||
get_props {bp_path, widget}
|
||||
get_slot_props {bp_path, widget}
|
||||
add_widget {bp_path, class, name?, parent?, index?, is_variable?, properties?, canvas_slot?}
|
||||
remove_widget {bp_path, widget}
|
||||
rename_widget {bp_path, widget, new_name}
|
||||
move_widget {bp_path, widget, parent?, index?}
|
||||
set_root {bp_path, widget}
|
||||
set_is_variable {bp_path, widget, is_variable}
|
||||
set_property {bp_path, widget, property, value}
|
||||
set_properties {bp_path, widget, properties}
|
||||
set_slot_property {bp_path, widget, property, value}
|
||||
set_canvas_slot {bp_path, widget, position?, size?, anchors_min?, anchors_max?, alignment?, auto_size?, z_order?}
|
||||
set_named_slot {bp_path, host, slot, content}
|
||||
clear_named_slot {bp_path, host, slot}
|
||||
set_desired_focus {bp_path, widget}
|
||||
apply_tree {bp_path, clear_existing?, widgets:[...], desired_focus?}
|
||||
compile_save {bp_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _load_widget_bp(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(asset, unreal.WidgetBlueprint):
|
||||
raise RuntimeError(f"not a WidgetBlueprint: {path!r}")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_widget_class(path_or_name: str):
|
||||
if not path_or_name:
|
||||
raise RuntimeError("widget class required")
|
||||
if "/" in path_or_name:
|
||||
cls = unreal.load_class(None, path_or_name)
|
||||
if cls:
|
||||
return cls
|
||||
for prefix in (
|
||||
"/Script/UMG.",
|
||||
"/Script/CommonUI.",
|
||||
"/Script/AdvancedWidgets.",
|
||||
"/Script/Engine.",
|
||||
):
|
||||
cls = unreal.load_class(None, prefix + path_or_name)
|
||||
if cls:
|
||||
return cls
|
||||
raise RuntimeError(f"widget class not found: {path_or_name!r}")
|
||||
|
||||
|
||||
def _vec2(value, default):
|
||||
if value is None:
|
||||
value = default
|
||||
return unreal.Vector2D(float(value[0]), float(value[1]))
|
||||
|
||||
|
||||
def _json(raw: str) -> dict:
|
||||
return json.loads(raw or "{}")
|
||||
|
||||
|
||||
def _save(bp):
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
|
||||
def describe(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
return {"ok": True, **_json(_lib().describe_widget_tree_json(bp))}
|
||||
|
||||
|
||||
def get_props(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_properties_json(bp, args["widget"]))}
|
||||
|
||||
|
||||
def get_slot_props(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_slot_properties_json(bp, args["widget"]))}
|
||||
|
||||
|
||||
def _set_props(bp, widget_name: str, props: dict) -> list[str]:
|
||||
warnings = []
|
||||
for key, value in (props or {}).items():
|
||||
ok = bool(_lib().set_widget_property(bp, widget_name, str(key), str(value)))
|
||||
if not ok:
|
||||
warnings.append(f"set_widget_property failed: {widget_name}.{key}")
|
||||
return warnings
|
||||
|
||||
|
||||
def _set_canvas_slot(bp, widget_name: str, slot: dict) -> bool:
|
||||
return bool(_lib().set_canvas_slot_layout(
|
||||
bp,
|
||||
widget_name,
|
||||
_vec2(slot.get("position"), (0, 0)),
|
||||
_vec2(slot.get("size"), (100, 40)),
|
||||
_vec2(slot.get("anchors_min"), (0, 0)),
|
||||
_vec2(slot.get("anchors_max"), (0, 0)),
|
||||
_vec2(slot.get("alignment"), (0, 0)),
|
||||
bool(slot.get("auto_size", False)),
|
||||
int(slot.get("z_order", 0)),
|
||||
))
|
||||
|
||||
|
||||
def add_widget(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
cls = _load_widget_class(args["class"])
|
||||
name = _lib().add_widget_to_tree(
|
||||
bp,
|
||||
cls,
|
||||
args.get("name") or "",
|
||||
args.get("parent") or "",
|
||||
int(args.get("index", -1)),
|
||||
bool(args.get("is_variable", True)),
|
||||
)
|
||||
if not name:
|
||||
return {"ok": False, "error": "add_widget_to_tree failed"}
|
||||
warnings = _set_props(bp, name, args.get("properties") or {})
|
||||
if args.get("canvas_slot") and not _set_canvas_slot(bp, name, args["canvas_slot"]):
|
||||
warnings.append(f"set_canvas_slot failed: {name}")
|
||||
_save(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], "widget": name, "warnings": warnings}
|
||||
|
||||
|
||||
def remove_widget(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().remove_widget_from_tree(bp, args["widget"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"]}
|
||||
|
||||
|
||||
def rename_widget(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().rename_widget_in_tree(bp, args["widget"], args["new_name"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"], "new_name": args["new_name"]}
|
||||
|
||||
|
||||
def move_widget(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().move_widget_in_tree(bp, args["widget"], args.get("parent") or "", int(args.get("index", -1))))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"], "parent": args.get("parent") or ""}
|
||||
|
||||
|
||||
def set_root(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_root_widget(bp, args["widget"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "root": args["widget"]}
|
||||
|
||||
|
||||
def set_is_variable(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_widget_is_variable(bp, args["widget"], bool(args.get("is_variable", True))))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"], "is_variable": bool(args.get("is_variable", True))}
|
||||
|
||||
|
||||
def set_property(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_widget_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
|
||||
|
||||
|
||||
def set_properties(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
warnings = _set_props(bp, args["widget"], args.get("properties") or {})
|
||||
_save(bp)
|
||||
return {"ok": not warnings, "widget": args["widget"], "warnings": warnings}
|
||||
|
||||
|
||||
def set_slot_property(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_widget_slot_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
|
||||
|
||||
|
||||
def set_canvas_slot(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = _set_canvas_slot(bp, args["widget"], args)
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"]}
|
||||
|
||||
|
||||
def set_named_slot(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_named_slot_content(bp, args["host"], args["slot"], args["content"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "host": args["host"], "slot": args["slot"], "content": args["content"]}
|
||||
|
||||
|
||||
def clear_named_slot(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().clear_named_slot_content(bp, args["host"], args["slot"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "host": args["host"], "slot": args["slot"]}
|
||||
|
||||
|
||||
def set_desired_focus(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_desired_focus_widget(bp, args["widget"]))
|
||||
_save(bp)
|
||||
return {"ok": ok, "widget": args["widget"]}
|
||||
|
||||
|
||||
def apply_tree(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
warnings = []
|
||||
created = []
|
||||
if args.get("clear_existing"):
|
||||
tree = _json(_lib().describe_widget_tree_json(bp))
|
||||
root = tree.get("root")
|
||||
if root:
|
||||
_lib().remove_widget_from_tree(bp, root)
|
||||
|
||||
for spec in args.get("widgets") or []:
|
||||
cls = _load_widget_class(spec["class"])
|
||||
name = _lib().add_widget_to_tree(
|
||||
bp,
|
||||
cls,
|
||||
spec.get("name") or "",
|
||||
spec.get("parent") or "",
|
||||
int(spec.get("index", -1)),
|
||||
bool(spec.get("is_variable", True)),
|
||||
)
|
||||
if not name:
|
||||
warnings.append(f"add failed: {spec.get('name') or spec.get('class')}")
|
||||
continue
|
||||
created.append(name)
|
||||
warnings.extend(_set_props(bp, name, spec.get("properties") or {}))
|
||||
if spec.get("canvas_slot") and not _set_canvas_slot(bp, name, spec["canvas_slot"]):
|
||||
warnings.append(f"set_canvas_slot failed: {name}")
|
||||
|
||||
if args.get("desired_focus"):
|
||||
if not _lib().set_desired_focus_widget(bp, args["desired_focus"]):
|
||||
warnings.append(f"set_desired_focus failed: {args['desired_focus']}")
|
||||
|
||||
_save(bp)
|
||||
return {"ok": not warnings, "created": created, "warnings": warnings, "tree": _json(_lib().describe_widget_tree_json(bp))}
|
||||
|
||||
|
||||
def compile_save(args: dict) -> dict:
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
_save(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"]}
|
||||
|
||||
|
||||
def create_animation(args: dict) -> dict:
|
||||
"""Create a UMG WidgetAnimation that keys one float property on a widget.
|
||||
|
||||
{bp_path, name, widget, property?='RenderOpacity', times?, values?}
|
||||
times/values are parallel lists (seconds / value). Omit both for a default
|
||||
blink pulse (1.0 -> 0.12 -> 1.0 over 0.9s). Loop is set at PlayAnimation time
|
||||
(NumLoopsToPlay=0), not stored on the animation.
|
||||
"""
|
||||
bp = _load_widget_bp(args["bp_path"])
|
||||
name = args["name"]
|
||||
widget = args["widget"]
|
||||
prop = args.get("property") or "RenderOpacity"
|
||||
times = args.get("times")
|
||||
values = args.get("values")
|
||||
if isinstance(times, str):
|
||||
times = json.loads(times)
|
||||
if isinstance(values, str):
|
||||
values = json.loads(values)
|
||||
if not times or not values:
|
||||
times = [0.0, 0.45, 0.9]
|
||||
values = [1.0, 0.12, 1.0]
|
||||
if len(times) != len(values):
|
||||
return {"ok": False, "error": "times and values must be the same length"}
|
||||
times = [float(t) for t in times]
|
||||
values = [float(v) for v in values]
|
||||
ok = bool(_lib().create_widget_float_animation(bp, name, widget, prop, times, values))
|
||||
if ok:
|
||||
_save(bp)
|
||||
return {"ok": ok, "name": name, "widget": widget, "property": prop,
|
||||
"keys": list(zip(times, values))}
|
||||
|
||||
|
||||
def screenshot(args: dict) -> dict:
|
||||
"""Render a WidgetBlueprint to a PNG file. Vision loop for AI iteration.
|
||||
|
||||
Strategy stack (first that works wins):
|
||||
1) unreal.WidgetRenderer -> RenderTarget -> ExportRenderTarget (clean, widget-only)
|
||||
2) Open the WBP in the UMG editor and use HighResShot (captures editor chrome)
|
||||
|
||||
Args:
|
||||
bp_path: /Game/.../WBP_Foo
|
||||
size: [w, h] default [1920, 1080]
|
||||
out_dir: project-relative folder under Saved/ default 'MCP/widgets'
|
||||
Returns:
|
||||
{ ok, png_path, file_url, strategy }
|
||||
"""
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_widget_bp(bp_path)
|
||||
if bp is None:
|
||||
return {"ok": False, "error": "widget bp not found"}
|
||||
size = args.get("size") or [1920, 1080]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
proj_saved = unreal.Paths.project_saved_dir()
|
||||
out_subdir = (args.get("out_dir") or "MCP/widgets").strip("/")
|
||||
out_dir = proj_saved + out_subdir + "/"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
safe_name = bp_path.rsplit("/", 1)[-1]
|
||||
png_name = f"{safe_name}.png"
|
||||
|
||||
# ---- Strategy 0: native C++ helper (best — pure widget, no editor chrome) ----
|
||||
if hasattr(unreal, "MCPWidgetRenderLibrary"):
|
||||
try:
|
||||
widget_class = bp.generated_class()
|
||||
ok, full_path, err = unreal.MCPWidgetRenderLibrary.render_widget_to_png(
|
||||
widget_class, out_dir.rstrip("/").replace("/", os.sep), safe_name, w, h
|
||||
)
|
||||
if ok:
|
||||
return {"ok": True, "strategy": "MCPWidgetRenderLibrary",
|
||||
"png_path": full_path,
|
||||
"file_url": "file:///" + full_path.replace("\\", "/"),
|
||||
"size": [w, h]}
|
||||
cpp_err = err or "unknown C++ error"
|
||||
except Exception as e:
|
||||
cpp_err = str(e)
|
||||
else:
|
||||
cpp_err = "MCPWidgetRenderLibrary not exposed — Live-Coding compile the plugin"
|
||||
|
||||
# ---- Strategy 1: WidgetRenderer (if exposed) ----
|
||||
if hasattr(unreal, "WidgetRenderer"):
|
||||
try:
|
||||
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
|
||||
widget_class = bp.generated_class()
|
||||
instance = unreal.create_widget(world, widget_class) if world else None
|
||||
if instance is None:
|
||||
raise RuntimeError("create_widget returned None")
|
||||
# Transient render target
|
||||
rt_factory = getattr(unreal, "TextureRenderTargetFactoryNew", None)
|
||||
if rt_factory is None:
|
||||
raise RuntimeError("TextureRenderTargetFactoryNew unavailable")
|
||||
rt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
f"RT_MCP_{safe_name}", "/Game/_MCPTransient", unreal.TextureRenderTarget2D, rt_factory()
|
||||
)
|
||||
rt.set_editor_property("size_x", w)
|
||||
rt.set_editor_property("size_y", h)
|
||||
rt.set_editor_property("render_target_format", unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
||||
renderer = unreal.WidgetRenderer()
|
||||
renderer.draw_widget(instance, rt, 0.016)
|
||||
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, png_name)
|
||||
# Cleanup transient RT (best effort)
|
||||
try:
|
||||
unreal.EditorAssetLibrary.delete_asset(rt.get_path_name())
|
||||
except Exception:
|
||||
pass
|
||||
full = out_dir + png_name
|
||||
return {"ok": True, "strategy": "WidgetRenderer", "png_path": full,
|
||||
"file_url": "file:///" + full.replace("\\", "/"),
|
||||
"size": [w, h]}
|
||||
except Exception as e:
|
||||
renderer_err = str(e)
|
||||
else:
|
||||
renderer_err = "WidgetRenderer not exposed in this UE build"
|
||||
|
||||
# ---- Strategy 2: open in UMG editor + HighResShot ----
|
||||
try:
|
||||
aes = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
|
||||
aes.open_editor_for_assets([bp])
|
||||
# Force a screenshot of the editor window
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
cmd = f"HighResShot {w}x{h} {png_name}"
|
||||
unreal.SystemLibrary.execute_console_command(world, cmd)
|
||||
# UE saves under Saved/Screenshots/Windows/<png_name>
|
||||
guessed = proj_saved + f"Screenshots/Windows/{png_name}"
|
||||
return {"ok": True, "strategy": "HighResShot_editor_chrome",
|
||||
"warning": f"widget-only render failed: {renderer_err}. Editor chrome included.",
|
||||
"png_path": guessed,
|
||||
"file_url": "file:///" + guessed.replace("\\", "/"),
|
||||
"size": [w, h]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"all strategies failed; cpp={cpp_err}; renderer={renderer_err}; screenshot={e}"}
|
||||
|
||||
|
||||
OPS = {
|
||||
"describe": describe,
|
||||
"get_props": get_props,
|
||||
"get_slot_props": get_slot_props,
|
||||
"add_widget": add_widget,
|
||||
"remove_widget": remove_widget,
|
||||
"rename_widget": rename_widget,
|
||||
"move_widget": move_widget,
|
||||
"set_root": set_root,
|
||||
"set_is_variable": set_is_variable,
|
||||
"set_property": set_property,
|
||||
"set_properties": set_properties,
|
||||
"set_slot_property": set_slot_property,
|
||||
"set_canvas_slot": set_canvas_slot,
|
||||
"set_named_slot": set_named_slot,
|
||||
"clear_named_slot": clear_named_slot,
|
||||
"set_desired_focus": set_desired_focus,
|
||||
"apply_tree": apply_tree,
|
||||
"compile_save": compile_save,
|
||||
"create_animation": create_animation,
|
||||
"screenshot": screenshot,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result: dict, p: dict) -> str:
|
||||
txt = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(sd, exist_ok=True)
|
||||
rid = p.get("__rid") or "last"
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
if rid != "last":
|
||||
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-WIDGET] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
Reference in New Issue
Block a user