From 3d6f6d72101e5e0bb600e28f1ac5626b9cd59316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9D=ED=9B=88?= <123369lee@naver.com> Date: Wed, 5 Aug 2026 02:14:50 +0900 Subject: [PATCH] feat(input): add read-only Enhanced Input asset inspection --- CHANGELOG.md | 4 + Docs/API_REFERENCE.md | 23 +- Docs/SPEC_CORE.md | 5 +- Docs/specs/SPEC_MonolithGAS.md | 28 +- ...6-08-04-enhanced-input-asset-inspection.md | 93 ++ Skills/unreal-input/SKILL.md | 93 ++ Source/MonolithGAS/MonolithGAS.Build.cs | 2 + .../Private/MonolithGASInputAssetActions.cpp | 1084 +++++++++++++++++ .../MonolithGAS/Private/MonolithGASModule.cpp | 21 +- .../MonolithGASInputAssetActionsTests.cpp | 492 ++++++++ .../Public/MonolithGASInputAssetActions.h | 24 + 11 files changed, 1859 insertions(+), 10 deletions(-) create mode 100644 Docs/testing/2026-08-04-enhanced-input-asset-inspection.md create mode 100644 Skills/unreal-input/SKILL.md create mode 100644 Source/MonolithGAS/Private/MonolithGASInputAssetActions.cpp create mode 100644 Source/MonolithGAS/Private/Tests/MonolithGASInputAssetActionsTests.cpp create mode 100644 Source/MonolithGAS/Public/MonolithGASInputAssetActions.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a16c6a76..9618ed43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Enhanced Input assets now have a bounded read-only preflight surface.** Five `input` actions list and inspect `UInputAction` / `UInputMappingContext` assets and validate missing actions, invalid keys, duplicate-key warnings, and scan completeness. Canonical path checks, stable pagination, independent mapping/instanced-object caps, and explicit truncation prevent guessed paths or silently partial results; the namespace stays available when GAS authoring is disabled and never compiles, saves, mutates, transacts, or dirties an asset. + ## [0.22.0] - 2026-08-01 ### Internal diff --git a/Docs/API_REFERENCE.md b/Docs/API_REFERENCE.md index 3ca1aee66..6391dd676 100644 --- a/Docs/API_REFERENCE.md +++ b/Docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # Monolith API Reference -**Version:** v0.22.0 · **Last updated:** 2026-08-01 +**Version:** v0.22.0 · **Last updated:** 2026-08-04 **In-tree action total is approximate: ~1,400+ actions across 25+ in-tree namespaces** (public, in-tree only; all active by default, plus 45 experimental town-gen actions that register only when `bEnableProceduralTownGen=true`). The surface is too large to track to the unit — **query `monolith_discover()` (its `total_actions` field) for the exact live figure.** The `ui` namespace re-exports 4 GAS UI binding actions as aliases. v0.19.0 adds an LLM C++ authoring ergonomics pack (`source`, 8 actions + `editor.get_build_errors` fix hints), live-PIE introspection + driving and stat-group readout (`editor`), anim-node binding read/write and time-series PIE sampling (`animation`), a Blueprint variable census + contract reconciliation (`blueprint`), and T3D asset-text export (`project`); plus two first-launch fixes (issue #70) and a ~40% smaller `tools/list` manifest. The `monolith_*` meta-tools (`discover`, `status`, `update`, `reindex`, `guide`) plus the `bulk_fill_query` and `describe_query` framework dispatchers round out the MCP tool count. This total EXCLUDES sibling-plugin actions — they ship in their own repos and are never in the public release zip. @@ -30,6 +30,7 @@ The per-namespace numbers in the Table of Contents and body sections below are k | [mesh](#mesh) | 194 | Mesh inspection, scene manipulation, spatial queries, blockout, GeometryScript, procedural geo, lighting, audio, performance, mesh import (incl. skeletal + animation). +45 town gen registers only with `bEnableProceduralTownGen=true` (experimental, not in the public count) | | [ui](#ui) | 138 | UMG widget CRUD, templates, styling, animation v1+v2, EffectSurface, Spec Builder, Type Registry, settings scaffolding, headline scaffolders, navigation/conversion gap-closure, accessibility, CommonUI, GAS UI bindings | | [gas](#gas) | 135 | Gameplay Ability System: abilities, attributes, effects, ASC, tags, cues, targeting, input, inspect, scaffold | +| [input](#input) | 5 | Read-only Enhanced Input Action and Mapping Context discovery, bounded inspection, and validation | | [combograph](#combograph) | 13 | ComboGraph melee combo authoring (conditional on `WITH_COMBOGRAPH`) | | [ai](#ai) | 221 | Behavior Trees, State Trees, EQS, Blackboards, AI Controllers, Perception, Smart Objects, Navigation, Mass, Zone Graph, runtime PIE inspection, scaffolds | | [logicdriver](#logicdriver) | 66 | Logic Driver Pro state machines: graph CRUD, runtime PIE control, scaffolds, dialogue (conditional on `WITH_LOGICDRIVER`) | @@ -987,6 +988,26 @@ See `Plugins/Monolith/Docs/specs/SPEC_MonolithGAS.md` for the deep dive. --- +## input + +Read-only Enhanced Input asset discovery, bounded inspection, and validation. The namespace is registered by `MonolithGAS` but remains available when GAS authoring is disabled. Its dispatcher is advertised as read-only and idempotent. + +All asset inputs require canonical mounted package paths or matching top-level object paths. List and mapping responses expose explicit pagination; trigger/modifier arrays and validation scans expose explicit truncation/completeness rather than silently dropping data. + +| Action | Parameters | Returns | +|---|---|---| +| `list_input_actions` | `path=/Game`, `offset=0`, `limit=200` (1–1000), `include_details=false` | Stable Input Action page with `total`, `count`, and `has_more`; optional bounded detail rows | +| `get_input_action` | `asset_path` | Value type, description, behavior flags, accumulation policy, and bounded trigger/modifier class arrays | +| `list_input_mapping_contexts` | `path=/Game`, `offset=0`, `limit=200` (1–1000), `include_details=false`, `mapping_limit=100` (1–500) | Stable context page; optional details load only returned contexts and cap mappings per context | +| `get_input_mapping_context` | `asset_path`, `mapping_offset=0`, `mapping_limit=100` (1–500) | Context metadata and a bounded stable mapping page | +| `validate_input_mappings` | `context_paths` or `path=/Game`, `offset=0`, `limit=200` (1–1000), `mapping_scan_limit=4096` (1–10000) | Missing-action/invalid-key errors, duplicate-key warnings, and per-context/global `valid` + `complete` evidence | + +`context_paths` and `path` are mutually exclusive. Duplicate-key assignments are warnings because they may be deliberate Enhanced Input layouts; missing actions, invalid keys, load failures, or a mapping scan cutoff are errors. `page_complete` covers mapping scans in the returned context page, while `all_contexts_covered` covers context pagination. Global `complete` requires both, and `valid=true` additionally requires zero errors. + +See `Plugins/Monolith/Skills/unreal-input/SKILL.md` for routing and bounded workflow examples. + +--- + ## combograph ComboGraph melee combo authoring. **13 actions.** **Conditional on `#if WITH_COMBOGRAPH`** — requires the ComboGraph marketplace plugin. Reflection-only (no direct C++ API linkage). diff --git a/Docs/SPEC_CORE.md b/Docs/SPEC_CORE.md index 31be36a3d..ffefc71dd 100644 --- a/Docs/SPEC_CORE.md +++ b/Docs/SPEC_CORE.md @@ -125,6 +125,8 @@ All three sources emit free-text strings into the same array. No schema or envel - GeometryScripting (optional — enables Tier 5 mesh operations) - GameplayAbilities (optional — enables MonolithGAS module; `#if WITH_GBA` compile guard) +`MonolithGAS` also owns the always-available Enhanced Input inspection surface. Its direct engine-module dependencies are `AssetRegistry`, `EnhancedInput`, and `InputCore`; these five read-only `input` actions register before the GAS settings gate and do not depend on Blueprint Attributes. + ### Optional-Dependency Detection Matrix Modules that probe for optional plugins follow a unified Build.cs convention: 3-location detection (project Plugins/, engine Plugins/Marketplace/, engine Plugins/Runtime/) with `MONOLITH_RELEASE_BUILD=1` env-var escape hatch. When the env var is `"1"`, detection short-circuits and the corresponding `WITH_*` define is forced off — the released DLL drops the hard import and Blueprint-only users without the dep don't hit `GetLastError=126` at module load. @@ -160,7 +162,7 @@ Each module has its own spec file under `specs/`. The table below is the index. | 3.10 | MonolithUI | [specs/SPEC_MonolithUI.md](specs/SPEC_MonolithUI.md) | Widget blueprint CRUD, slot/template/styling, animation v1+v2, bindings, settings/accessibility scaffolds, **Spec Builder + Type Registry + EffectSurface + Style Service** (Phase A–L expansion 2026-04-26), CommonUI categories A–I. **~130+ module-owned actions** (always-on + CommonUI under `WITH_COMMONUI`) + 4 GAS UI binding aliases | | 3.11 | MonolithMesh | [specs/SPEC_MonolithMesh.md](specs/SPEC_MonolithMesh.md) | Mesh/scene/spatial/blockout/GeometryScript/procedural (~190+ core + 45 experimental town gen) | | 3.12 | MonolithBABridge | [specs/SPEC_MonolithBABridge.md](specs/SPEC_MonolithBABridge.md) | IModularFeatures bridge for Blueprint Assist (0 MCP actions, integration only) | -| 3.13 | MonolithGAS | [specs/SPEC_MonolithGAS.md](specs/SPEC_MonolithGAS.md) | Gameplay Ability System integration (~130+ actions, incl. 4 UI binding aliased into `ui::`, WITH_GBA) | +| 3.13 | MonolithGAS | [specs/SPEC_MonolithGAS.md](specs/SPEC_MonolithGAS.md) | Gameplay Ability System integration (~130+ `gas` actions, incl. 4 UI binding aliases) plus 5 always-registered read-only Enhanced Input asset inspection actions under `input` | | 3.14 | MonolithComboGraph | [specs/SPEC_MonolithComboGraph.md](specs/SPEC_MonolithComboGraph.md) | ComboGraph integration (~13 actions, WITH_COMBOGRAPH) | | 3.15 | MonolithLogicDriver | [specs/SPEC_MonolithLogicDriver.md](specs/SPEC_MonolithLogicDriver.md) | Logic Driver Pro integration (~66 actions, WITH_LOGICDRIVER) | | 3.16 | MonolithAI | [specs/SPEC_MonolithAI.md](specs/SPEC_MonolithAI.md) | Behavior Trees, Blackboards, State Trees, EQS, Smart Objects, Perception, Nav (~220+ actions) | @@ -585,6 +587,7 @@ Counts below were re-verified against the live `monolith_discover()` registry on | MonolithSource | source | ~20 | 11 baseline + 1 v0.17.0 Phase 2 audit action `audit_module_dep_reality` registered cross-module from `MonolithReflectionIntel` (catches UPROPERTY / API-symbol references whose owning module is missing from the declaring module's `Build.cs` deps; the audit handler lives in `MonolithReflectionIntel`, registration onto `source` is for caller ergonomics — agents already discover `source_query` first) + 8 ([Unreleased]) LLM C++ authoring-ergonomics actions across Phases 1-3 (`get_include_path`, `get_signature`, `check_deprecations` — Phase 1; `verify_symbols`, `find_example_usage` — Phase 2; `lint_header`, `generate_class_stub` — Phase 3; plus `suggest_build_cs_deps` registered cross-module from `MonolithReflectionIntel`). See [`specs/SPEC_MonolithSource.md`](specs/SPEC_MonolithSource.md). | | MonolithUI | ui | 134 module-owned (79 always-on + 55 CommonUI under `WITH_COMMONUI`) + 4 GAS UI binding aliases (registered from `MonolithGAS`, conditional on `WITH_GBA`) = **138** distinct registrations into `ui::` in the full-stack configuration | Architecture expansion Phase A–L landed 2026-04-26 (see prior entry). v0.14.11 [Unreleased] Tier 1/2/3/4 close-the-loop ergonomics layered on top: Tier 1 fixes (hash-cache mis-keying, CommonUI allowlist additions, `create_bound_action_bar.action_button_class` param, `compile_widget` `errors[]`/`warnings[]` surface, `set_widget_property` `value` alias). Tier 2 +8 actions: `rename_widget`, `add_widget_variable`, `audit_focus_chain`, `apply_token_binding` (MVP-STUB — issue #2-10b for full BP-graph node-write completion), `list_widget_property_enums`, `convert_textblock_to_common`, `set_action_bar_button_class`, `dump_blueprint_compile_log` (gains `BlueprintGraph` + `Projects` deps in `MonolithUI.Build.cs`). Tier 3 +4 scaffolders: `scaffold_main_menu`, `scaffold_settings_panel_with_tabs`, `scaffold_pause_menu`, `build_menu_from_spec` (MVP — issue #3-18b for multi-screen aggregation). Tier 4 polish: `convert_button_to_common` Tokenforge auto-detect, `-32011` Tokenforge Provider Absence error contract parity with `-32010`, new CommonUI Property Allowlist Coverage section in [`specs/SPEC_MonolithUI.md`](specs/SPEC_MonolithUI.md). The 4 GAS aliases (`bind_widget_to_attribute`, `unbind_widget_attribute`, `list_attribute_bindings`, `clear_widget_attribute_bindings`) come from `MonolithGAS/Private/MonolithGASUIBindingActions.cpp` and dispatch to the same handlers as their canonical `gas::` versions. Phase 3 (2026-05-23) of [`Docs/plans/2026-05-22-monolith-ui-bp-gap-actions.md`](plans/2026-05-22-monolith-ui-bp-gap-actions.md) added 4 CommonUI-gated actions — `set_widget_navigation_bulk`, `dump_widget_navigation`, `convert_border_to_common`, `reparent_widget_root` — bumping CommonUI 51 → 55, module-owned 129 → 133, full-stack 133 → 137. Phase 4 (2026-05-23) added 1 always-on action — `set_widget_is_variable` — bumping always-on 78 → 79, module-owned 133 → 134, full-stack 137 → 138 (CommonUI subtotal unchanged) | | MonolithGAS | gas | 135 | 131 documented in the Action Categories table + 4 UI binding actions (`bind_widget_to_attribute`, `unbind_widget_attribute`, `list_attribute_bindings`, `clear_widget_attribute_bindings`) — the latter four are also aliased into `ui` | +| MonolithGAS | input | 5 | Read-only Enhanced Input asset preflight: stable bounded `UInputAction` / `UInputMappingContext` discovery and readback plus mapping validation. Direct `AssetRegistry`, `EnhancedInput`, and `InputCore` dependencies; registered independently of `bEnableGAS`; canonical paths only; explicit pagination, truncation, and validation completeness. | | MonolithComboGraph | combograph | 13 | | | MonolithAI | ai | 223 | Phase J F8 added `add_perception_to_actor` and `get_bt_graph` — pre-J baseline was 219, not the previously documented 229 (per-category `~N` estimates in SPEC_MonolithAI were aspirational, not literal) + 2 test/profiling harness Wave 1 (`rebuild_navigation`, `validate_nav_points`). Motion Matching AI-wander runtime surface (2026-06-07, **no MCP-action delta**): 3 `UBTTaskNode` classes (`BTTask_SetMaxWalkSpeed`, `BTTask_SetCrouch`, `BTTask_RandomizeFloat` — placed via `add_bt_node`) + 1 Blueprintable `AAIController` (`AMonolithBehaviorTreeAIController`, runs its BT `UPROPERTY` on `OnPossess`). Fixes: `reorder_bt_children` order now persists (`NodePosX`-based); `build_behavior_tree_from_spec` now links `UBehaviorTree::BlackboardAsset`. See [`specs/SPEC_MonolithAI.md`](specs/SPEC_MonolithAI.md) § Motion Matching AI-wander runtime surface | | MonolithLogicDriver | logicdriver | 66 | | diff --git a/Docs/specs/SPEC_MonolithGAS.md b/Docs/specs/SPEC_MonolithGAS.md index 41b579a2e..957c22ca4 100644 --- a/Docs/specs/SPEC_MonolithGAS.md +++ b/Docs/specs/SPEC_MonolithGAS.md @@ -8,12 +8,12 @@ ## MonolithGAS -**Dependencies:** Core, CoreUObject, Engine, MonolithCore, GameplayAbilities, GameplayTags -**Namespace:** `gas` (135 actions) + 4 cross-namespace aliases into `ui` | **Tool:** `gas_query(action, params)` | **Actions:** 135 (Phase J F8: +`grant_ability_to_pawn`) -**Conditional:** GBA (Blueprint Attributes) features wrapped in `#if WITH_GBA`. Core GAS engine modules (GameplayAbilities, GameplayTags, GameplayTasks) are always available. When GBA is absent, Blueprint AttributeSet creation is disabled but all 135 actions still register and compile cleanly. When `bEnableGAS` is disabled in settings, 0 actions registered. +**Dependencies:** Core, CoreUObject, Engine, MonolithCore, AssetRegistry, EnhancedInput, InputCore, GameplayAbilities, GameplayTags +**Namespaces:** `gas` (135 actions) + 4 cross-namespace aliases into `ui`; `input` (5 read-only actions) | **Tools:** `gas_query(action, params)`, `input_query(action, params)` +**Conditional:** GBA (Blueprint Attributes) features are wrapped in `#if WITH_GBA`. Core GAS engine modules remain available. When GBA is absent, Blueprint AttributeSet creation is disabled but the `gas` schemas still compile cleanly. When `bEnableGAS` is disabled, GAS authoring actions are not registered; the five Enhanced Input asset inspection actions remain registered because they do not depend on GAS authoring. **Settings toggle:** `bEnableGAS` (default: True) -MonolithGAS provides full MCP coverage of the Gameplay Ability System. It covers ability CRUD, attribute set management, gameplay effect authoring, ASC (Ability System Component) inspection and manipulation, gameplay tag operations, gameplay cue management, target data, input binding, runtime inspection, scaffolding of common GAS patterns, and Widget→Attribute binding via class-extension authoring. +MonolithGAS provides full MCP coverage of the Gameplay Ability System. It covers ability CRUD, attribute set management, gameplay effect authoring, ASC (Ability System Component) inspection and manipulation, gameplay tag operations, gameplay cue management, target data, input binding, runtime inspection, scaffolding of common GAS patterns, and Widget→Attribute binding via class-extension authoring. A separate `input` namespace owns read-only Enhanced Input asset preflight so IA/IMC inspection is not coupled to the GAS settings toggle. ### Action Categories @@ -56,6 +56,26 @@ See [SPEC_CORE.md §11 Recent Fixes](../SPEC_CORE.md#recent-fixes-phase-j--shipp --- +### Enhanced Input Asset Inspection (`input`, 5 actions) + +`FMonolithGASInputAssetActions` registers five read-only actions before the GAS settings gate. The namespace uses direct `AssetRegistry`, `EnhancedInput`, and `InputCore` dependencies and advertises `readOnlyHint=true` plus `idempotentHint=true`. + +| Action | Contract | +|---|---| +| `list_input_actions` | Stable AssetRegistry page under a canonical package root. `offset` is non-negative; `limit` is 1–1000. `include_details` loads only the returned page. | +| `get_input_action` | Exact type-checked `UInputAction` readback: value type, description, behavior flags, accumulation, player-mappable presence, and trigger/modifier class identities. Each instanced-object array is capped at 256 with explicit count/truncation fields. | +| `list_input_mapping_contexts` | Stable context page with the same asset bounds. Optional details cap each context to `mapping_limit` 1–500. | +| `get_input_mapping_context` | Exact type-checked context readback with independent `mapping_offset` and `mapping_limit` pagination. Each mapping's trigger/modifier array is capped at 64 with explicit metadata. | +| `validate_input_mappings` | Validates a bounded context page and at most `mapping_scan_limit` 1–10000 mappings per context. Missing actions, invalid keys, load failures, and scan cutoffs are errors. Multiple actions on one key are warnings because that layout can be intentional. | + +Asset parameters accept only canonical mounted package paths or matching top-level object paths. Filesystem paths, subobjects, whitespace aliases, and mismatched object leaves return `-32602`; no alternate asset, widened search, or path guess is used. `context_paths` and `path` are mutually exclusive, and an explicit empty or duplicate context list is rejected. + +Every list result reports `total`, `offset`, `limit`, `count`, and `has_more`. Mapping pages report their own offsets/counts/truncation. Validation reports `page_complete` for mapping traversal and `all_contexts_covered` for context pagination; global `complete` requires both, and `valid=true` additionally requires zero errors. None of these handlers calls `Modify`, opens a transaction, saves, compiles, or dirties a package. + +Focused verification lives in `Docs/testing/2026-08-04-enhanced-input-asset-inspection.md`; routing guidance lives in `Skills/unreal-input/SKILL.md`. + +--- + ### Bulk Fill & Describe Surface (2026-05-11) The `gas` namespace registers a `FMonolithBulkFillRegistry` adapter (`MonolithGASBulkFillAdapter.cpp`) routed from the central `bulk_fill_query("apply")` and `describe_query("schema")` dispatchers. Phase 2 of the MCP ergonomics rollout (design spec `Docs/plans/2026-05-11-monolith-mcp-ergonomics-design.md`, implementation plan `Docs/plans/2026-05-11-monolith-mcp-ergonomics.md`). This collapses the 20-attr × 10-level ≈ 200-call grind on AttributeInit DataTables into a single transacted call. diff --git a/Docs/testing/2026-08-04-enhanced-input-asset-inspection.md b/Docs/testing/2026-08-04-enhanced-input-asset-inspection.md new file mode 100644 index 000000000..5e4651304 --- /dev/null +++ b/Docs/testing/2026-08-04-enhanced-input-asset-inspection.md @@ -0,0 +1,93 @@ +# Enhanced Input Asset Inspection Verification + +**Date:** 2026-08-04 +**Scope:** Five read-only `input` actions in `MonolithGAS` +**Engines:** Unreal Engine 5.7 and 5.8 +**Status:** Pass + +--- + +## 1. Goal + +Provide reusable Enhanced Input asset preflight without bringing the fork's mutation, transaction, or save surface into this PR. + +| Action | Verified contract | +|---|---| +| `list_input_actions` | Stable bounded AssetRegistry page; details load only returned assets | +| `get_input_action` | Exact type-checked action metadata with bounded trigger/modifier arrays | +| `list_input_mapping_contexts` | Stable bounded context page and per-context mapping cap | +| `get_input_mapping_context` | Independent stable mapping pagination | +| `validate_input_mappings` | Missing-action/invalid-key errors, duplicate-key warnings, and explicit completeness | + +The namespace advertises `readOnlyHint=true` and `idempotentHint=true`. It registers before the GAS settings gate and remains available when GAS authoring is disabled. + +--- + +## 2. Bounds and failure semantics + +| Surface | Limit / behavior | +|---|---| +| Asset page | 1–1,000; default 200 | +| Mapping read page | 1–500; default 100 | +| Input Action trigger/modifier array | 256 per array | +| Mapping trigger/modifier array | 64 per array | +| Validation context page | 1–1,000; default 200 | +| Explicit context paths | 1–1,000, unique | +| Validation mapping scan | 1–10,000 per context; default 4,096 | + +Canonical mounted package or matching top-level object paths are required. Filesystem paths, subobjects, leading/trailing whitespace, and mismatched package/object leaves return invalid-parameter errors. `context_paths` and `path` are mutually exclusive. No path, asset, or result fallback is substituted. + +Every list result reports `total`, `offset`, `limit`, `count`, and `has_more`. Mapping pages and instanced-object arrays expose their own counts and truncation. Validation separates mapping-scan `page_complete` from pagination-level `all_contexts_covered`; a cutoff on either axis makes global `complete=false` and prevents `valid=true`. + +--- + +## 3. Build verification + +Both engine roots were resolved from isolated host `.uproject` `EngineAssociation` values through launcher installation metadata; no engine path is encoded in source or scripts. + +| Engine | Gate | Result | Evidence | +|---|---|---|---| +| UE 5.7 | `RunUAT BuildPlugin -NoTargetPlatforms -Rocket` | Pass, 436/436 actions, UAT exit 0 | `D:\P4\MonolithValidation20260804\05-input\Logs\UE57\Log.txt` | +| UE 5.8 | `RunUAT BuildPlugin -NoTargetPlatforms -Rocket` | Pass, 436/436 actions, UAT exit 0 | `D:\P4\MonolithValidation20260804\05-input\Logs\UE58\Log.txt` | +| UE 5.7 | Final `UnrealEditor Win64 Development` rebuild after completeness hardening | Pass, 436/436 actions, UBT exit 0 | `D:\P4\MonolithValidation20260804\05-input\Logs\FinalBuild-UE57.log` | +| UE 5.8 | Final `UnrealEditor Win64 Development` rebuild after completeness hardening | Pass, 436/436 actions, UBT exit 0 | `D:\P4\MonolithValidation20260804\05-input\Logs\FinalBuild-UE58.log` | +| UE 5.7 | Namespace-policy follow-up `UnrealEditor Win64 Development` target | Pass; implementation and focused tests compiled independently, module linked, result succeeded | `D:\P4\MonolithValidation20260804\05-input\Logs\NamespacePolicyBuild-UE57.log` | +| UE 5.8 | Namespace-policy follow-up `UnrealEditor Win64 Development` target | Pass; implementation and focused tests compiled independently, module linked, result succeeded | `D:\P4\MonolithValidation20260804\05-input\Logs\NamespacePolicyBuild-UE58.log` | + +Both gates compiled `MonolithGASInputAssetActions.cpp` and `MonolithGASInputAssetActionsTests.cpp` as individual adaptive-build actions, then linked `MonolithGAS` successfully. + +--- + +## 4. Focused automation + +Each packaged-plugin host ran: + +```text +Automation RunTests Monolith.Input.Assets +``` + +| Engine | Tests found | Success | Failed / skipped | Final marker | Evidence | +|---|---:|---:|---:|---|---| +| UE 5.7 | 3 | 3 | 0 | `TEST COMPLETE. EXIT CODE: 0` | `D:\P4\MonolithValidation20260804\05-input\Logs\NamespacePolicyTests-UE57.log` | +| UE 5.8 | 3 | 3 | 0 | `TEST COMPLETE. EXIT CODE: 0` | `D:\P4\MonolithValidation20260804\05-input\Logs\NamespacePolicyTests-UE58.log` | + +The tests cover the exact five-action registry/schema surface and dispatcher hints; malformed types, paths, selector combinations, and hard caps; and transient IA/IMC readback, stable asset/mapping/validation pagination, duplicate-key warning semantics, mapping-scan and context-coverage completeness, and package cleanliness. + +The first UE 5.7 host launch did not reach tests because the packaged `MonolithBABridge` binary had been linked against the locally installed Blueprint Assist plugin while the host had not enabled it. Enabling that installed plugin reproduced the already-established UE 5.7 host contract; the final run above passed 3/3. MCP auto-start was disabled and first-time indexing was deferred in both final isolated hosts. Because UE 5.7 reused the host from the setup attempt, it performed a no-change incremental index check before the tests; this did not change the 3/3 focused result. + +--- + +## 5. Static and visual gates + +| Gate | Result | Reason | +|---|---|---| +| Named C++ scopes | Pass | New source/test helpers use file-stem-derived named namespaces; handlers and tests fully qualify every namespaced symbol, with no anonymous namespace, `using namespace`, or individual `using` declaration | +| Patch hygiene | Pass | `git diff --check` completed without whitespace errors | +| PC 1920x1080 screenshot | N/A | Headless asset inspection handlers, schemas, tests, docs, and routing guidance have no visual surface | +| Discord screenshot upload | N/A | No relevant visual artifact; `Build\BatchFiles\Script\UploadScreenshotTestsToDiscord.bat` was not invoked | + +--- + +## 6. Result + +Pass. The `input` namespace adds five cohesive read-only actions with deterministic paging, explicit completeness, no asset mutation path, and verified UE 5.7/5.8 behavior. diff --git a/Skills/unreal-input/SKILL.md b/Skills/unreal-input/SKILL.md new file mode 100644 index 000000000..c711c8a6a --- /dev/null +++ b/Skills/unreal-input/SKILL.md @@ -0,0 +1,93 @@ +--- +name: unreal-input +description: Use for read-only discovery, bounded inspection, and validation of Enhanced Input Action and Input Mapping Context assets through the Monolith input namespace. Routes asset authoring to unreal-blueprints or a purpose-built writer instead of guessing mutation actions. +--- + +# unreal-input + +Use the `input` namespace to inventory and audit `UInputAction` and `UInputMappingContext` assets without modifying, compiling, saving, transacting, or dirtying them. + +## Discovery + +```text +monolith_discover({ namespace: "input" }) +describe_query({ action: "action_schema", params: { + target_namespace: "input", target_action: "" +}}) +``` + +The live catalog is authoritative. The five actions below are the current read-only surface. + +## Routing + +Use this skill for: + +- finding Input Action and Input Mapping Context assets +- reading Input Action value type, behavior flags, triggers, and modifiers +- reading mapping rows, keys, actions, triggers, modifiers, and player-mappable metadata +- preflighting missing actions, invalid keys, duplicate-key assignments, and scan completeness + +Use a different workflow for: + +- `unreal-config` — `DefaultInput.ini` or other project configuration +- `unreal-gas` — binding Input Actions to GAS ability activation +- `unreal-blueprints` — wiring Enhanced Input events into Blueprint graphs +- asset mutation — the `input` namespace is intentionally read-only; do not guess create, set, add, or remove action names + +## Actions + +| Action | Parameters | Result | +|---|---|---| +| `list_input_actions` | `path=/Game`, `offset=0`, `limit=200` (1–1000), `include_details=false` | Stable page of Input Action identities; detailed rows include bounded trigger/modifier classes | +| `get_input_action` | `asset_path` | Value type, description, behavior flags, accumulation policy, trigger/modifier classes, counts, and truncation flags | +| `list_input_mapping_contexts` | `path=/Game`, `offset=0`, `limit=200` (1–1000), `include_details=false`, `mapping_limit=100` (1–500) | Stable context page; optional details load only the returned assets and bound each mapping list | +| `get_input_mapping_context` | `asset_path`, `mapping_offset=0`, `mapping_limit=100` (1–500) | Context metadata and a stable bounded page of mapping rows | +| `validate_input_mappings` | `context_paths` or `path=/Game`, `offset=0`, `limit=200` (1–1000), `mapping_scan_limit=4096` (1–10000) | Per-context issues plus `valid`, `complete`, error/warning counts, and pagination evidence | + +## Contract + +- Asset inputs accept canonical mounted Unreal package paths (`/Game/Input/IA_Jump`) or matching top-level object paths (`/Game/Input/IA_Jump.IA_Jump`). Filesystem paths, subobjects, whitespace aliases, and mismatched package/object leaves are rejected. +- Package filters are canonical roots such as `/Game/Input`; an omitted filter defaults to `/Game`. +- List results are sorted by object path before pagination. `total`, `offset`, `limit`, `count`, and `has_more` make continuation deterministic. +- Input Action trigger/modifier arrays and mapping rows have independent hard caps. Truncation is explicit and never presented as a complete result. +- `validate_input_mappings` treats a missing action, invalid key, load failure, or scan cutoff as an error. A key assigned to multiple actions is a warning because Enhanced Input can use that layout intentionally. +- `page_complete` reports whether every mapping in the returned context page was scanned. `all_contexts_covered` reports whether pagination covered the full selected context set. Global `complete` requires both; `valid=true` additionally requires zero errors. Always check `valid`, `complete`, and `has_more`. +- The namespace stays available when Monolith GAS authoring is disabled because these are Enhanced Input asset reads, not GAS mutations. + +## Workflows + +### Inventory actions without loading every asset + +```text +input_query({ action: "list_input_actions", params: { + path: "/Game/Input", offset: 0, limit: 200 +}}) +``` + +Set `include_details: true` only when the returned page needs trigger/modifier metadata. + +### Page through a mapping context + +```text +input_query({ action: "get_input_mapping_context", params: { + asset_path: "/Game/Input/IMC_Default", + mapping_offset: 0, + mapping_limit: 100 +}}) +``` + +If `has_more_mappings` is true, advance `mapping_offset` by `mappings_returned`. + +### Validate selected contexts + +```text +input_query({ action: "validate_input_mappings", params: { + context_paths: [ + "/Game/Input/IMC_Default", + "/Game/Input/IMC_Menu" + ], + mapping_scan_limit: 4096 +}}) +``` + +`context_paths` and `path` are mutually exclusive. An explicit empty or duplicate path list is rejected instead of being treated as an all-project scan. diff --git a/Source/MonolithGAS/MonolithGAS.Build.cs b/Source/MonolithGAS/MonolithGAS.Build.cs index 22ea551dc..dabe06674 100644 --- a/Source/MonolithGAS/MonolithGAS.Build.cs +++ b/Source/MonolithGAS/MonolithGAS.Build.cs @@ -23,7 +23,9 @@ public MonolithGAS(ReadOnlyTargetRules Target) : base(Target) "UnrealEd", "BlueprintGraph", "GameplayAbilitiesEditor", "GameplayTasksEditor", "GameplayTagsEditor", + "AssetRegistry", "EnhancedInput", + "InputCore", "EditorScriptingUtilities", "Json", "JsonUtilities", // UMGEditor: editor-side UWidgetBlueprintExtension + FWidgetBlueprintCompilerContext diff --git a/Source/MonolithGAS/Private/MonolithGASInputAssetActions.cpp b/Source/MonolithGAS/Private/MonolithGASInputAssetActions.cpp new file mode 100644 index 000000000..4e83b0bbb --- /dev/null +++ b/Source/MonolithGAS/Private/MonolithGASInputAssetActions.cpp @@ -0,0 +1,1084 @@ +#include "MonolithGASInputAssetActions.h" + +#include "MonolithParamSchema.h" + +#include "AssetRegistry/AssetRegistryModule.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "EnhancedActionKeyMapping.h" +#include "InputAction.h" +#include "InputCoreTypes.h" +#include "InputMappingContext.h" +#include "InputModifiers.h" +#include "InputTriggers.h" +#include "Misc/PackageName.h" +#include "Modules/ModuleManager.h" +#include "UObject/SoftObjectPath.h" + +namespace MonolithGASInputAssetActions +{ + constexpr int32 DefaultAssetPageLimit = 200; + constexpr int32 MaxAssetPageLimit = 1000; + constexpr int32 DefaultMappingPageLimit = 100; + constexpr int32 MaxMappingPageLimit = 500; + constexpr int32 MaxInstancedObjectsPerArray = 256; + constexpr int32 MaxInstancedObjectsPerMapping = 64; + constexpr int32 DefaultMappingScanLimit = 4096; + constexpr int32 MaxMappingScanLimit = 10000; + constexpr int32 MaxExplicitContextPaths = 1000; + + FMonolithActionResult InvalidParam(const TCHAR* Field, const FString& Detail) + { + return FMonolithActionResult::Error( + FString::Printf(TEXT("Invalid parameter '%s': %s"), Field, *Detail), + -32602); + } + + bool HasParam(const TSharedPtr& Params, const TCHAR* Field) + { + return Params.IsValid() && Params->HasField(Field); + } + + bool ParseBoundedInteger( + const TSharedPtr& Params, + const TCHAR* Field, + int32 DefaultValue, + int32 MinValue, + int32 MaxValue, + int32& OutValue, + FMonolithActionResult& OutError) + { + double Number = static_cast(DefaultValue); + if (HasParam(Params, Field)) + { + const TSharedPtr Value = Params->TryGetField(Field); + if (!Value.IsValid() || Value->Type != EJson::Number || !Value->TryGetNumber(Number)) + { + OutError = InvalidParam(Field, TEXT("expected an integer JSON number")); + return false; + } + } + + if (!FMath::IsFinite(Number) + || Number != FMath::TruncToDouble(Number) + || Number < static_cast(MinValue) + || Number > static_cast(MaxValue)) + { + OutError = InvalidParam( + Field, + FString::Printf(TEXT("expected an integer in the range %d..%d"), MinValue, MaxValue)); + return false; + } + + OutValue = static_cast(Number); + return true; + } + + bool ParseOptionalBool( + const TSharedPtr& Params, + const TCHAR* Field, + bool DefaultValue, + bool& OutValue, + FMonolithActionResult& OutError) + { + OutValue = DefaultValue; + if (!HasParam(Params, Field)) + { + return true; + } + + const TSharedPtr Value = Params->TryGetField(Field); + if (!Value.IsValid() || Value->Type != EJson::Boolean || !Value->TryGetBool(OutValue)) + { + OutError = InvalidParam(Field, TEXT("expected a boolean")); + return false; + } + return true; + } + + bool ReadRequiredString( + const TSharedPtr& Params, + const TCHAR* Field, + FString& OutValue, + FMonolithActionResult& OutError) + { + if (!HasParam(Params, Field)) + { + OutError = InvalidParam(Field, TEXT("field is required")); + return false; + } + + const TSharedPtr Value = Params->TryGetField(Field); + if (!Value.IsValid() + || Value->Type != EJson::String + || !Value->TryGetString(OutValue) + || OutValue.IsEmpty()) + { + OutError = InvalidParam(Field, TEXT("expected a non-empty string")); + return false; + } + return true; + } + + bool ReadOptionalString( + const TSharedPtr& Params, + const TCHAR* Field, + const FString& DefaultValue, + FString& OutValue, + FMonolithActionResult& OutError) + { + OutValue = DefaultValue; + if (!HasParam(Params, Field)) + { + return true; + } + + const TSharedPtr Value = Params->TryGetField(Field); + if (!Value.IsValid() || Value->Type != EJson::String || !Value->TryGetString(OutValue)) + { + OutError = InvalidParam(Field, TEXT("expected a string")); + return false; + } + return true; + } + + bool ParsePackageFilter( + const FString& Input, + FString& OutFilter, + FMonolithActionResult& OutError) + { + OutFilter = Input; + FString Trimmed = Input; + Trimmed.TrimStartAndEndInline(); + if (Trimmed != Input) + { + OutError = InvalidParam(TEXT("path"), TEXT("leading or trailing whitespace is not allowed")); + return false; + } + if (Input.Contains(TEXT("\\")) + || Input.Contains(TEXT(":")) + || Input.Contains(TEXT(".")) + || Input.EndsWith(TEXT("/"))) + { + OutError = InvalidParam( + TEXT("path"), + TEXT("expected a canonical Unreal package prefix such as /Game/Input")); + return false; + } + if (!FPackageName::IsValidLongPackageName(Input)) + { + OutError = InvalidParam( + TEXT("path"), + TEXT("expected a valid mounted long package prefix")); + return false; + } + return true; + } + + bool ParseAssetPath( + const FString& Input, + const TCHAR* Field, + FString& OutObjectPath, + FMonolithActionResult& OutError) + { + FString Trimmed = Input; + Trimmed.TrimStartAndEndInline(); + if (Trimmed != Input) + { + OutError = InvalidParam(Field, TEXT("leading or trailing whitespace is not allowed")); + return false; + } + if (Input.Contains(TEXT("\\")) + || Input.Contains(TEXT(":")) + || Input.EndsWith(TEXT(".uasset"), ESearchCase::IgnoreCase) + || Input.EndsWith(TEXT(".umap"), ESearchCase::IgnoreCase)) + { + OutError = InvalidParam( + Field, + TEXT("expected a canonical Unreal package or top-level object path")); + return false; + } + + FString PackagePath; + FString ObjectName; + const bool bObjectPath = Input.Split(TEXT("."), &PackagePath, &ObjectName); + if (!bObjectPath) + { + PackagePath = Input; + } + if (!FPackageName::IsValidLongPackageName(PackagePath)) + { + OutError = InvalidParam(Field, TEXT("expected a valid mounted long package name")); + return false; + } + + const FString PackageLeaf = FPackageName::GetLongPackageAssetName(PackagePath); + if (PackageLeaf.IsEmpty()) + { + OutError = InvalidParam(Field, TEXT("asset name is missing")); + return false; + } + if (bObjectPath && ObjectName != PackageLeaf) + { + OutError = InvalidParam( + Field, + FString::Printf( + TEXT("object name '%s' must match package leaf '%s'"), + *ObjectName, + *PackageLeaf)); + return false; + } + + OutObjectPath = PackagePath + TEXT(".") + PackageLeaf; + if (!FPackageName::IsValidObjectPath(OutObjectPath)) + { + OutError = InvalidParam(Field, TEXT("expected a valid top-level object path")); + return false; + } + return true; + } + + template + TAsset* LoadExactAsset( + const FString& Input, + const TCHAR* Field, + const TCHAR* ExpectedType, + FMonolithActionResult& OutError) + { + FString ObjectPath; + if (!ParseAssetPath(Input, Field, ObjectPath, OutError)) + { + return nullptr; + } + + UObject* Object = FSoftObjectPath(ObjectPath).TryLoad(); + if (!Object) + { + OutError = FMonolithActionResult::Error( + FString::Printf(TEXT("Asset not found: %s"), *ObjectPath)); + return nullptr; + } + TAsset* Asset = Cast(Object); + if (!Asset) + { + OutError = FMonolithActionResult::Error( + FString::Printf( + TEXT("Asset '%s' is %s, expected %s"), + *ObjectPath, + *Object->GetClass()->GetName(), + ExpectedType)); + return nullptr; + } + return Asset; + } + + FString ValueTypeToString(EInputActionValueType ValueType) + { + switch (ValueType) + { + case EInputActionValueType::Boolean: return TEXT("Boolean"); + case EInputActionValueType::Axis1D: return TEXT("Axis1D"); + case EInputActionValueType::Axis2D: return TEXT("Axis2D"); + case EInputActionValueType::Axis3D: return TEXT("Axis3D"); + default: return TEXT("Unknown"); + } + } + + FString AccumulationToString(EInputActionAccumulationBehavior Behavior) + { + return Behavior == EInputActionAccumulationBehavior::Cumulative + ? TEXT("Cumulative") + : TEXT("TakeHighestAbsoluteValue"); + } + + FString TrackingModeToString(EMappingContextRegistrationTrackingMode Mode) + { + return Mode == EMappingContextRegistrationTrackingMode::CountRegistrations + ? TEXT("CountRegistrations") + : TEXT("Untracked"); + } + + TSharedPtr InstancedObjectToJson(const UObject* Object) + { + TSharedPtr Json = MakeShared(); + Json->SetStringField(TEXT("class"), Object ? Object->GetClass()->GetName() : TEXT("None")); + Json->SetStringField(TEXT("class_path"), Object ? Object->GetClass()->GetPathName() : TEXT("")); + return Json; + } + + template + TArray> InstancedObjectArrayToJson( + const TArray>& Objects, + int32 Limit, + bool& bOutTruncated) + { + const int32 Count = FMath::Min(Objects.Num(), Limit); + TArray> Values; + Values.Reserve(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values.Add(MakeShared(InstancedObjectToJson(Objects[Index].Get()))); + } + bOutTruncated = Objects.Num() > Count; + return Values; + } + + TSharedPtr InputActionToJson(const UInputAction* Action) + { + TSharedPtr Json = MakeShared(); + if (!Action) + { + return Json; + } + + Json->SetStringField(TEXT("asset_path"), Action->GetPathName()); + Json->SetStringField(TEXT("package_path"), Action->GetOutermost()->GetName()); + Json->SetStringField(TEXT("name"), Action->GetName()); + Json->SetStringField(TEXT("value_type"), ValueTypeToString(Action->ValueType)); + Json->SetStringField(TEXT("description"), Action->ActionDescription.ToString()); + Json->SetBoolField(TEXT("consume_input"), Action->bConsumeInput); + Json->SetBoolField(TEXT("consume_legacy_mappings"), Action->bConsumesActionAndAxisMappings); + Json->SetBoolField(TEXT("trigger_when_paused"), Action->bTriggerWhenPaused); + Json->SetBoolField(TEXT("reserve_all_mappings"), Action->bReserveAllMappings); + Json->SetStringField(TEXT("accumulation"), AccumulationToString(Action->AccumulationBehavior)); + + bool bTriggersTruncated = false; + bool bModifiersTruncated = false; + Json->SetArrayField( + TEXT("triggers"), + InstancedObjectArrayToJson( + Action->Triggers, + MaxInstancedObjectsPerArray, + bTriggersTruncated)); + Json->SetArrayField( + TEXT("modifiers"), + InstancedObjectArrayToJson( + Action->Modifiers, + MaxInstancedObjectsPerArray, + bModifiersTruncated)); + Json->SetNumberField(TEXT("trigger_count"), Action->Triggers.Num()); + Json->SetNumberField(TEXT("modifier_count"), Action->Modifiers.Num()); + Json->SetBoolField(TEXT("triggers_truncated"), bTriggersTruncated); + Json->SetBoolField(TEXT("modifiers_truncated"), bModifiersTruncated); + Json->SetBoolField( + TEXT("has_player_mappable_settings"), + Action->GetPlayerMappableKeySettings() != nullptr); + return Json; + } + + TSharedPtr MappingToJson(const FEnhancedActionKeyMapping& Mapping, int32 Index) + { + TSharedPtr Json = MakeShared(); + Json->SetNumberField(TEXT("index"), Index); + Json->SetStringField(TEXT("action"), Mapping.Action ? Mapping.Action->GetPathName() : TEXT("")); + Json->SetStringField(TEXT("action_name"), Mapping.Action ? Mapping.Action->GetName() : TEXT("")); + Json->SetStringField(TEXT("key"), Mapping.Key.ToString()); + Json->SetBoolField(TEXT("key_valid"), Mapping.Key.IsValid()); + Json->SetBoolField(TEXT("is_player_mappable"), Mapping.IsPlayerMappable()); + Json->SetStringField(TEXT("mapping_name"), Mapping.GetMappingName().ToString()); + Json->SetStringField(TEXT("display_name"), Mapping.GetDisplayName().ToString()); + Json->SetStringField(TEXT("display_category"), Mapping.GetDisplayCategory().ToString()); + + bool bTriggersTruncated = false; + bool bModifiersTruncated = false; + Json->SetArrayField( + TEXT("triggers"), + InstancedObjectArrayToJson( + Mapping.Triggers, + MaxInstancedObjectsPerMapping, + bTriggersTruncated)); + Json->SetArrayField( + TEXT("modifiers"), + InstancedObjectArrayToJson( + Mapping.Modifiers, + MaxInstancedObjectsPerMapping, + bModifiersTruncated)); + Json->SetNumberField(TEXT("trigger_count"), Mapping.Triggers.Num()); + Json->SetNumberField(TEXT("modifier_count"), Mapping.Modifiers.Num()); + Json->SetBoolField(TEXT("triggers_truncated"), bTriggersTruncated); + Json->SetBoolField(TEXT("modifiers_truncated"), bModifiersTruncated); + return Json; + } + + void SetPageMetadata( + const TSharedPtr& Json, + int32 Total, + int32 Offset, + int32 Limit, + int32 Count) + { + Json->SetNumberField(TEXT("total"), Total); + Json->SetNumberField(TEXT("offset"), Offset); + Json->SetNumberField(TEXT("limit"), Limit); + Json->SetNumberField(TEXT("count"), Count); + Json->SetBoolField(TEXT("has_more"), static_cast(Offset) + Count < Total); + } + + TSharedPtr MappingContextToJson( + const UInputMappingContext* Context, + int32 MappingOffset, + int32 MappingLimit) + { + TSharedPtr Json = MakeShared(); + if (!Context) + { + return Json; + } + + Json->SetStringField(TEXT("asset_path"), Context->GetPathName()); + Json->SetStringField(TEXT("package_path"), Context->GetOutermost()->GetName()); + Json->SetStringField(TEXT("name"), Context->GetName()); + Json->SetStringField(TEXT("description"), Context->ContextDescription.ToString()); + Json->SetBoolField(TEXT("filters_by_input_mode"), Context->ShouldFilterMappingByInputMode()); + Json->SetStringField( + TEXT("registration_tracking_mode"), + TrackingModeToString(Context->GetRegistrationTrackingMode())); + + const TArray& Mappings = Context->GetMappings(); + const int32 Start = FMath::Min(MappingOffset, Mappings.Num()); + const int32 End = static_cast(FMath::Min( + Mappings.Num(), + static_cast(MappingOffset) + MappingLimit)); + TArray> MappingValues; + MappingValues.Reserve(End - Start); + for (int32 Index = Start; Index < End; ++Index) + { + MappingValues.Add(MakeShared(MappingToJson(Mappings[Index], Index))); + } + Json->SetArrayField(TEXT("mappings"), MappingValues); + Json->SetNumberField(TEXT("mapping_count"), Mappings.Num()); + Json->SetNumberField(TEXT("mapping_offset"), MappingOffset); + Json->SetNumberField(TEXT("mapping_limit"), MappingLimit); + Json->SetNumberField(TEXT("mappings_returned"), MappingValues.Num()); + Json->SetBoolField( + TEXT("mappings_truncated"), + Start > 0 || End < Mappings.Num()); + Json->SetBoolField(TEXT("has_more_mappings"), End < Mappings.Num()); + return Json; + } + + void QueryAssets( + UClass* AssetClass, + const FString& PackageFilter, + TArray& OutAssets) + { + FARFilter Filter; + Filter.ClassPaths.Add(AssetClass->GetClassPathName()); + Filter.bRecursiveClasses = true; + Filter.PackagePaths.Add(FName(*PackageFilter)); + Filter.bRecursivePaths = true; + + FAssetRegistryModule& Module = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + Module.Get().GetAssets(Filter, OutAssets); + OutAssets.Sort([](const FAssetData& A, const FAssetData& B) + { + return A.GetObjectPathString() < B.GetObjectPathString(); + }); + } + + bool ParseAssetListParams( + const TSharedPtr& Params, + FString& OutPath, + int32& OutOffset, + int32& OutLimit, + bool& bOutIncludeDetails, + FMonolithActionResult& OutError) + { + FString Path; + if (!ReadOptionalString(Params, TEXT("path"), TEXT("/Game"), Path, OutError) + || !ParsePackageFilter(Path, OutPath, OutError) + || !ParseBoundedInteger(Params, TEXT("offset"), 0, 0, MAX_int32, OutOffset, OutError) + || !ParseBoundedInteger( + Params, + TEXT("limit"), + DefaultAssetPageLimit, + 1, + MaxAssetPageLimit, + OutLimit, + OutError) + || !ParseOptionalBool(Params, TEXT("include_details"), false, bOutIncludeDetails, OutError)) + { + return false; + } + return true; + } + + bool ParseContextPaths( + const TSharedPtr& Params, + TArray& OutPaths, + FMonolithActionResult& OutError) + { + OutPaths.Reset(); + if (!HasParam(Params, TEXT("context_paths"))) + { + return true; + } + + const TSharedPtr Field = Params->TryGetField(TEXT("context_paths")); + const TArray>* Values = nullptr; + if (!Field.IsValid() + || Field->Type != EJson::Array + || !Field->TryGetArray(Values) + || !Values + || Values->IsEmpty()) + { + OutError = InvalidParam( + TEXT("context_paths"), + TEXT("expected a non-empty array of canonical asset paths")); + return false; + } + if (Values->Num() > MaxExplicitContextPaths) + { + OutError = InvalidParam( + TEXT("context_paths"), + FString::Printf(TEXT("at most %d paths are accepted"), MaxExplicitContextPaths)); + return false; + } + + TSet Seen; + for (int32 Index = 0; Index < Values->Num(); ++Index) + { + FString Path; + if (!(*Values)[Index].IsValid() + || (*Values)[Index]->Type != EJson::String + || !(*Values)[Index]->TryGetString(Path) + || Path.IsEmpty()) + { + OutError = InvalidParam( + TEXT("context_paths"), + FString::Printf(TEXT("element %d must be a non-empty string"), Index)); + return false; + } + + FString ObjectPath; + if (!ParseAssetPath(Path, TEXT("context_paths"), ObjectPath, OutError)) + { + return false; + } + if (Seen.Contains(ObjectPath)) + { + OutError = InvalidParam( + TEXT("context_paths"), + FString::Printf(TEXT("duplicate path '%s'"), *ObjectPath)); + return false; + } + Seen.Add(ObjectPath); + OutPaths.Add(ObjectPath); + } + OutPaths.Sort(); + return true; + } + + TSharedPtr MakeIssue( + const TCHAR* Type, + const TCHAR* Severity, + const FString& Message, + int32 MappingIndex = INDEX_NONE) + { + TSharedPtr Issue = MakeShared(); + Issue->SetStringField(TEXT("type"), Type); + Issue->SetStringField(TEXT("severity"), Severity); + Issue->SetStringField(TEXT("message"), Message); + if (MappingIndex != INDEX_NONE) + { + Issue->SetNumberField(TEXT("mapping_index"), MappingIndex); + } + return Issue; + } +} + +void FMonolithGASInputAssetActions::RegisterActions(FMonolithToolRegistry& Registry) +{ + FMonolithDispatcherAnnotations Annotations; + Annotations.bReadOnlyHint = true; + Annotations.bIdempotentHint = true; + Annotations.Title = TEXT("Enhanced Input Asset Inspection"); + Registry.SetDispatcherAnnotations(TEXT("input"), Annotations); + + Registry.RegisterAction( + TEXT("input"), + TEXT("list_input_actions"), + TEXT("List Enhanced Input action assets with stable bounded pagination"), + FMonolithActionHandler::CreateStatic(&HandleListInputActions), + FParamSchemaBuilder() + .OptionalAssetPath(TEXT("path"), TEXT("Canonical package root; defaults to /Game")) + .Optional(TEXT("offset"), TEXT("integer"), TEXT("Zero-based result offset"), TEXT("0")) + .Optional(TEXT("limit"), TEXT("integer"), TEXT("Maximum assets to return (1-1000)"), TEXT("200")) + .Optional(TEXT("include_details"), TEXT("boolean"), TEXT("Load only the returned page and include action details"), TEXT("false")) + .Build()); + + Registry.RegisterAction( + TEXT("input"), + TEXT("get_input_action"), + TEXT("Inspect one Enhanced Input action asset without modifying it"), + FMonolithActionHandler::CreateStatic(&HandleGetInputAction), + FParamSchemaBuilder() + .RequiredAssetPath(TEXT("asset_path"), TEXT("Canonical InputAction package or object path")) + .Build()); + + Registry.RegisterAction( + TEXT("input"), + TEXT("list_input_mapping_contexts"), + TEXT("List Enhanced Input mapping contexts with stable bounded pagination"), + FMonolithActionHandler::CreateStatic(&HandleListInputMappingContexts), + FParamSchemaBuilder() + .OptionalAssetPath(TEXT("path"), TEXT("Canonical package root; defaults to /Game")) + .Optional(TEXT("offset"), TEXT("integer"), TEXT("Zero-based result offset"), TEXT("0")) + .Optional(TEXT("limit"), TEXT("integer"), TEXT("Maximum assets to return (1-1000)"), TEXT("200")) + .Optional(TEXT("include_details"), TEXT("boolean"), TEXT("Load only the returned page and include bounded mappings"), TEXT("false")) + .Optional(TEXT("mapping_limit"), TEXT("integer"), TEXT("Mappings per detailed context (1-500)"), TEXT("100")) + .Build()); + + Registry.RegisterAction( + TEXT("input"), + TEXT("get_input_mapping_context"), + TEXT("Inspect one Enhanced Input mapping context with bounded mapping pagination"), + FMonolithActionHandler::CreateStatic(&HandleGetInputMappingContext), + FParamSchemaBuilder() + .RequiredAssetPath(TEXT("asset_path"), TEXT("Canonical InputMappingContext package or object path")) + .Optional(TEXT("mapping_offset"), TEXT("integer"), TEXT("Zero-based mapping offset"), TEXT("0")) + .Optional(TEXT("mapping_limit"), TEXT("integer"), TEXT("Maximum mappings to return (1-500)"), TEXT("100")) + .Build()); + + Registry.RegisterAction( + TEXT("input"), + TEXT("validate_input_mappings"), + TEXT("Read-only validation for missing actions, invalid keys, duplicate-key warnings, and scan completeness"), + FMonolithActionHandler::CreateStatic(&HandleValidateInputMappings), + FParamSchemaBuilder() + .Optional(TEXT("context_paths"), TEXT("array"), TEXT("Canonical InputMappingContext paths; mutually exclusive with path")) + .OptionalAssetPath(TEXT("path"), TEXT("Canonical package root when context_paths is omitted; defaults to /Game")) + .Optional(TEXT("offset"), TEXT("integer"), TEXT("Zero-based context offset"), TEXT("0")) + .Optional(TEXT("limit"), TEXT("integer"), TEXT("Maximum contexts to validate (1-1000)"), TEXT("200")) + .Optional(TEXT("mapping_scan_limit"), TEXT("integer"), TEXT("Maximum mappings scanned per context (1-10000)"), TEXT("4096")) + .Build()); +} + +FMonolithActionResult FMonolithGASInputAssetActions::HandleListInputActions( + const TSharedPtr& Params) +{ + FString PackageFilter; + int32 Offset = 0; + int32 Limit = 0; + bool bIncludeDetails = false; + FMonolithActionResult Error; + if (!MonolithGASInputAssetActions::ParseAssetListParams( + Params, + PackageFilter, + Offset, + Limit, + bIncludeDetails, + Error)) + { + return Error; + } + + TArray Assets; + MonolithGASInputAssetActions::QueryAssets(UInputAction::StaticClass(), PackageFilter, Assets); + const int32 Start = FMath::Min(Offset, Assets.Num()); + const int32 End = static_cast(FMath::Min( + Assets.Num(), + static_cast(Offset) + Limit)); + TArray> Rows; + Rows.Reserve(End - Start); + for (int32 Index = Start; Index < End; ++Index) + { + const FAssetData& AssetData = Assets[Index]; + TSharedPtr Row = MakeShared(); + Row->SetStringField(TEXT("asset_path"), AssetData.GetObjectPathString()); + Row->SetStringField(TEXT("package_path"), AssetData.PackageName.ToString()); + Row->SetStringField(TEXT("name"), AssetData.AssetName.ToString()); + if (bIncludeDetails) + { + if (UInputAction* Action = Cast(AssetData.GetAsset())) + { + Row = MonolithGASInputAssetActions::InputActionToJson(Action); + } + else + { + Row->SetStringField(TEXT("load_error"), TEXT("Asset could not be loaded as UInputAction")); + } + } + Rows.Add(MakeShared(Row)); + } + + TSharedPtr Result = MakeShared(); + Result->SetStringField(TEXT("path"), PackageFilter); + Result->SetBoolField(TEXT("include_details"), bIncludeDetails); + Result->SetArrayField(TEXT("actions"), Rows); + MonolithGASInputAssetActions::SetPageMetadata( + Result, + Assets.Num(), + Offset, + Limit, + Rows.Num()); + return FMonolithActionResult::Success(Result); +} + +FMonolithActionResult FMonolithGASInputAssetActions::HandleGetInputAction( + const TSharedPtr& Params) +{ + FString AssetPath; + FMonolithActionResult Error; + if (!MonolithGASInputAssetActions::ReadRequiredString( + Params, + TEXT("asset_path"), + AssetPath, + Error)) + { + return Error; + } + UInputAction* Action = MonolithGASInputAssetActions::LoadExactAsset( + AssetPath, + TEXT("asset_path"), + TEXT("UInputAction"), + Error); + return Action + ? FMonolithActionResult::Success(MonolithGASInputAssetActions::InputActionToJson(Action)) + : Error; +} + +FMonolithActionResult FMonolithGASInputAssetActions::HandleListInputMappingContexts( + const TSharedPtr& Params) +{ + FString PackageFilter; + int32 Offset = 0; + int32 Limit = 0; + int32 MappingLimit = MonolithGASInputAssetActions::DefaultMappingPageLimit; + bool bIncludeDetails = false; + FMonolithActionResult Error; + if (!MonolithGASInputAssetActions::ParseAssetListParams( + Params, + PackageFilter, + Offset, + Limit, + bIncludeDetails, + Error) + || !MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("mapping_limit"), + MonolithGASInputAssetActions::DefaultMappingPageLimit, + 1, + MonolithGASInputAssetActions::MaxMappingPageLimit, + MappingLimit, + Error)) + { + return Error; + } + + TArray Assets; + MonolithGASInputAssetActions::QueryAssets( + UInputMappingContext::StaticClass(), + PackageFilter, + Assets); + const int32 Start = FMath::Min(Offset, Assets.Num()); + const int32 End = static_cast(FMath::Min( + Assets.Num(), + static_cast(Offset) + Limit)); + TArray> Rows; + Rows.Reserve(End - Start); + for (int32 Index = Start; Index < End; ++Index) + { + const FAssetData& AssetData = Assets[Index]; + TSharedPtr Row = MakeShared(); + Row->SetStringField(TEXT("asset_path"), AssetData.GetObjectPathString()); + Row->SetStringField(TEXT("package_path"), AssetData.PackageName.ToString()); + Row->SetStringField(TEXT("name"), AssetData.AssetName.ToString()); + if (bIncludeDetails) + { + if (UInputMappingContext* Context = Cast(AssetData.GetAsset())) + { + Row = MonolithGASInputAssetActions::MappingContextToJson(Context, 0, MappingLimit); + } + else + { + Row->SetStringField( + TEXT("load_error"), + TEXT("Asset could not be loaded as UInputMappingContext")); + } + } + Rows.Add(MakeShared(Row)); + } + + TSharedPtr Result = MakeShared(); + Result->SetStringField(TEXT("path"), PackageFilter); + Result->SetBoolField(TEXT("include_details"), bIncludeDetails); + Result->SetNumberField(TEXT("mapping_limit"), MappingLimit); + Result->SetArrayField(TEXT("contexts"), Rows); + MonolithGASInputAssetActions::SetPageMetadata( + Result, + Assets.Num(), + Offset, + Limit, + Rows.Num()); + return FMonolithActionResult::Success(Result); +} + +FMonolithActionResult FMonolithGASInputAssetActions::HandleGetInputMappingContext( + const TSharedPtr& Params) +{ + FString AssetPath; + int32 MappingOffset = 0; + int32 MappingLimit = MonolithGASInputAssetActions::DefaultMappingPageLimit; + FMonolithActionResult Error; + if (!MonolithGASInputAssetActions::ReadRequiredString( + Params, + TEXT("asset_path"), + AssetPath, + Error) + || !MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("mapping_offset"), + 0, + 0, + MAX_int32, + MappingOffset, + Error) + || !MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("mapping_limit"), + MonolithGASInputAssetActions::DefaultMappingPageLimit, + 1, + MonolithGASInputAssetActions::MaxMappingPageLimit, + MappingLimit, + Error)) + { + return Error; + } + + UInputMappingContext* Context = + MonolithGASInputAssetActions::LoadExactAsset( + AssetPath, + TEXT("asset_path"), + TEXT("UInputMappingContext"), + Error); + return Context + ? FMonolithActionResult::Success( + MonolithGASInputAssetActions::MappingContextToJson( + Context, + MappingOffset, + MappingLimit)) + : Error; +} + +FMonolithActionResult FMonolithGASInputAssetActions::HandleValidateInputMappings( + const TSharedPtr& Params) +{ + const bool bExplicitContexts = + MonolithGASInputAssetActions::HasParam(Params, TEXT("context_paths")); + if (bExplicitContexts && MonolithGASInputAssetActions::HasParam(Params, TEXT("path"))) + { + return MonolithGASInputAssetActions::InvalidParam( + TEXT("path"), + TEXT("path and context_paths are mutually exclusive")); + } + + TArray ContextPaths; + FMonolithActionResult Error; + if (!MonolithGASInputAssetActions::ParseContextPaths(Params, ContextPaths, Error)) + { + return Error; + } + if (!bExplicitContexts) + { + FString Path; + FString PackageFilter; + if (!MonolithGASInputAssetActions::ReadOptionalString( + Params, + TEXT("path"), + TEXT("/Game"), + Path, + Error) + || !MonolithGASInputAssetActions::ParsePackageFilter(Path, PackageFilter, Error)) + { + return Error; + } + TArray Assets; + MonolithGASInputAssetActions::QueryAssets( + UInputMappingContext::StaticClass(), + PackageFilter, + Assets); + ContextPaths.Reserve(Assets.Num()); + for (const FAssetData& Asset : Assets) + { + ContextPaths.Add(Asset.GetObjectPathString()); + } + } + + int32 Offset = 0; + int32 Limit = MonolithGASInputAssetActions::DefaultAssetPageLimit; + int32 MappingScanLimit = MonolithGASInputAssetActions::DefaultMappingScanLimit; + if (!MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("offset"), + 0, + 0, + MAX_int32, + Offset, + Error) + || !MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("limit"), + MonolithGASInputAssetActions::DefaultAssetPageLimit, + 1, + MonolithGASInputAssetActions::MaxAssetPageLimit, + Limit, + Error) + || !MonolithGASInputAssetActions::ParseBoundedInteger( + Params, + TEXT("mapping_scan_limit"), + MonolithGASInputAssetActions::DefaultMappingScanLimit, + 1, + MonolithGASInputAssetActions::MaxMappingScanLimit, + MappingScanLimit, + Error)) + { + return Error; + } + + const int32 Start = FMath::Min(Offset, ContextPaths.Num()); + const int32 End = static_cast(FMath::Min( + ContextPaths.Num(), + static_cast(Offset) + Limit)); + TArray> ContextResults; + ContextResults.Reserve(End - Start); + int32 ErrorCount = 0; + int32 WarningCount = 0; + bool bPageComplete = true; + + for (int32 ContextIndex = Start; ContextIndex < End; ++ContextIndex) + { + const FString& ContextPath = ContextPaths[ContextIndex]; + TSharedPtr ContextResult = MakeShared(); + ContextResult->SetStringField(TEXT("context_path"), ContextPath); + TArray> Issues; + UInputMappingContext* Context = + MonolithGASInputAssetActions::LoadExactAsset( + ContextPath, + TEXT("context_paths"), + TEXT("UInputMappingContext"), + Error); + if (!Context) + { + ++ErrorCount; + bPageComplete = false; + Issues.Add(MakeShared(MonolithGASInputAssetActions::MakeIssue( + TEXT("context_load_failed"), + TEXT("error"), + Error.ErrorMessage))); + ContextResult->SetBoolField(TEXT("valid"), false); + ContextResult->SetBoolField(TEXT("complete"), false); + ContextResult->SetNumberField(TEXT("errors"), 1); + ContextResult->SetNumberField(TEXT("warnings"), 0); + ContextResult->SetArrayField(TEXT("issues"), Issues); + ContextResults.Add(MakeShared(ContextResult)); + continue; + } + + const TArray& Mappings = Context->GetMappings(); + const int32 ScanCount = FMath::Min(Mappings.Num(), MappingScanLimit); + int32 ContextErrors = 0; + int32 ContextWarnings = 0; + TMap> KeyToActions; + for (int32 MappingIndex = 0; MappingIndex < ScanCount; ++MappingIndex) + { + const FEnhancedActionKeyMapping& Mapping = Mappings[MappingIndex]; + if (!Mapping.Action) + { + ++ContextErrors; + Issues.Add(MakeShared(MonolithGASInputAssetActions::MakeIssue( + TEXT("missing_action"), + TEXT("error"), + TEXT("Mapping has no InputAction"), + MappingIndex))); + } + if (!Mapping.Key.IsValid()) + { + ++ContextErrors; + Issues.Add(MakeShared(MonolithGASInputAssetActions::MakeIssue( + TEXT("invalid_key"), + TEXT("error"), + TEXT("Mapping has an invalid FKey"), + MappingIndex))); + } + if (Mapping.Action && Mapping.Key.IsValid()) + { + TArray& Actions = KeyToActions.FindOrAdd(Mapping.Key.ToString()); + Actions.AddUnique(Mapping.Action->GetPathName()); + } + } + + TArray Keys; + KeyToActions.GetKeys(Keys); + Keys.Sort(); + for (const FString& Key : Keys) + { + TArray Actions = KeyToActions.FindChecked(Key); + if (Actions.Num() < 2) + { + continue; + } + Actions.Sort(); + ++ContextWarnings; + TSharedPtr Issue = MonolithGASInputAssetActions::MakeIssue( + TEXT("duplicate_key_assignment"), + TEXT("warning"), + FString::Printf(TEXT("Key '%s' is assigned to multiple actions"), *Key)); + Issue->SetStringField(TEXT("key"), Key); + TArray> ActionValues; + for (const FString& Action : Actions) + { + ActionValues.Add(MakeShared(Action)); + } + Issue->SetArrayField(TEXT("actions"), ActionValues); + Issues.Add(MakeShared(Issue)); + } + + const bool bContextComplete = ScanCount == Mappings.Num(); + if (!bContextComplete) + { + ++ContextErrors; + Issues.Add(MakeShared(MonolithGASInputAssetActions::MakeIssue( + TEXT("mapping_scan_limit_exceeded"), + TEXT("error"), + FString::Printf( + TEXT("Validation scanned %d of %d mappings"), + ScanCount, + Mappings.Num())))); + bPageComplete = false; + } + + ErrorCount += ContextErrors; + WarningCount += ContextWarnings; + ContextResult->SetStringField(TEXT("asset_path"), Context->GetPathName()); + ContextResult->SetNumberField(TEXT("mapping_count"), Mappings.Num()); + ContextResult->SetNumberField(TEXT("mappings_scanned"), ScanCount); + ContextResult->SetNumberField(TEXT("mapping_scan_limit"), MappingScanLimit); + ContextResult->SetBoolField(TEXT("complete"), bContextComplete); + ContextResult->SetBoolField(TEXT("valid"), bContextComplete && ContextErrors == 0); + ContextResult->SetNumberField(TEXT("errors"), ContextErrors); + ContextResult->SetNumberField(TEXT("warnings"), ContextWarnings); + ContextResult->SetArrayField(TEXT("issues"), Issues); + ContextResults.Add(MakeShared(ContextResult)); + } + + const bool bAllContextsCovered = Start == 0 && End == ContextPaths.Num(); + const bool bComplete = bPageComplete && bAllContextsCovered; + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("valid"), bComplete && ErrorCount == 0); + Result->SetBoolField(TEXT("complete"), bComplete); + Result->SetBoolField(TEXT("page_complete"), bPageComplete); + Result->SetBoolField(TEXT("all_contexts_covered"), bAllContextsCovered); + Result->SetNumberField(TEXT("errors"), ErrorCount); + Result->SetNumberField(TEXT("warnings"), WarningCount); + Result->SetNumberField(TEXT("mapping_scan_limit"), MappingScanLimit); + Result->SetArrayField(TEXT("contexts"), ContextResults); + MonolithGASInputAssetActions::SetPageMetadata( + Result, + ContextPaths.Num(), + Offset, + Limit, + ContextResults.Num()); + return FMonolithActionResult::Success(Result); +} diff --git a/Source/MonolithGAS/Private/MonolithGASModule.cpp b/Source/MonolithGAS/Private/MonolithGASModule.cpp index b5759a3b1..3463c3dd8 100644 --- a/Source/MonolithGAS/Private/MonolithGASModule.cpp +++ b/Source/MonolithGAS/Private/MonolithGASModule.cpp @@ -9,6 +9,7 @@ #include "MonolithGASCueActions.h" #include "MonolithGASTargetActions.h" #include "MonolithGASInputActions.h" +#include "MonolithGASInputAssetActions.h" #include "MonolithGASInspectActions.h" #include "MonolithGASScaffoldActions.h" #include "MonolithGASUIBindingActions.h" @@ -18,15 +19,19 @@ DEFINE_LOG_CATEGORY(LogMonolithGAS); void FMonolithGASModule::StartupModule() { + FMonolithToolRegistry& Registry = FMonolithToolRegistry::Get(); + FMonolithGASInputAssetActions::RegisterActions(Registry); + const UMonolithSettings* Settings = GetDefault(); if (!Settings || !Settings->bEnableGAS) { + const int32 InputActionCount = Registry.GetActions(TEXT("input")).Num(); UE_LOG(LogMonolithGAS, Log, - TEXT("MonolithGAS: GAS integration disabled in settings")); + TEXT("MonolithGAS: GAS integration disabled in settings; %d input asset actions remain available"), + InputActionCount); return; } - FMonolithToolRegistry& Registry = FMonolithToolRegistry::Get(); FMonolithGASAbilityActions::RegisterActions(Registry); FMonolithGASAttributeActions::RegisterActions(Registry); FMonolithGASEffectActions::RegisterActions(Registry); @@ -47,20 +52,28 @@ void FMonolithGASModule::StartupModule() // dep is absent. See MonolithGASBulkFillAdapter.cpp for the split. FMonolithGASBulkFillAdapter::Register(); - int32 ActionCount = Registry.GetActions(TEXT("gas")).Num(); + const int32 GasActionCount = Registry.GetActions(TEXT("gas")).Num(); + const int32 InputActionCount = Registry.GetActions(TEXT("input")).Num(); const TCHAR* GbaStatus = #if WITH_GBA TEXT("available"); #else TEXT("not installed"); #endif - UE_LOG(LogMonolithGAS, Log, TEXT("MonolithGAS: Loaded (%d actions, GBA=%s)"), ActionCount, GbaStatus); + UE_LOG( + LogMonolithGAS, + Log, + TEXT("MonolithGAS: Loaded (%d gas actions, %d input actions, GBA=%s)"), + GasActionCount, + InputActionCount, + GbaStatus); } void FMonolithGASModule::ShutdownModule() { FMonolithGASBulkFillAdapter::Unregister(); FMonolithToolRegistry::Get().UnregisterNamespace(TEXT("gas")); + FMonolithToolRegistry::Get().UnregisterNamespace(TEXT("input")); } IMPLEMENT_MODULE(FMonolithGASModule, MonolithGAS) diff --git a/Source/MonolithGAS/Private/Tests/MonolithGASInputAssetActionsTests.cpp b/Source/MonolithGAS/Private/Tests/MonolithGASInputAssetActionsTests.cpp new file mode 100644 index 000000000..7bfff04f3 --- /dev/null +++ b/Source/MonolithGAS/Private/Tests/MonolithGASInputAssetActionsTests.cpp @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: MIT + +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR + +#include "AssetRegistry/AssetRegistryModule.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "InputAction.h" +#include "InputCoreTypes.h" +#include "InputMappingContext.h" +#include "InputModifiers.h" +#include "InputTriggers.h" +#include "Misc/Guid.h" +#include "Misc/PackageName.h" +#include "Modules/ModuleManager.h" +#include "MonolithToolRegistry.h" +#include "UObject/Package.h" +#include "UObject/UObjectGlobals.h" + +namespace MonolithGASInputAssetActionsTests +{ + const TArray RequiredActions = { + TEXT("list_input_actions"), + TEXT("get_input_action"), + TEXT("list_input_mapping_contexts"), + TEXT("get_input_mapping_context"), + TEXT("validate_input_mappings") + }; + + FString MakeObjectPath(const FString& PackagePath) + { + return PackagePath + TEXT(".") + FPackageName::GetLongPackageAssetName(PackagePath); + } + + FMonolithActionResult Execute( + const FString& Action, + const TSharedPtr& Params) + { + return FMonolithToolRegistry::Get().ExecuteAction(TEXT("input"), Action, Params); + } + + int32 GetInt(const TSharedPtr& Object, const TCHAR* Field, int32 Default = 0) + { + double Value = static_cast(Default); + if (Object.IsValid()) + { + Object->TryGetNumberField(Field, Value); + } + return static_cast(Value); + } + + bool GetBool(const TSharedPtr& Object, const TCHAR* Field, bool Default = false) + { + bool Value = Default; + if (Object.IsValid()) + { + Object->TryGetBoolField(Field, Value); + } + return Value; + } + + FString GetString( + const TSharedPtr& Object, + const TCHAR* Field, + const FString& Default = FString()) + { + FString Value = Default; + if (Object.IsValid()) + { + Object->TryGetStringField(Field, Value); + } + return Value; + } + + void CleanupAsset(const FString& PackagePath) + { + UObject* Asset = StaticFindObject( + UObject::StaticClass(), + nullptr, + *MakeObjectPath(PackagePath)); + UPackage* Package = FindPackage(nullptr, *PackagePath); + if (Asset) + { + if (UInputMappingContext* Context = Cast(Asset)) + { + Context->UnmapAll(); + } + FAssetRegistryModule::AssetDeleted(Asset); + const FName TransientName = MakeUniqueObjectName( + GetTransientPackage(), + Asset->GetClass(), + *FString::Printf(TEXT("MONOLITH_INPUT_TEST_%s"), *Asset->GetName())); + Asset->Rename( + *TransientName.ToString(), + GetTransientPackage(), + REN_DontCreateRedirectors | REN_NonTransactional); + Asset->ClearFlags(RF_Public | RF_Standalone); + Asset->MarkAsGarbage(); + } + if (Package) + { + Package->SetDirtyFlag(false); + Package->MarkAsGarbage(); + } + } + + template + TAsset* CreateAsset(const FString& PackagePath) + { + UPackage* Package = CreatePackage(*PackagePath); + if (!Package) + { + return nullptr; + } + TAsset* Asset = NewObject( + Package, + *FPackageName::GetLongPackageAssetName(PackagePath), + RF_Public | RF_Standalone | RF_Transactional); + if (Asset) + { + FAssetRegistryModule::AssetCreated(Asset); + } + return Asset; + } + + struct FScopedInputFixture + { + FString Root; + FString ActionAPath; + FString ActionBPath; + FString ContextPath; + UInputAction* ActionA = nullptr; + UInputAction* ActionB = nullptr; + UInputMappingContext* Context = nullptr; + + FScopedInputFixture() + { + Root = FString::Printf( + TEXT("/Game/Tests/Monolith/InputInspection/%s"), + *FGuid::NewGuid().ToString(EGuidFormats::Digits)); + ActionAPath = Root + TEXT("/IA_Primary"); + ActionBPath = Root + TEXT("/IA_Secondary"); + ContextPath = Root + TEXT("/IMC_Test"); + } + + ~FScopedInputFixture() + { + CleanupAsset(ContextPath); + CleanupAsset(ActionBPath); + CleanupAsset(ActionAPath); + } + + bool Create() + { + ActionA = CreateAsset(ActionAPath); + ActionB = CreateAsset(ActionBPath); + Context = CreateAsset(ContextPath); + if (!ActionA || !ActionB || !Context) + { + return false; + } + + ActionA->ValueType = EInputActionValueType::Axis2D; + ActionA->ActionDescription = FText::FromString(TEXT("Primary test action")); + ActionA->Triggers.Add(NewObject(ActionA)); + ActionA->Modifiers.Add(NewObject(ActionA)); + Context->ContextDescription = FText::FromString(TEXT("Read-only inspection fixture")); + Context->MapKey(ActionA, EKeys::SpaceBar); + Context->MapKey(ActionB, EKeys::SpaceBar); + + ActionA->GetOutermost()->SetDirtyFlag(false); + ActionB->GetOutermost()->SetDirtyFlag(false); + Context->GetOutermost()->SetDirtyFlag(false); + return true; + } + + bool PackagesAreClean() const + { + return ActionA + && ActionB + && Context + && !ActionA->GetOutermost()->IsDirty() + && !ActionB->GetOutermost()->IsDirty() + && !Context->GetOutermost()->IsDirty(); + } + }; + + TSharedPtr MakeAssetParams(const FString& AssetPath) + { + TSharedPtr Params = MakeShared(); + Params->SetStringField(TEXT("asset_path"), AssetPath); + return Params; + } + + TArray> MakeStringArray(std::initializer_list Values) + { + TArray> Result; + for (const FString& Value : Values) + { + Result.Add(MakeShared(Value)); + } + return Result; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FMonolithGASInputAssetRegistrationTest, + "Monolith.Input.Assets.Registration", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FMonolithGASInputAssetRegistrationTest::RunTest(const FString& /*Parameters*/) +{ + FMonolithToolRegistry& Registry = FMonolithToolRegistry::Get(); + bool bPassed = true; + for (const FString& Action : MonolithGASInputAssetActionsTests::RequiredActions) + { + bPassed &= TestTrue( + *FString::Printf(TEXT("input.%s is registered"), *Action), + Registry.HasAction(TEXT("input"), Action)); + } + bPassed &= TestEqual( + TEXT("The read-only input namespace owns exactly five actions"), + Registry.GetActions(TEXT("input")).Num(), + 5); + bPassed &= TestFalse( + TEXT("Mutation actions are not exposed by this surface"), + Registry.HasAction(TEXT("input"), TEXT("create_input_action"))); + + const FMonolithDispatcherAnnotations Annotations = + Registry.GetDispatcherAnnotations(TEXT("input")); + bPassed &= TestTrue(TEXT("input dispatcher is read-only"), Annotations.bReadOnlyHint); + bPassed &= TestTrue(TEXT("input dispatcher is idempotent"), Annotations.bIdempotentHint); + + for (const FMonolithActionInfo& Info : Registry.GetActions(TEXT("input"))) + { + if (Info.Action == TEXT("get_input_mapping_context")) + { + bPassed &= TestTrue( + TEXT("Mapping read schema publishes pagination"), + Info.ParamSchema.IsValid() + && Info.ParamSchema->HasField(TEXT("mapping_offset")) + && Info.ParamSchema->HasField(TEXT("mapping_limit"))); + } + if (Info.Action == TEXT("validate_input_mappings")) + { + bPassed &= TestTrue( + TEXT("Validation schema publishes scan bounds"), + Info.ParamSchema.IsValid() + && Info.ParamSchema->HasField(TEXT("mapping_scan_limit")) + && Info.ParamSchema->HasField(TEXT("limit"))); + } + } + return bPassed; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FMonolithGASInputAssetParamGuardTest, + "Monolith.Input.Assets.ParamGuards", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FMonolithGASInputAssetParamGuardTest::RunTest(const FString& /*Parameters*/) +{ + bool bPassed = true; + { + TSharedPtr Params = MakeShared(); + Params->SetNumberField(TEXT("limit"), 0); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("list_input_actions"), Params); + bPassed &= TestFalse(TEXT("Zero list limit is rejected"), Result.bSuccess); + bPassed &= TestEqual(TEXT("Zero list limit is invalid params"), Result.ErrorCode, -32602); + } + { + TSharedPtr Params = MakeShared(); + Params->SetNumberField(TEXT("asset_path"), 17); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("get_input_action"), Params); + bPassed &= TestFalse(TEXT("Non-string asset path is rejected"), Result.bSuccess); + bPassed &= TestEqual(TEXT("Non-string asset path is invalid params"), Result.ErrorCode, -32602); + } + { + TSharedPtr Params = MakeShared(); + Params->SetStringField(TEXT("asset_path"), TEXT("/Game/Input/IMC_Test.IMC_Other")); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("get_input_mapping_context"), Params); + bPassed &= TestFalse(TEXT("Mismatched object leaf is rejected"), Result.bSuccess); + bPassed &= TestEqual(TEXT("Mismatched object leaf is invalid params"), Result.ErrorCode, -32602); + } + { + TSharedPtr Params = MakeShared(); + Params->SetStringField(TEXT("asset_path"), TEXT("/Game/Input/IMC_Test")); + Params->SetNumberField(TEXT("mapping_limit"), 501); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("get_input_mapping_context"), Params); + bPassed &= TestFalse(TEXT("Mapping page over hard cap is rejected"), Result.bSuccess); + } + { + TSharedPtr Params = MakeShared(); + Params->SetArrayField( + TEXT("context_paths"), + MonolithGASInputAssetActionsTests::MakeStringArray({ TEXT("/Game/Input/IMC_Test") })); + Params->SetStringField(TEXT("path"), TEXT("/Game/Input")); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("validate_input_mappings"), Params); + bPassed &= TestFalse(TEXT("Mutually exclusive validation selectors are rejected"), Result.bSuccess); + } + { + TSharedPtr Params = MakeShared(); + Params->SetArrayField(TEXT("context_paths"), {}); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("validate_input_mappings"), Params); + bPassed &= TestFalse(TEXT("Explicit empty context list is rejected"), Result.bSuccess); + } + return bPassed; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FMonolithGASInputAssetReadbackTest, + "Monolith.Input.Assets.ReadbackAndValidation", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FMonolithGASInputAssetReadbackTest::RunTest(const FString& /*Parameters*/) +{ + MonolithGASInputAssetActionsTests::FScopedInputFixture Fixture; + if (!TestTrue(TEXT("Fixture assets were created"), Fixture.Create())) + { + return false; + } + + bool bPassed = true; + { + TSharedPtr Params = MakeShared(); + Params->SetStringField(TEXT("path"), Fixture.Root); + Params->SetNumberField(TEXT("limit"), 1); + Params->SetBoolField(TEXT("include_details"), true); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("list_input_actions"), Params); + bPassed &= TestTrue(TEXT("Input action list succeeds"), Result.bSuccess); + bPassed &= TestEqual( + TEXT("Input action list total"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("total")), + 2); + bPassed &= TestEqual( + TEXT("Input action page count"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("count")), + 1); + bPassed &= TestTrue( + TEXT("Input action page reports more"), + MonolithGASInputAssetActionsTests::GetBool(Result.Result, TEXT("has_more"))); + } + { + const FMonolithActionResult Result = MonolithGASInputAssetActionsTests::Execute( + TEXT("get_input_action"), + MonolithGASInputAssetActionsTests::MakeAssetParams(Fixture.ActionAPath)); + bPassed &= TestTrue(TEXT("Input action read succeeds"), Result.bSuccess); + bPassed &= TestEqual( + TEXT("Input action value type is preserved"), + MonolithGASInputAssetActionsTests::GetString(Result.Result, TEXT("value_type")), + FString(TEXT("Axis2D"))); + bPassed &= TestEqual( + TEXT("Input action trigger count"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("trigger_count")), + 1); + bPassed &= TestEqual( + TEXT("Input action modifier count"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("modifier_count")), + 1); + } + { + TSharedPtr Params = + MonolithGASInputAssetActionsTests::MakeAssetParams(Fixture.ContextPath); + Params->SetNumberField(TEXT("mapping_limit"), 1); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("get_input_mapping_context"), Params); + bPassed &= TestTrue(TEXT("Mapping context read succeeds"), Result.bSuccess); + bPassed &= TestEqual( + TEXT("Mapping context total mappings"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("mapping_count")), + 2); + bPassed &= TestEqual( + TEXT("Mapping context returned mappings"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("mappings_returned")), + 1); + bPassed &= TestTrue( + TEXT("Mapping context reports truncation"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("mappings_truncated"))); + bPassed &= TestTrue( + TEXT("Mapping context reports more"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("has_more_mappings"))); + } + { + TSharedPtr Params = MakeShared(); + Params->SetArrayField( + TEXT("context_paths"), + MonolithGASInputAssetActionsTests::MakeStringArray({ Fixture.ContextPath })); + Params->SetNumberField(TEXT("mapping_scan_limit"), 100); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("validate_input_mappings"), Params); + bPassed &= TestTrue(TEXT("Mapping validation succeeds"), Result.bSuccess); + bPassed &= TestTrue( + TEXT("Mapping validation is complete"), + MonolithGASInputAssetActionsTests::GetBool(Result.Result, TEXT("complete"))); + bPassed &= TestTrue( + TEXT("Duplicate-key warning does not invalidate"), + MonolithGASInputAssetActionsTests::GetBool(Result.Result, TEXT("valid"))); + bPassed &= TestEqual( + TEXT("Mapping validation error count"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("errors")), + 0); + bPassed &= TestEqual( + TEXT("Mapping validation warning count"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("warnings")), + 1); + } + { + TSharedPtr Params = MakeShared(); + Params->SetArrayField( + TEXT("context_paths"), + MonolithGASInputAssetActionsTests::MakeStringArray({ + Fixture.ContextPath, + Fixture.Root + TEXT("/ZZ_IMC_Missing") + })); + Params->SetNumberField(TEXT("limit"), 1); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("validate_input_mappings"), Params); + bPassed &= TestTrue(TEXT("Paginated validation returns structured output"), Result.bSuccess); + bPassed &= TestTrue( + TEXT("Returned validation page is complete"), + MonolithGASInputAssetActionsTests::GetBool(Result.Result, TEXT("page_complete"))); + bPassed &= TestFalse( + TEXT("One page does not cover all contexts"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("all_contexts_covered"), + true)); + bPassed &= TestFalse( + TEXT("Partial context coverage cannot claim complete"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("complete"), + true)); + bPassed &= TestFalse( + TEXT("Partial context coverage cannot claim valid"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("valid"), + true)); + bPassed &= TestTrue( + TEXT("Paginated validation reports another page"), + MonolithGASInputAssetActionsTests::GetBool(Result.Result, TEXT("has_more"))); + bPassed &= TestEqual( + TEXT("A complete clean page adds no errors"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("errors")), + 0); + } + { + TSharedPtr Params = MakeShared(); + Params->SetArrayField( + TEXT("context_paths"), + MonolithGASInputAssetActionsTests::MakeStringArray({ Fixture.ContextPath })); + Params->SetNumberField(TEXT("mapping_scan_limit"), 1); + const FMonolithActionResult Result = + MonolithGASInputAssetActionsTests::Execute(TEXT("validate_input_mappings"), Params); + bPassed &= TestTrue(TEXT("Bounded mapping validation returns structured output"), Result.bSuccess); + bPassed &= TestFalse( + TEXT("Bounded mapping validation reports incomplete"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("complete"), + true)); + bPassed &= TestFalse( + TEXT("Incomplete mapping validation cannot claim valid"), + MonolithGASInputAssetActionsTests::GetBool( + Result.Result, + TEXT("valid"), + true)); + bPassed &= TestEqual( + TEXT("Scan cutoff is an explicit error"), + MonolithGASInputAssetActionsTests::GetInt(Result.Result, TEXT("errors")), + 1); + } + + bPassed &= TestTrue(TEXT("Read-only calls leave fixture packages clean"), Fixture.PackagesAreClean()); + return bPassed; +} + +#endif // WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR diff --git a/Source/MonolithGAS/Public/MonolithGASInputAssetActions.h b/Source/MonolithGAS/Public/MonolithGASInputAssetActions.h new file mode 100644 index 000000000..181404e5a --- /dev/null +++ b/Source/MonolithGAS/Public/MonolithGASInputAssetActions.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MonolithToolRegistry.h" + +/** + * Read-only Enhanced Input asset discovery, bounded inspection, and validation. + * + * The `input` namespace is independent of the optional GAS authoring toggle: + * UInputAction and UInputMappingContext are engine Enhanced Input assets. These + * handlers never transact, save, mutate, or dirty the inspected packages. + */ +class MONOLITHGAS_API FMonolithGASInputAssetActions +{ +public: + static void RegisterActions(FMonolithToolRegistry& Registry); + +private: + static FMonolithActionResult HandleListInputActions(const TSharedPtr& Params); + static FMonolithActionResult HandleGetInputAction(const TSharedPtr& Params); + static FMonolithActionResult HandleListInputMappingContexts(const TSharedPtr& Params); + static FMonolithActionResult HandleGetInputMappingContext(const TSharedPtr& Params); + static FMonolithActionResult HandleValidateInputMappings(const TSharedPtr& Params); +};